Files
ely-eye/backend/tests/test_cartridge_scope.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

285 lines
12 KiB
Python

"""A cartridge is a retrieval scope: mount it and only it answers, append and the bundle follows."""
from __future__ import annotations
import json
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
import pyarrow.parquet as pq
import pytest
from fastapi.testclient import TestClient
from typer.testing import CliRunner
import ely_eye.api as api_module
import ely_eye.cli as cli_module
from ely_eye import cartridge_store
from ely_eye.cartridge import CartridgeService
from ely_eye.cli import app
from ely_eye.config import Settings
from ely_eye.db import Database
from ely_eye.memory import MemoryService
from ely_eye.migrations import SCHEMA_VERSION
from ely_eye.retrieval import RetrievalService
from ely_eye.schemas import MemoryOrigin, SearchFilters
HOME_KEYS = "我们的钥匙放在玄关抽屉里"
WORK_KEYS = "备用钥匙在办公室第二个抽屉里"
class Store:
"""One SQLite file, the four services that read and write it."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.memory = MemoryService(settings=settings)
self.db = self.memory.db
self.cartridges = CartridgeService(settings=settings, database=self.db)
self.search = RetrievalService(settings=settings, database=self.db)
def remember(self, text: str, scenario: str) -> str:
atom = self.memory.remember(
text, holder_id="wife", origin=MemoryOrigin.self_, scenario=scenario
)
return atom.atom_id
def cartridge(self, name: str, atom_ids: list[str]) -> str:
atoms = self.db.get_atoms(atom_ids)
sources = self.db.get_sources(sorted({atom.source_id for atom in atoms}))
return self.cartridges.materialize(name, atoms, sources).cartridge_id
def texts(self, query: str = "钥匙", **filters: Any) -> list[str]:
result = self.search.search(query, filters=SearchFilters(**filters) if filters else None)
return [hit.atom.text for hit in result.hits]
@pytest.fixture
def store(tmp_path: Path, model_dir: Path) -> Store:
return Store(Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife"))
def test_mounting_one_cartridge_stops_the_other_from_answering(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
work = store.cartridge("work", [store.remember(WORK_KEYS, "work")])
assert sorted(store.texts()) == sorted([HOME_KEYS, WORK_KEYS])
store.cartridges.switch(home)
assert store.texts() == [HOME_KEYS]
store.cartridges.switch(work)
assert store.texts() == [WORK_KEYS]
def test_an_explicit_scope_overrides_what_is_mounted(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
work = store.cartridge("work", [store.remember(WORK_KEYS, "work")])
store.cartridges.switch(home)
assert store.texts(cartridge_ids=[work]) == [WORK_KEYS]
def test_nothing_mounted_answers_from_the_whole_library(store: Store) -> None:
store.cartridge("home", [store.remember(HOME_KEYS, "home")])
state = store.cartridges.mount_state()
assert state.mounted == []
assert state.scoped is False
assert state.atoms_in_scope == 0
assert store.texts() == [HOME_KEYS]
def test_mounting_two_cartridges_answers_from_both(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
work = store.cartridge("work", [store.remember(WORK_KEYS, "work")])
store.cartridges.mount(home)
store.cartridges.mount(work)
state = store.cartridges.mount_state()
assert sorted(state.mounted) == sorted([home, work])
assert state.atoms_in_scope == 2
assert sorted(store.texts()) == sorted([HOME_KEYS, WORK_KEYS])
def test_unmounting_reports_the_state_and_refuses_a_stranger(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
store.cartridges.mount(home)
state = store.cartridges.unmount(home)
assert state.mounted == [] and state.scoped is False
with pytest.raises(ValueError, match=f"{home} is not mounted; nothing to unmount"):
store.cartridges.unmount(home)
def test_append_adds_a_memory_and_the_bundle_follows(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
before = next(m for m in store.cartridges.list_manifests() if m.cartridge_id == home)
later = store.remember("备用钥匙在办公室第二个抽屉里", "home")
after = store.cartridges.append(home, [later])
assert after.dna != before.dna
assert after.atom_count == before.atom_count + 1
assert cartridge_store.membership_count(store.db, home) == 2
rows = pq.read_table(store.settings.cartridge_dir / home / "atoms.parquet").num_rows
assert rows == before.atom_count + 1
store.cartridges.switch(home)
assert sorted(store.texts()) == sorted([HOME_KEYS, WORK_KEYS])
def test_append_refuses_atoms_that_are_not_in_the_store(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
with pytest.raises(ValueError, match="not in the store: mem-missing:text"):
store.cartridges.append(home, ["mem-missing:text"])
def test_append_needs_something_to_append(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
with pytest.raises(ValueError, match="append needs at least one atom"):
store.cartridges.append(home, [])
def test_a_cartridge_without_a_fingerprint_cannot_be_mounted(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
row = next(r for r in store.db.list_cartridges() if r["cartridge_id"] == home)
manifest = json.loads(row["manifest_json"])
manifest["kv_fingerprint"] = None
store.db.write_cartridge(
cartridge_id=home,
name=row["name"],
root_path=Path(row["root_path"]),
manifest_json=json.dumps(manifest),
dna=row["dna"],
token_equivalent=row["token_equivalent"],
created_at=datetime.fromisoformat(row["created_at"]),
)
with pytest.raises(ValueError, match="carries no kv fingerprint; rebuild it before mounting"):
store.cartridges.mount(home)
def test_a_mount_refuses_a_different_rope_and_names_the_field(
store: Store, model_dir: Path, write_model_fixture: Callable[..., Path]
) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
write_model_fixture(model_dir, rope_parameters={"rope_type": "yarn", "factor": 2.0})
with pytest.raises(ValueError, match="rope: factor: cartridge 4.0 vs local 2.0"):
store.cartridges.mount(home)
def test_a_mount_refuses_when_the_machine_cannot_prove_the_model(store: Store) -> None:
home = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
blind = CartridgeService(
settings=Settings(home=store.settings.home, model_dir=None), database=store.db
)
with pytest.raises(ValueError, match="model_hash, tokenizer_hash, chat_template_hash, rope"):
blind.mount(home)
def test_forgetting_a_memory_takes_it_out_of_the_cartridge(store: Store) -> None:
atom_id = store.remember(HOME_KEYS, "home")
home = store.cartridge("home", [atom_id])
store.cartridges.mount(home)
store.memory.forget(atom_id=atom_id, reason="moved")
assert cartridge_store.membership_count(store.db, home) == 0
assert store.texts() == []
def upload_home(client: TestClient) -> str:
answer = client.post(
"/api/ingest/upload?cartridge_name=home",
files={"files": ("pantry.txt", b"the pantry keeps flour", "text/plain")},
)
assert answer.status_code == 200, answer.text
return answer.json()["cartridge_id"]
def test_the_api_mounts_and_reports_the_scope(
tmp_path: Path, model_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
settings = Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife")
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
client = TestClient(api_module.create_app())
atom = client.post("/api/memory", json={"text": HOME_KEYS, "scenario": "home"}).json()
assert client.get("/api/mounts").json() == {"mounted": [], "scoped": False, "atoms_in_scope": 0}
cartridge_id = upload_home(client)
answer = client.post(f"/api/cartridges/{cartridge_id}/mount")
assert answer.status_code == 200
assert answer.json()["kv_fingerprint"]["rope"]["factor"] == 4.0
assert client.get("/api/mounts").json() == {
"mounted": [cartridge_id],
"scoped": True,
"atoms_in_scope": 2,
}
hits = client.post("/api/search", json={"query": "钥匙"}).json()["hits"]
assert [hit["atom"]["atom_id"] for hit in hits] == [atom["atom_id"]]
stranger = client.post("/api/cartridges/cartridge-does-not-exist/mount")
assert stranger.status_code == 400 and "Unknown cartridge" in stranger.json()["detail"]
assert client.post(f"/api/cartridges/{cartridge_id}/unmount").status_code == 200
assert client.post(f"/api/cartridges/{cartridge_id}/unmount").status_code == 400
def test_the_api_appends_and_refuses_an_unknown_atom(
tmp_path: Path, model_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
settings = Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife")
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
client = TestClient(api_module.create_app())
client.post("/api/memory", json={"text": HOME_KEYS, "scenario": "home"})
cartridge_id = upload_home(client)
answer = client.post(
f"/api/cartridges/{cartridge_id}/append", json={"atom_ids": ["mem-nope:text"]}
)
assert answer.status_code == 400 and "not in the store" in answer.json()["detail"]
extra = client.post("/api/memory", json={"text": WORK_KEYS, "scenario": "home"}).json()
grown = client.post(
f"/api/cartridges/{cartridge_id}/append", json={"atom_ids": [extra["atom_id"]]}
)
assert grown.status_code == 200 and grown.json()["atom_count"] == 3
def test_the_cli_mounts_and_answers_in_json(
tmp_path: Path, model_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
settings = Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife")
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
runner = CliRunner()
store = Store(settings)
cartridge_id = store.cartridge("home", [store.remember(HOME_KEYS, "home")])
mounted = runner.invoke(app, ["mount", cartridge_id])
assert mounted.exit_code == 0, mounted.output
assert json.loads(mounted.output)["cartridge_id"] == cartridge_id
state = json.loads(runner.invoke(app, ["mounts"]).output)
assert state == {"mounted": [cartridge_id], "scoped": True, "atoms_in_scope": 1}
refused = runner.invoke(app, ["mount", "cartridge-does-not-exist"])
assert isinstance(refused.exception, ValueError) and "Unknown cartridge" in str(
refused.exception
)
def test_a_third_version_store_gains_the_scope_tables_on_upgrade(
tmp_path: Path, model_dir: Path
) -> None:
settings = Settings(home=tmp_path / "home", model_dir=model_dir)
first = Database(settings)
with first.connect() as connection:
connection.execute("DROP TABLE cartridge_atoms")
connection.execute("DROP TABLE mounts")
connection.execute("PRAGMA user_version = 3")
reopened = Database(settings)
with reopened.connect() as connection:
tables = {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
version = connection.execute("PRAGMA user_version").fetchone()[0]
assert {"cartridge_atoms", "mounts"} <= tables
assert version == SCHEMA_VERSION
assert cartridge_store.mounted_ids(reopened) == []