Files
ely-eye/backend/src/ely_eye/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

197 lines
7.2 KiB
Python

"""The compatibility header a cartridge carries, and the check that gates a mount.
KV bytes are only valid under the RoPE, quantization and engine settings that produced
them (profiles.py launches YaRN factor 4.0 and llama.cpp --cache-type-k q4_0). A cartridge
mounted under different settings does not fail: it silently attends to wrong positions. So
the manifest carries a fingerprint and the mount compares it field by field.
"""
from __future__ import annotations
import hashlib
import json
from importlib import metadata
from pathlib import Path
from typing import Any
from pydantic import BaseModel
from .config import Settings
FORMAT_SEMVER = "1.0.0"
UNKNOWN = ""
class KvFingerprint(BaseModel):
format_semver: str = FORMAT_SEMVER
model_id: str = UNKNOWN
model_hash: str = UNKNOWN
tokenizer_hash: str = UNKNOWN
chat_template_hash: str = UNKNOWN
rope: dict[str, Any] = {}
context_tokens: int = 0
kv_dtype: str = UNKNOWN
engine: str = UNKNOWN
engine_version: str = UNKNOWN
adapter_hashes: dict[str, str] = {}
def _read_bytes(path: Path) -> bytes | None:
if not path.is_file():
return None
return path.read_bytes()
def _hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _config_json(model_dir: Path) -> dict[str, Any]:
data = _read_bytes(model_dir / "config.json")
if data is None:
return {}
try:
parsed = json.loads(data)
except json.JSONDecodeError as error:
raise ValueError(f"{model_dir / 'config.json'}: the model config is unreadable ({error})") from error
if not isinstance(parsed, dict):
raise ValueError(f"{model_dir / 'config.json'}: expected a JSON object") # noqa: TRY004 # the file's bytes are wrong, not the caller's argument
return parsed
def _model_hash(model_dir: Path) -> str:
config = _read_bytes(model_dir / "config.json")
if config is None:
return UNKNOWN
digest = hashlib.sha256(config)
index = _read_bytes(model_dir / "model.safetensors.index.json")
if index is not None:
digest.update(index)
return digest.hexdigest()
for shard in sorted(model_dir.glob("*.safetensors")):
digest.update(f"{shard.name}:{shard.stat().st_size}".encode())
return digest.hexdigest()
def _rope(config: dict[str, Any]) -> dict[str, Any]:
rope: dict[str, Any] = {}
for key in ("rope_parameters", "rope_scaling"):
block = config.get(key)
if isinstance(block, dict):
rope.update(block)
for key in ("partial_rotary_factor", "max_position_embeddings", "sliding_window"):
value = config.get(key)
if value is not None:
rope[key] = value
return rope
def _chat_template_hash(model_dir: Path, config: dict[str, Any]) -> str:
template = config.get("chat_template")
if not isinstance(template, str):
data = _read_bytes(model_dir / "chat_template.json")
if data is None:
return UNKNOWN
try:
parsed = json.loads(data)
except json.JSONDecodeError as error:
raise ValueError(f"{model_dir / 'chat_template.json'}: unreadable ({error})") from error
template = parsed.get("chat_template") if isinstance(parsed, dict) else None
return _hex(template.encode("utf-8")) if isinstance(template, str) else UNKNOWN
def adapter_hashes(adapter_root: Path) -> dict[str, str]:
"""Hash every adapter weight under a directory, keyed by adapter kind."""
found: dict[str, str] = {}
if not adapter_root.is_dir():
return found
for weight in sorted(adapter_root.rglob("adapter_model.safetensors")):
kind = weight.parent.name
found[kind] = _hex(weight.read_bytes())
return found
def compute_fingerprint(settings: Settings, adapter_root: Path | None = None) -> KvFingerprint:
"""Read what this machine can prove; unverifiable fields come back empty."""
engine_version = UNKNOWN
try:
engine_version = metadata.version(
"transformers" if settings.runtime_backend == "transformers" else "sglang"
)
except metadata.PackageNotFoundError:
pass # an engine that is not installed here is a fact to report, not a crash
model_dir = settings.model_dir
config = _config_json(model_dir) if model_dir is not None else {}
tokenizer = _read_bytes(model_dir / "tokenizer.json") if model_dir is not None else None
if tokenizer is None and model_dir is not None:
tokenizer = _read_bytes(model_dir / "tokenizer_config.json")
return KvFingerprint(
model_id=settings.model_id,
model_hash=_model_hash(model_dir) if model_dir is not None else UNKNOWN,
tokenizer_hash=_hex(tokenizer) if tokenizer is not None else UNKNOWN,
chat_template_hash=_chat_template_hash(model_dir, config) if model_dir is not None else UNKNOWN,
rope=_rope(config),
context_tokens=settings.live_context_tokens,
kv_dtype=settings.kv_cache_dtype,
engine=settings.runtime_backend,
engine_version=engine_version,
adapter_hashes=adapter_hashes(adapter_root) if adapter_root is not None else {},
)
def unverifiable_fields(fingerprint: KvFingerprint) -> list[str]:
"""Fields this machine cannot prove; a mount must not wave them through."""
missing: list[str] = []
for field in ("model_hash", "tokenizer_hash", "chat_template_hash", "engine_version"):
if not getattr(fingerprint, field):
missing.append(field)
if not fingerprint.rope:
missing.append("rope")
return missing
def fingerprint_diff(stored: KvFingerprint, live: KvFingerprint) -> list[str]:
"""Named differences between a cartridge's header and this machine."""
differences: list[str] = []
if stored.format_semver != live.format_semver:
differences.append(
f"format_semver: cartridge {stored.format_semver} vs this build {live.format_semver}"
)
for field in (
"model_id",
"model_hash",
"tokenizer_hash",
"chat_template_hash",
"context_tokens",
"kv_dtype",
"engine",
"engine_version",
):
left, right = getattr(stored, field), getattr(live, field)
if left != right:
differences.append(f"{field}: cartridge {left!r} vs local {right!r}")
if stored.rope != live.rope:
keys = sorted(set(stored.rope) | set(live.rope))
changes = [
f"{key}: cartridge {stored.rope.get(key)!r} vs local {live.rope.get(key)!r}"
for key in keys
if stored.rope.get(key) != live.rope.get(key)
]
differences.append("rope: " + "; ".join(changes))
if stored.adapter_hashes != live.adapter_hashes:
kinds = sorted(set(stored.adapter_hashes) | set(live.adapter_hashes))
changes = [
f"{kind}: cartridge {_short(stored.adapter_hashes.get(kind))} vs local "
f"{_short(live.adapter_hashes.get(kind))}"
for kind in kinds
if stored.adapter_hashes.get(kind) != live.adapter_hashes.get(kind)
]
differences.append("adapters: " + "; ".join(changes))
return differences
def _short(value: str | None) -> str:
if not value:
return "absent"
return value[:12]