diff --git a/packages/cultcache-py/PARITY.md b/packages/cultcache-py/PARITY.md index d8abfc9f..42155a13 100644 --- a/packages/cultcache-py/PARITY.md +++ b/packages/cultcache-py/PARITY.md @@ -21,6 +21,10 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti. The live interop harness sends Python a simulation observation, receives a consensus candidate, then requests the committed `gamecult.mesh.simulation_fact` document back over the raw snapshot lane and checks its MessagePack slots. +- Python CultMesh prediction reconciliation: + The live interop harness runs a Python CultMesh session client, writes a local + prediction, pulls the live Python peer shard log, applies the authoritative + entry, and verifies the session reports the record as reconciled. - Python package health: `cultcache-py-verify --json` checks public exports, `py.typed` markers, and benchmark sanity for Python-owned hot paths. diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 1969250c..b512c31b 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -289,12 +289,13 @@ 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, - 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. + committed simulation fact snapshots, Python session prediction reconciliation, + 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/replication.py b/packages/cultcache-py/src/cultnet_py/replication.py index cb60eb9b..e544a1b5 100644 --- a/packages/cultcache-py/src/cultnet_py/replication.py +++ b/packages/cultcache-py/src/cultnet_py/replication.py @@ -113,12 +113,17 @@ def apply_shard_log_response( change_kind = entry.get("changeKind") if change_kind in ("added", "updated") and isinstance(entry.get("put"), dict): put = entry["put"] + schema_id = str(put["document"]["schemaId"]) + if schema_id not in documents_by_schema_id: + continue result = apply_raw_document_record(cache, documents_by_schema_id, put["document"]) applied.append(CultNetAppliedRecord(result.schema_id, result.record_key, str(change_kind), result.value)) elif change_kind == "removed" and isinstance(entry.get("delete"), dict): delete = entry["delete"] schema_id = str(delete["schemaId"]) record_key = str(delete["recordKey"]) + if schema_id not in documents_by_schema_id: + continue document = documents_by_schema_id[schema_id] cache.delete(document, record_key) applied.append(CultNetAppliedRecord(schema_id, record_key, "removed", None)) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index c376b924..22753d95 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -486,6 +486,23 @@ class CultCacheTests(unittest.TestCase): "shardEpoch": 1, "resyncRequired": False, "entries": [ + { + "sequence": 0, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-unknown", + "document": { + "schemaId": "replica.unknown.v1", + "recordKey": "unknown:1", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("ignored", "unknown", 0)), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + }, { "sequence": 1, "changeKind": "updated", diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index f327f2d0..739183c0 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -312,6 +312,11 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state assert.equal(pythonCultMeshNodeEmit.note.authorRuntimeId, "python-node-emit"); assert.equal(pythonCultMeshNodeEmit.note.title, "Python node emitted raw put"); + const pythonPrediction = await runPythonCultMeshPredictionReconcile(pythonPort); + assert.ok(pythonPrediction.reconciledKeys.includes("note:python-node-emit")); + assert.equal(pythonPrediction.note.title, "Python node emitted raw put"); + assert.equal(pythonPrediction.note.authorRuntimeId, "python-node-emit"); + const pythonSubscription = await runPythonCultNetSubscription(pythonPort); assert.equal(pythonSubscription.changeKind, "added"); assert.equal(pythonSubscription.recordKey, "note:python-sub-client"); @@ -950,6 +955,60 @@ async function runPythonCultNetSubscription(port: number): Promise { return JSON.parse(stdout); } +async function runPythonCultMeshPredictionReconcile(port: number): Promise { + const schemaPathJson = JSON.stringify(interopSchemaPath); + const script = [ + "import json", + "import tempfile", + "from pathlib import Path", + "from cultcache_py import define_database_entry_type", + "from cultmesh_py import CultMesh, CultMeshGameSessionOptions", + "from cultnet_py import CultNetClientAuthorityScope, CultNetRawClient", + `schema_json = Path(${schemaPathJson}).read_text(encoding='utf-8')`, + "note_doc = define_database_entry_type(", + " 'cultnet.interop-note',", + " [('schemaVersion', 0), ('documentId', 1), ('authorRuntimeId', 2), ('title', 3), ('body', 4), ('tags', 5, [])],", + ` schema_id='${interopNoteSchemaId}',`, + " schema_version='cultnet.interop_note.v0',", + " canonical_schema_json=schema_json,", + ")", + "with tempfile.TemporaryDirectory() as tmp:", + " node = CultMesh.create_node(Path(tmp) / 'prediction.cc', runtime_id='python-predict-client')", + " node.register_document(note_doc)", + " session = CultMesh.create_game_session(", + " node,", + " CultMeshGameSessionOptions(client_authority_scopes=(", + ` CultNetClientAuthorityScope('python-predict-client', schema_ids=('${interopNoteSchemaId}',), key_prefix='note:python-node-emit'),`, + " )),", + " )", + " session.predict(note_doc, 'note:python-node-emit', {", + " 'schemaVersion': 'cultnet.interop_note.v0',", + " 'documentId': 'note:python-node-emit',", + " 'authorRuntimeId': 'python-predict-client',", + " 'title': 'Predicted Python node emit',", + " 'body': 'local predicted body',", + " 'tags': ['python', 'prediction'],", + " })", + ` client = CultNetRawClient('127.0.0.1', ${port}, timeout_seconds=4.0)`, + " changes = session.sync_shard_log(client, shard_id='interop', shard_epoch=1, after_sequence=0)", + " note = node.get_required(note_doc, 'note:python-node-emit')", + " print(json.dumps({", + " 'reconciledKeys': [change.record_key for change in changes if change.change_kind == 'reconciled'],", + " 'changes': [{'kind': change.change_kind, 'key': change.record_key} for change in changes],", + " 'note': note,", + " }))", + ].join("\n"); + const { stdout } = await execFileAsync(pythonCommand, ["-c", script], { + cwd: cultcachePyRoot, + env: { + ...process.env, + PYTHONPATH: cultcachePySrc, + }, + timeout: 8000, + }); + return JSON.parse(stdout); +} + async function requestPythonDatabaseChange(port: number, subscribe: Record, put: Record): Promise { const socket = connectTcp(port, "127.0.0.1"); const framer = new LengthPrefixedMessageFramer();