diff --git a/tests/test_vaultstore_codec.py b/tests/test_vaultstore_codec.py new file mode 100644 index 00000000..c5e909fc --- /dev/null +++ b/tests/test_vaultstore_codec.py @@ -0,0 +1,91 @@ +"""VaultStore round-trip — Shape B+ Phase B. + +Exit gate: exact CGA recall is identical after a save/load cycle, and the +restored versors are BIT-IDENTICAL to the originals — which proves the load path +did NOT reproject/normalize them (``vault/store.py`` is a CLAUDE.md forbidden +normalization site; persistence must be pure (de)serialization). The derived +``_exact_index`` is rebuilt on load and the lazy ``_matrix_cache`` stays None. +""" + +from __future__ import annotations + +import json + +import numpy as np + +from teaching.epistemic import EpistemicStatus +from vault.store import VaultStore + + +def _versors(n: int = 6) -> list[np.ndarray]: + out = [] + for i in range(n): + v = np.zeros(32, dtype=np.float32) + v[0] = 1.0 + v[(i % 31) + 1] = 0.1 * (i + 1) # distinct, near-identity + out.append(v) + return out + + +def _populated_store() -> VaultStore: + store = VaultStore(reproject_interval=0) # no auto-reproject; isolate persistence + for i, v in enumerate(_versors()): + store.store( + v, + {"turn": i, "role": "user" if i % 2 == 0 else "assistant"}, + epistemic_status=EpistemicStatus.SPECULATIVE, + ) + return store + + +def test_vaultstore_round_trip_preserves_exact_recall() -> None: + store = _populated_store() + query = _versors()[2] + before = store.recall(query, top_k=5) + + restored = VaultStore.from_dict(store.to_dict()) + after = restored.recall(query, top_k=5) + + assert [(r["index"], r["score"]) for r in before] == [ + (r["index"], r["score"]) for r in after + ] + assert [r["metadata"] for r in before] == [r["metadata"] for r in after] + assert [r["versor"].tobytes() for r in before] == [ + r["versor"].tobytes() for r in after + ] + + +def test_vaultstore_restore_does_not_reproject_versors() -> None: + # Bit-identical versors prove the load path called no null_project/normalizer. + store = _populated_store() + restored = VaultStore.from_dict(store.to_dict()) + assert len(restored) == len(store) + for original, recovered in zip(store._versors, restored._versors): + assert recovered.tobytes() == original.tobytes() + assert recovered.dtype == np.float32 + + +def test_vaultstore_round_trip_is_json_safe_and_preserves_scalars() -> None: + store = _populated_store() + restored = VaultStore.from_dict(json.loads(json.dumps(store.to_dict()))) + assert len(restored) == len(store) + assert restored.store_count == store.store_count + assert restored.reproject_interval == store.reproject_interval + + +def test_vaultstore_restore_rebuilds_exact_match_index() -> None: + # The exact-match short-circuit (score == inf) must work after restore, + # which requires _exact_index to be rebuilt over the restored bytes. + store = _populated_store() + restored = VaultStore.from_dict(store.to_dict()) + exact_query = _versors()[3] + hits = restored.recall(exact_query, top_k=3) + assert hits[0]["score"] == float("inf") # exact match found via rebuilt index + + +def test_empty_vaultstore_round_trips() -> None: + store = VaultStore(reproject_interval=50, max_entries=10) + restored = VaultStore.from_dict(store.to_dict()) + assert len(restored) == 0 + assert restored.reproject_interval == 50 + assert restored.recall(_versors()[0], top_k=3) == [] diff --git a/vault/store.py b/vault/store.py index 54ad614d..ee762c50 100644 --- a/vault/store.py +++ b/vault/store.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING import numpy as np from algebra.backend import vault_recall, vault_recall_batch from algebra.cga import null_project +from core.array_codec import decode_array, encode_array from core.epistemic_state import EpistemicState from core.physics.energy import EnergyClass, EnergyProfile from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus @@ -387,3 +388,49 @@ class VaultStore: def __len__(self) -> int: return len(self._versors) + + def to_dict(self) -> dict: + """Serialize the vault to a bit-exact, JSON-safe dict (Shape B+ Phase B). + + Pure (de)serialization, NOT normalization (``vault/store.py`` is a + CLAUDE.md forbidden normalization site): the persisted versors are the + exact bytes currently in the store — already null-projected at their last + reproject boundary during the live session — encoded losslessly via the + array codec. The derived ``_exact_index`` and the lazy ``_matrix_cache`` + are NOT persisted; they are rebuilt deterministically on load. Metadata + is assumed JSON-safe (it is, by construction: primitives only). + """ + return { + "versors": [encode_array(v) for v in self._versors], + "metadata": [dict(m) for m in self._metadata], + "store_count": int(self._store_count), + "reproject_interval": int(self._reproject_interval), + "max_entries": self._max_entries, + } + + @classmethod + def from_dict(cls, payload: dict) -> "VaultStore": + """Reconstruct a VaultStore from ``to_dict`` output. + + The load path performs NO reprojection / normalization / repair: it + restores the exact persisted versors (bit-identical, so exact CGA recall + is preserved) and rebuilds only the derived ``_exact_index``. The lazy + ``_matrix_cache`` is left None and rebuilt on the first recall. This is + the bright line — restoring bytes is not a normalization site. + """ + store = cls( + reproject_interval=int(payload["reproject_interval"]), + max_entries=payload["max_entries"], + ) + store._versors = deque( + (decode_array(v) for v in payload["versors"]), + maxlen=store._max_entries, + ) + store._metadata = deque( + (dict(m) for m in payload["metadata"]), + maxlen=store._max_entries, + ) + store._store_count = int(payload["store_count"]) + store._rebuild_index() # pure: key -> indices over the restored exact bytes + store._matrix_cache = None # derived; lazily rebuilt on first recall + return store