diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 8cb9c2c6..e4e074d3 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -286,12 +286,12 @@ Current receipts: catalog, wire-message catalog discovery, raw snapshot, raw document put/delete, mutation receipt, fire-command receipt, database subscription changes, shard catalog, shard log catch-up, simulation consensus candidates, - and witness artifact bundle round-trips. The same test asks the Python peer - for CultMesh Verse catalog and peer exchange responses over the CultNet pipe, - then verifies the public `CultMeshDiscoveryClient` can fetch typed Python - Verse and peer descriptors from that live endpoint. It also verifies - `CultNetRawClient` can fetch the live Python peer's schema catalog, raw - snapshot, and shard catalog. + committed simulation fact snapshots, and witness artifact bundle round-trips. + The same test asks the Python peer for CultMesh Verse catalog and peer + exchange responses over the CultNet pipe, then verifies the public + `CultMeshDiscoveryClient` can fetch typed Python Verse and peer descriptors + from that live endpoint. It also verifies `CultNetRawClient` can fetch the + live Python peer's schema catalog, raw snapshot, and shard catalog. - `packages/cultcache-py/tests/test_cultcache.py` covers Python CultMesh Verse catalog, peer exchange, authority lease, stream negotiation, CultNet helper shapes, raw snapshot/shard-log application, simulation claim hashing, diff --git a/packages/cultcache-py/src/cultnet_py/interop_peer.py b/packages/cultcache-py/src/cultnet_py/interop_peer.py index 016c9833..9f3c73de 100644 --- a/packages/cultcache-py/src/cultnet_py/interop_peer.py +++ b/packages/cultcache-py/src/cultnet_py/interop_peer.py @@ -27,12 +27,15 @@ from cultcache_py import ( from cultcache_py.documents import DocumentDefinition from cultmesh_py import ( PEER_EXCHANGE_REQUEST, + SIMULATION_FACT_DOCUMENT_TYPE, VERSE_CATALOG_REQUEST, CultMeshPeerCard, CultMeshPeerCatalog, + CultMeshSimulationFact, CultMeshVerseCatalog, CultMeshVerseCompatibility, CultMeshVerseDescriptor, + simulation_fact_document, ) from .framing import read_frame, write_frame @@ -54,6 +57,7 @@ INTEROP_FIRE_RECEIPT_SCHEMA_VERSION = "cultnet.interop_fire_weapon_receipt.v0" WITNESS_ARTIFACT_BUNDLE_DOCUMENT_TYPE = "cultnet.witness_artifact_bundle" WITNESS_ARTIFACT_BUNDLE_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/contracts/cultnet.witness-artifact-bundle.schema.json" WITNESS_ARTIFACT_BUNDLE_SCHEMA_VERSION = "cultnet.witness_artifact_bundle.v0" +SIMULATION_FACT_SCHEMA_ID = simulation_fact_document.catalog_entry().schema_id INTEROP_WIRE_CONTRACT = "cultnet.schema.v0" DISCOVERY_PROBE_SCHEMA_VERSION = "cultnet.discovery_probe.v0" DISCOVERY_ANNOUNCE_SCHEMA_VERSION = "cultnet.discovery_announce.v0" @@ -475,9 +479,21 @@ def simulation_candidate_responses(state: PeerState, message: dict[str, Any]) -> witnesses = state.observations.setdefault(group, {}) witnesses[witness_id] = observation candidates = build_simulation_candidates(message.get("messageId", ""), list(witnesses.values())) + commit_simulation_facts(state, candidates) return candidates +def commit_simulation_facts(state: PeerState, candidates: list[dict[str, Any]]) -> None: + binding = state.bindings_by_schema_id[SIMULATION_FACT_SCHEMA_ID] + for candidate in candidates: + if not candidate.get("hasQuorum"): + continue + key = CultMeshSimulationFact.create_record_key(candidate) + if state.cache.get(binding.document, key) is not None: + continue + state.cache.put(binding.document, key, CultMeshSimulationFact.from_candidate(candidate)) + + def build_simulation_candidates(message_id: str, observations: list[dict[str, Any]]) -> list[dict[str, Any]]: by_claim: dict[str, list[dict[str, Any]]] = defaultdict(list) total_weight = sum(observation_weight(observation) for observation in observations) @@ -751,6 +767,7 @@ def default_peer_catalog(runtime_id: str) -> CultMeshPeerCatalog: def define_interop_documents(note_schema_id: str, note_schema_json: str) -> dict[str, Binding]: + simulation_entry = simulation_fact_document.catalog_entry() return { "note": binding(INTEROP_DOCUMENT_TYPE, note_schema_id, INTEROP_SCHEMA_VERSION, note_schema_json, note_slots, note_from_slots), "mutationIntent": binding(INTEROP_MUTATION_INTENT_DOCUMENT_TYPE, INTEROP_MUTATION_INTENT_SCHEMA_ID, INTEROP_MUTATION_INTENT_SCHEMA_VERSION, "{}", mutation_intent_slots, mutation_intent_from_slots), @@ -765,6 +782,11 @@ def define_interop_documents(note_schema_id: str, note_schema_json: str) -> dict witness_artifact_bundle_slots, witness_artifact_bundle_from_slots, ), + "simulationFact": Binding( + document=simulation_fact_document, + schema_id=simulation_entry.schema_id, + payload_schema_version=simulation_entry.schema_version, + ), } @@ -799,7 +821,7 @@ def hello_message(state: PeerState) -> dict[str, Any]: "runtimeKind": state.runtime_kind, "agentId": state.agent_id, "displayName": state.display_name, - "supportedDocumentTypes": [INTEROP_DOCUMENT_TYPE, WITNESS_ARTIFACT_BUNDLE_DOCUMENT_TYPE], + "supportedDocumentTypes": [INTEROP_DOCUMENT_TYPE, WITNESS_ARTIFACT_BUNDLE_DOCUMENT_TYPE, SIMULATION_FACT_DOCUMENT_TYPE], "supportedMutationContracts": [{ "documentType": INTEROP_DOCUMENT_TYPE, "payloadSchemaVersion": INTEROP_SCHEMA_VERSION, @@ -846,6 +868,18 @@ def catalog_response(state: PeerState, request: dict[str, Any]) -> dict[str, Any "contentHash": WITNESS_ARTIFACT_BUNDLE_SCHEMA_ID, "schemaJson": witness_artifact_bundle_schema_json() if include_schema_json else None, }) + if (not schema_ids or SIMULATION_FACT_SCHEMA_ID in schema_ids) and (not kinds or "document_payload" in kinds): + entry = simulation_fact_document.catalog_entry() + schemas.append({ + "schemaId": entry.schema_id, + "kind": "document_payload", + "schemaVersion": entry.schema_version, + "documentType": SIMULATION_FACT_DOCUMENT_TYPE, + "title": "CultMesh Simulation Fact", + "wireContracts": [INTEROP_WIRE_CONTRACT], + "contentHash": entry.content_hash, + "schemaJson": entry.canonical_schema_json if include_schema_json else None, + }) return {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request.get("messageId", ""), "schemas": schemas} diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index 8e734d04..f327f2d0 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -30,6 +30,7 @@ const tsPeerScript = resolve(cultNetTsRoot, "dist-test", "test", "interop", "cul const interopSchemaPath = resolve(cultNetTsRoot, "integration", "contracts", "cultnet.interop-note.schema.json"); const interopNoteSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json"; const witnessArtifactBundleSchemaId = "https://github.com/GameCult/cultnet-ts/contracts/cultnet.witness-artifact-bundle.schema.json"; +const simulationFactSchemaId = "gamecult.mesh.simulation_fact"; const csharpProjectPath = resolve( cultLibRoot, "tests", @@ -476,6 +477,24 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state assert.equal(pythonCandidate.totalWeight, 1); assert.equal(pythonCandidate.hasQuorum, true); assert.equal(pythonCandidate.confidence, 1); + const simulationFactKey = createSimulationFactKey(pythonCandidate); + const pythonFactSnapshot = await requestPythonSnapshot(pythonPort, { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "python-simulation-fact-snapshot", + schemaIds: [simulationFactSchemaId], + recordKeys: [simulationFactKey], + }); + assert.equal(pythonFactSnapshot.schemaVersion, "cultnet.snapshot_response_raw.v0"); + assert.equal(pythonFactSnapshot.documents[0].schemaId, simulationFactSchemaId); + assert.equal(pythonFactSnapshot.documents[0].recordKey, simulationFactKey); + const simulationFactSlots = decode(pythonFactSnapshot.documents[0].payload) as any[]; + assert.equal(simulationFactSlots[0], simulationFactKey.slice("simulation:".length)); + assert.equal(simulationFactSlots[1], "interop"); + assert.equal(simulationFactSlots[3], 42); + assert.equal(simulationFactSlots[4], "python-target"); + assert.equal(simulationFactSlots[5], "hit"); + assert.equal(simulationFactSlots[6], claimHash); + assert.equal(simulationFactSlots[8], 1); const pythonWitness = await putAndSnapshotPythonWitnessBundle(pythonPort, { schemaVersion: "cultnet.document_put_raw.v0", @@ -1083,6 +1102,48 @@ function computeSimulationClaimHash(...parts: string[]): string { return createHash("sha256").update(parts.join("\x1f"), "utf8").digest("hex"); } +function createSimulationFactKey(candidate: any): string { + const factId = createHash("sha256") + .update([ + candidate.shardId, + String(candidate.shardEpoch), + String(candidate.frame), + candidate.subjectId, + candidate.claimKind, + ].join("\x1f"), "utf8") + .digest("hex"); + return `simulation:${factId}`; +} + +async function requestPythonSnapshot(port: number, request: Record): Promise { + const socket = connectTcp(port, "127.0.0.1"); + const framer = new LengthPrefixedMessageFramer(); + await once(socket, "connect"); + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error("Timed out waiting for Python snapshot response.")); + }, 5000); + + socket.on("data", (chunk) => { + for (const frame of framer.push(chunk)) { + const decoded = decode(frame) as any; + if (decoded?.schemaVersion === "cultnet.snapshot_response_raw.v0") { + clearTimeout(timeout); + socket.end(); + resolve(decoded); + } + } + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + socket.write(encodeFrame(encode(request))); + }); +} + async function putAndSnapshotPythonWitnessBundle(port: number, put: Record): Promise<{ catalog: any; snapshot: any }> { const socket = connectTcp(port, "127.0.0.1"); const framer = new LengthPrefixedMessageFramer();