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.
This commit is contained in:
1 parent
674c88e84e
commit
9fe13fffb3
21 files changed
+1492
-153
No files matched your search
@@ -0,0 +1,53 @@
|
||||
"""Shared fixtures: a model snapshot the fingerprint can read without a 9B download."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
ROPE: dict[str, Any] = {
|
||||
"rope_type": "yarn",
|
||||
"factor": 4.0,
|
||||
"original_max_position_embeddings": 262_144,
|
||||
"mrope_interleaved": True,
|
||||
"mrope_section": [11, 11, 10],
|
||||
}
|
||||
TEMPLATE = "{% for message in messages %}{{ message['content'] }}{% endfor %}"
|
||||
|
||||
|
||||
def write_model(model_dir: Path, **config_overrides: Any) -> Path:
|
||||
config: dict[str, Any] = {
|
||||
"model_type": "qwen3_5",
|
||||
"max_position_embeddings": 262_144,
|
||||
"partial_rotary_factor": 0.25,
|
||||
"rope_parameters": dict(ROPE),
|
||||
"chat_template": TEMPLATE,
|
||||
}
|
||||
config.update(config_overrides)
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
(model_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
(model_dir / "tokenizer.json").write_text('{"version": "1.0"}', encoding="utf-8")
|
||||
(model_dir / "model.safetensors.index.json").write_text(
|
||||
json.dumps({"metadata": {}, "weight_map": {"model-00001.safetensors": "0"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return model_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_template() -> str:
|
||||
return TEMPLATE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_model_fixture() -> Callable[..., Path]:
|
||||
return write_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_dir(tmp_path: Path) -> Path:
|
||||
return write_model(tmp_path / "model")
|
||||
@@ -0,0 +1,284 @@
|
||||
"""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) == []
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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))
|
||||
Reference in new issue
Block a user