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.
139 lines
5.5 KiB
Python
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))
|