feat(persistence): Shape B+ Phase B — VaultStore (de)serialize, exact recall preserved
Adds VaultStore.to_dict/from_dict on top of Phase A's array codec. Persists the versors (bit-exact via the codec), metadata, store_count, reproject_interval, and max_entries; rebuilds the derived _exact_index on load and leaves the lazy _matrix_cache None. Bright line (vault/store.py is a CLAUDE.md forbidden normalization site): the load path performs NO reprojection / normalization / repair — it restores the exact persisted bytes (already null-projected at their last live reproject boundary) and rebuilds only the pure index. Proven by a test asserting the restored versors are BIT-IDENTICAL to the originals (a reproject would change them via null_project) and that exact CGA recall — including the score==inf exact-match short-circuit — is identical after a save/load cycle. 5 new tests + INV-02 (normalize-not-called-outside-gate) + all vault tests pass (116 passed). Part of the A->E Shape B+ scope (Phase B).
This commit is contained in:
parent
09a766f9f1
commit
c0aebf142c
2 changed files with 138 additions and 0 deletions
91
tests/test_vaultstore_codec.py
Normal file
91
tests/test_vaultstore_codec.py
Normal file
|
|
@ -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) == []
|
||||||
|
|
@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from algebra.backend import vault_recall, vault_recall_batch
|
from algebra.backend import vault_recall, vault_recall_batch
|
||||||
from algebra.cga import null_project
|
from algebra.cga import null_project
|
||||||
|
from core.array_codec import decode_array, encode_array
|
||||||
from core.epistemic_state import EpistemicState
|
from core.epistemic_state import EpistemicState
|
||||||
from core.physics.energy import EnergyClass, EnergyProfile
|
from core.physics.energy import EnergyClass, EnergyProfile
|
||||||
from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus
|
from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus
|
||||||
|
|
@ -387,3 +388,49 @@ class VaultStore:
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._versors)
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue