"""Plan 05-12 — store <-> graph write-sync hook tests (RED scaffold). ``build_runtime_graph`` registers a ``_graph_sync_hook`` on the store so every ``insert`` / ``update`` / ``delete`` mutates the in-RAM graph's node payload. Hook exceptions are logged to stderr as structured events but NEVER break the underlying store write — the store is authoritative. Covered contracts: B1 — ``store.insert`` with registered hook adds the graph node + payload. B2 — ``store.update`` mutates the node's embedding / surface payload. B3 — ``store.delete`` removes the node from the graph. B4 — hook that raises does not break ``store.insert`` — write completes, stderr carries a structured ``graph_sync_failed`` event. B5 — cold start: after save/try_load round-trip the node payload blob restores every node attribute from cache. B6 — CACHE_VERSION bump from "05-09-v1" -> "05-12-v1" invalidates the old cache cleanly (forward-compat fence). """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 import pytest from iai_mcp import retrieve, runtime_graph_cache from iai_mcp.store import MemoryStore from iai_mcp.types import MemoryRecord # --------------------------------------------------------------------------- fixtures @pytest.fixture(autouse=True) def _isolated_keyring(monkeypatch: pytest.MonkeyPatch): import keyring as _keyring fake: dict[tuple[str, str], str] = {} monkeypatch.setattr(_keyring, "get_password", lambda s, u: fake.get((s, u))) monkeypatch.setattr( _keyring, "set_password", lambda s, u, p: fake.__setitem__((s, u), p) ) monkeypatch.setattr( _keyring, "delete_password", lambda s, u: fake.pop((s, u), None) ) yield fake @pytest.fixture def store(tmp_path: Path) -> MemoryStore: s = MemoryStore(path=tmp_path / "lancedb") s.root = tmp_path return s def _make_record( store: MemoryStore, text: str = "hello", vec_seed: float = 0.1, ) -> MemoryRecord: now = datetime.now(timezone.utc) return MemoryRecord( id=uuid4(), tier="episodic", literal_surface=text, aaak_index="", embedding=[vec_seed] * store.embed_dim, community_id=None, centrality=0.0, detail_level=2, pinned=False, stability=0.0, difficulty=0.0, last_reviewed=None, never_decay=False, never_merge=False, provenance=[], created_at=now, updated_at=now, tags=["t"], language="en", ) # ---------------------------------------------------------------- B1: insert def test_B1_insert_updates_graph_node(store): """B1: store.insert while a hook is registered adds node + payload.""" # Seed one record so build_runtime_graph has something to register with. seed = _make_record(store, "seed", 0.5) store.insert(seed) graph, _a, _rc = retrieve.build_runtime_graph(store) assert str(seed.id) in graph._nx.nodes # Now insert a second record; the hook should mirror it to the graph. new_rec = _make_record(store, "freshly-inserted", 0.3) store.insert(new_rec) assert str(new_rec.id) in graph._nx.nodes node = graph._nx.nodes[str(new_rec.id)] assert node.get("surface") == "freshly-inserted" assert "embedding" in node # ---------------------------------------------------------------- B2: update def test_B2_update_mutates_node_payload(store): """B2: store.update rewrites the node's embedding + surface on the graph.""" rec = _make_record(store, "before-update", 0.2) store.insert(rec) graph, _a, _rc = retrieve.build_runtime_graph(store) node_before = graph._nx.nodes[str(rec.id)] assert node_before["surface"] == "before-update" # Mutate surface and embedding. rec.literal_surface = "after-update" rec.embedding = [0.9] * store.embed_dim store.update(rec) node_after = graph._nx.nodes[str(rec.id)] assert node_after["surface"] == "after-update" # embedding replaced (first element is 0.9 now) assert list(node_after["embedding"])[0] == pytest.approx(0.9) # ---------------------------------------------------------------- B3: delete def test_B3_delete_removes_node(store): """B3: store.delete drops the node from the graph.""" rec = _make_record(store, "to-be-deleted", 0.4) store.insert(rec) graph, _a, _rc = retrieve.build_runtime_graph(store) assert str(rec.id) in graph._nx.nodes store.delete(rec.id) assert str(rec.id) not in graph._nx.nodes # ---------------------------------------------------------------- B4: hook robustness def test_B4_hook_exception_does_not_break_store_insert(store, capsys): """B4: a raising hook must never break store.insert; stderr logs a structured ``graph_sync_failed`` event.""" def _bad_hook(op, record): raise RuntimeError("hook is sad") store.register_graph_sync_hook(_bad_hook) rec = _make_record(store, "store-write-is-authoritative", 0.15) store.insert(rec) # must not raise # Verify the record actually landed in LanceDB. roundtrip = store.get(rec.id) assert roundtrip is not None assert roundtrip.literal_surface == "store-write-is-authoritative" # Structured stderr event logged. captured = capsys.readouterr() assert "graph_sync_failed" in captured.err # JSON parseability of at least one stderr line. found = False for line in captured.err.splitlines(): try: payload = json.loads(line) if payload.get("event") == "graph_sync_failed": assert payload.get("op") == "insert" found = True break except (ValueError, TypeError): continue assert found, "expected a JSON graph_sync_failed event on stderr" # ---------------------------------------------------------------- B5: cold start def test_B5_cold_start_restores_node_payload_from_cache(store): """B5: after save/try_load, build_runtime_graph rehydrates node attrs from the cache without re-reading all records.""" rec = _make_record(store, "cached-payload", 0.25) store.insert(rec) # First build — writes the v2 cache with node_payload blob. graph1, _a, _rc = retrieve.build_runtime_graph(store) node1 = graph1._nx.nodes[str(rec.id)] expected_surface = node1["surface"] expected_emb = list(node1["embedding"]) # Inspect via try_load (cache is encrypted under v3 sidecar per Phase 07.9 # W3 / D-03; raw file is ciphertext, so json.load on it would fail). loaded = runtime_graph_cache.try_load(store) assert loaded is not None, "cache must be loadable" _assignment, _rich_club, node_payload, _max_degree = loaded assert node_payload is not None, "cache is missing node_payload blob" assert str(rec.id) in node_payload # Rebuild — cache HIT must rehydrate payload without scanning store.all_records. graph2, _a, _rc = retrieve.build_runtime_graph(store) node2 = graph2._nx.nodes[str(rec.id)] assert node2["surface"] == expected_surface assert list(node2["embedding"]) == expected_emb # ---------------------------------------------------------------- B6: version bump def test_B6_cache_version_bump_invalidates_old_cache(store): """B6: CACHE_VERSION is "05-12-v1" — old "05-09-v1" caches invalidate cleanly on try_load. """ # Plant an old-format cache file manually. cache_path = runtime_graph_cache._cache_path(store) cache_path.parent.mkdir(parents=True, exist_ok=True) with cache_path.open("w") as f: json.dump( { "cache_version": "05-09-v1", # legacy "key": [0, 0, 4, store.embed_dim, "05-09-v1"], "assignment": { "node_to_community": {}, "community_centroids": {}, "modularity": 0.0, "backend": "flat", "top_communities": [], "mid_regions": {}, }, "rich_club": [], "saved_at": "2026-01-01T00:00:00+00:00", }, f, ) # CACHE_VERSION constant is the current one (Phase 07.9 W3 / bump # to "07-09-v3" with AES-256-GCM sidecar). Legacy 05-09 / 05-12 / 05-13 # / 06-02 cache files are rejected. assert runtime_graph_cache.CACHE_VERSION == "07-09-v3" # try_load on the old cache returns None (mismatch). assert runtime_graph_cache.try_load(store) is None