Files
ely-eye/backend/tests/test_kv_fingerprint.py
T
ZacharyZhang-NY 9fe13fffb3 Make a cartridge a scope you mount, not only a bundle you export
A cartridge used to be an export: materialize it, hash it, and never let it
change behaviour again. The transactive-memory promise needs the opposite —
mounting the home cartridge must make the work cartridge's memories
unfindable, and appending to a cartridge must make a new memory findable
through it.

Cartridge scope (schema v2 -> v3):
- cartridge_atoms (which atoms are in which cartridge) and cartridge_mounts
  (which cartridges this machine has mounted now); both rebuilt on reopen,
  because a mount is a machine-local state, not a fact about the data.
- mount / switch / unmount / append refuse a cartridge that was built for a
  different runtime, naming every differing field instead of a boolean.
- append rewrites atoms.parquet and sparse_index.bm25.zst, recomputes DNA and
  the KV fingerprint, and keeps the cartridges row in one SQLite transaction;
  the files are written first so a failed export cannot leave a half-cartridge
  in the database.
- RetrievalService.search() now ranks inside the mounted scope by default, and
  an explicit cartridge_ids filter overrides the mount. Nothing mounted means
  the whole library, so an empty store behaves as it did before.
- forget() removes the forgotten atoms from every cartridge's membership:
  forgetting must not leave a memory retrievable through a cartridge.

KV fingerprint (kv_fingerprint.py, stored in the manifest):
- model / tokenizer / chat-template hashes, the RoPE block (factor,
  original_max_position_embeddings, mrope_interleaved, mrope_section,
  partial_rotary_factor), the adapter hash, the context length, the engine
  version, and the fingerprint format version.
- A snapshot we cannot read is recorded as unverifiable and listed in
  unverifiable_fields; a blind machine refuses to mount rather than pretending
  the runtime matches, because a wrong RoPE factor silently corrupts retrieval.

Two defects found by running the new tests, not by reading:
- atoms.parquet could not export a memory at all: pyarrow refuses a struct with
  no child field, so metadata={} failed the whole table. The six JSON columns
  are now written as JSON text and decoded by the cartridge reader.
- materialize() inserted cartridge membership before the cartridges row existed,
  so the foreign key rejected every cartridge.

api.py crossed 500 lines, so the cartridge and mount routes moved to
api_cartridges.py. That module must not use `from __future__ import
annotations`: FastAPI then resolves Depends(settings_dep) against module
globals, where the injected dependency is only a parameter of register(), and
every route starts demanding settings as a query parameter.

Verification: 138 pytest passed (26 new: 10 fingerprint, 16 scope), Go
build/vet/gofmt/test clean, and scripts/mcp_write_smoke.sh now proves the whole
loop across two processes - mount narrows search, a memory written after the
upload is invisible until append_to_cartridge, unmount restores the library,
and rewriting the RoPE factor makes mount_cartridge refuse with "rope: factor:
cartridge 4.0 vs local 2.0". ruff clean on every touched file (the 20 remaining
package errors are in files this change did not touch; HEAD had 25). mypy: one
error, the pre-existing np.savez stub artifact at cartridge_assets.py:329
(HEAD:320). Repo pyright: 62 errors, unchanged from the p0-3 ledger.
2026-10-07 16:40:27 -04:00

139 lines
5.5 KiB
Python

"""KV bytes are only valid under the settings that produced them, so the header must say so."""
from __future__ import annotations
import json
from collections.abc import Callable
from importlib import metadata
from pathlib import Path
from typing import Any
import pytest
from ely_eye.config import Settings
from ely_eye.kv_fingerprint import (
FORMAT_SEMVER,
KvFingerprint,
adapter_hashes,
compute_fingerprint,
fingerprint_diff,
unverifiable_fields,
)
def settings_for(model_dir: Path | None, home: Path, **overrides: Any) -> Settings:
fields: dict[str, Any] = {"runtime_backend": "transformers", **overrides}
return Settings(home=home, model_dir=model_dir, **fields)
def test_a_readable_snapshot_leaves_nothing_unverifiable(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
fingerprint = compute_fingerprint(
settings_for(write_model_fixture(tmp_path / "model"), tmp_path)
)
assert unverifiable_fields(fingerprint) == []
assert fingerprint.format_semver == FORMAT_SEMVER
assert fingerprint.rope["factor"] == 4.0
assert fingerprint.engine_version == metadata.version("transformers")
def test_a_machine_without_the_model_says_what_it_cannot_prove(tmp_path: Path) -> None:
gaps = unverifiable_fields(compute_fingerprint(settings_for(None, tmp_path)))
assert gaps == ["model_hash", "tokenizer_hash", "chat_template_hash", "rope"]
def test_an_engine_that_is_not_installed_is_reported_not_guessed(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
settings = settings_for(
write_model_fixture(tmp_path / "model"), tmp_path, runtime_backend="sglang"
)
assert "engine_version" in unverifiable_fields(compute_fingerprint(settings))
def test_different_weights_change_the_model_hash_and_are_named(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
model = write_model_fixture(tmp_path / "model")
before = compute_fingerprint(settings_for(model, tmp_path))
(model / "model.safetensors.index.json").write_text(
json.dumps({"metadata": {}, "weight_map": {"model-00002.safetensors": "0"}}),
encoding="utf-8",
)
differences = fingerprint_diff(before, compute_fingerprint(settings_for(model, tmp_path)))
assert len(differences) == 1
assert differences[0].startswith("model_hash: cartridge ")
def test_a_rope_change_names_the_field_it_moves(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
model = write_model_fixture(tmp_path / "model")
before = compute_fingerprint(settings_for(model, tmp_path))
write_model_fixture(model, rope_parameters={"rope_type": "yarn", "factor": 2.0})
differences = fingerprint_diff(before, compute_fingerprint(settings_for(model, tmp_path)))
rope = [line for line in differences if line.startswith("rope: ")]
assert len(rope) == 1
assert "factor: cartridge 4.0 vs local 2.0" in rope[0]
assert any(line.startswith("model_hash: ") for line in differences)
def test_a_template_in_a_sidecar_file_hashes_the_same(
tmp_path: Path, write_model_fixture: Callable[..., Path], chat_template: str
) -> None:
in_config = write_model_fixture(tmp_path / "a")
sidecar = write_model_fixture(tmp_path / "b", chat_template=None)
(sidecar / "chat_template.json").write_text(
json.dumps({"chat_template": chat_template}), encoding="utf-8"
)
assert compute_fingerprint(settings_for(in_config, tmp_path)).chat_template_hash == (
compute_fingerprint(settings_for(sidecar, tmp_path)).chat_template_hash
)
def test_adapter_weights_are_hashed_by_kind(tmp_path: Path) -> None:
adapters = tmp_path / "adapters" / "hme-router"
adapters.mkdir(parents=True)
(adapters / "adapter_model.safetensors").write_bytes(b"weights-one")
found = adapter_hashes(tmp_path / "adapters")
assert list(found) == ["hme-router"]
(adapters / "adapter_model.safetensors").write_bytes(b"weights-two")
assert found != adapter_hashes(tmp_path / "adapters")
def test_a_missing_adapter_shows_up_as_absent(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
adapters = tmp_path / "adapters" / "hme-router"
adapters.mkdir(parents=True)
(adapters / "adapter_model.safetensors").write_bytes(b"weights")
model = write_model_fixture(tmp_path / "model")
stored = compute_fingerprint(settings_for(model, tmp_path), tmp_path / "adapters")
live = compute_fingerprint(settings_for(model, tmp_path), tmp_path / "gone")
differences = fingerprint_diff(stored, live)
assert len(differences) == 1
assert differences[0].startswith("adapters: hme-router: cartridge ")
assert differences[0].endswith("vs local absent")
def test_a_fingerprint_format_change_refuses_the_mount(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
live = compute_fingerprint(
settings_for(write_model_fixture(tmp_path / "model"), tmp_path)
)
stored = KvFingerprint.model_validate({**live.model_dump(), "format_semver": "0.9.0"})
assert fingerprint_diff(stored, live) == [
f"format_semver: cartridge 0.9.0 vs this build {FORMAT_SEMVER}"
]
def test_an_unreadable_model_config_fails_loudly(
tmp_path: Path, write_model_fixture: Callable[..., Path]
) -> None:
model = write_model_fixture(tmp_path / "model")
(model / "config.json").write_text("not json", encoding="utf-8")
with pytest.raises(ValueError, match="the model config is unreadable"):
compute_fingerprint(settings_for(model, tmp_path))