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
@@ -10,6 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import __version__
|
||||
from .api_cartridges import register as register_cartridge_routes
|
||||
from .benchmarks import (
|
||||
benchmark_prediction_report,
|
||||
benchmark_registry_report,
|
||||
@@ -18,7 +19,6 @@ from .benchmarks import (
|
||||
)
|
||||
from .cache_fabric import CacheFabric
|
||||
from .cartridge import CartridgeService
|
||||
from .cartridge_assets import CartridgeAssetService
|
||||
from .compiler import ContextCompiler
|
||||
from .config import Settings, get_settings
|
||||
from .db import Database
|
||||
@@ -44,7 +44,6 @@ from .schemas import (
|
||||
AdapterManifest,
|
||||
AdapterStatus,
|
||||
AnswerResponse,
|
||||
CartridgeAssetReport,
|
||||
EvidenceAtom,
|
||||
ForgetReport,
|
||||
ForgetRequest,
|
||||
@@ -246,47 +245,7 @@ def create_app() -> FastAPI:
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/cartridges")
|
||||
def cartridges(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
return CartridgeService(settings).list_manifests()
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/finalize", response_model=CartridgeAssetReport)
|
||||
def finalize_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeAssetReport:
|
||||
try:
|
||||
return CartridgeAssetService(settings).finalize(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/refresh")
|
||||
def refresh_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
):
|
||||
db = Database(settings)
|
||||
try:
|
||||
return CartridgeService(settings, db).rebuild(
|
||||
cartridge_id=cartridge_id,
|
||||
atoms=db.list_atoms(),
|
||||
sources=db.list_sources(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/cartridges/{cartridge_id}/assets", response_model=CartridgeAssetReport)
|
||||
def cartridge_assets(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeAssetReport:
|
||||
try:
|
||||
report = CartridgeAssetService(settings).status(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="Cartridge assets have not been finalized.")
|
||||
return report
|
||||
register_cartridge_routes(app, settings_dep)
|
||||
|
||||
@app.get("/api/adapters", response_model=list[AdapterStatus])
|
||||
def adapters(settings: Annotated[Settings, Depends(settings_dep)]) -> list[AdapterStatus]:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Cartridge and mount routes: a cartridge is a retrieval scope, not only an export."""
|
||||
|
||||
# No `from __future__ import annotations` here: FastAPI reads the route signatures, and
|
||||
# string annotations would be resolved against this module's globals, where the injected
|
||||
# settings_dep lives only as a parameter of register().
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
|
||||
from .cartridge import CartridgeService
|
||||
from .cartridge_assets import CartridgeAssetService
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .schemas import AppendRequest, CartridgeAssetReport, CartridgeManifest, MountState
|
||||
|
||||
|
||||
def register(app: FastAPI, settings_dep: Callable[[], Settings]) -> None:
|
||||
"""Mounting decides what a search may see; the KV fingerprint decides if it may mount here."""
|
||||
|
||||
@app.get("/api/cartridges")
|
||||
def cartridges(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
return CartridgeService(settings).list_manifests()
|
||||
|
||||
@app.get("/api/mounts", response_model=MountState)
|
||||
def mounts(settings: Annotated[Settings, Depends(settings_dep)]) -> MountState:
|
||||
return CartridgeService(settings).mount_state()
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/mount", response_model=CartridgeManifest)
|
||||
def mount_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeManifest:
|
||||
try:
|
||||
return CartridgeService(settings).mount(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/switch", response_model=CartridgeManifest)
|
||||
def switch_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeManifest:
|
||||
try:
|
||||
return CartridgeService(settings).switch(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/unmount", response_model=MountState)
|
||||
def unmount_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> MountState:
|
||||
try:
|
||||
return CartridgeService(settings).unmount(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/append", response_model=CartridgeManifest)
|
||||
def append_cartridge(
|
||||
cartridge_id: str,
|
||||
request: AppendRequest,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeManifest:
|
||||
try:
|
||||
return CartridgeService(settings).append(cartridge_id, request.atom_ids)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/finalize", response_model=CartridgeAssetReport)
|
||||
def finalize_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeAssetReport:
|
||||
try:
|
||||
return CartridgeAssetService(settings).finalize(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/cartridges/{cartridge_id}/refresh")
|
||||
def refresh_cartridge(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
):
|
||||
db = Database(settings)
|
||||
try:
|
||||
return CartridgeService(settings, db).rebuild(
|
||||
cartridge_id=cartridge_id,
|
||||
atoms=db.list_atoms(),
|
||||
sources=db.list_sources(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/cartridges/{cartridge_id}/assets", response_model=CartridgeAssetReport)
|
||||
def cartridge_assets(
|
||||
cartridge_id: str,
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
) -> CartridgeAssetReport:
|
||||
try:
|
||||
report = CartridgeAssetService(settings).status(cartridge_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="Cartridge assets have not been finalized.")
|
||||
return report
|
||||
@@ -4,16 +4,34 @@ import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
import zstandard as zstd
|
||||
|
||||
from . import cartridge_store
|
||||
from .config import Settings, get_settings
|
||||
from .db import Database
|
||||
from .schemas import AdapterManifest, CartridgeManifest, EvidenceAtom, SourceRecord
|
||||
from .kv_fingerprint import compute_fingerprint, fingerprint_diff, unverifiable_fields
|
||||
from .schemas import (
|
||||
AdapterManifest,
|
||||
CartridgeManifest,
|
||||
EvidenceAtom,
|
||||
MountState,
|
||||
SourceRecord,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
JSON_PARQUET_COLUMNS = (
|
||||
"embedding_refs",
|
||||
"layout",
|
||||
"relations",
|
||||
"trust",
|
||||
"metadata",
|
||||
"visibility",
|
||||
)
|
||||
|
||||
|
||||
class CartridgeService:
|
||||
@@ -27,7 +45,7 @@ class CartridgeService:
|
||||
atoms: list[EvidenceAtom],
|
||||
sources: list[SourceRecord],
|
||||
) -> CartridgeManifest:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
created_at = datetime.now(UTC)
|
||||
cartridge_id = self._cartridge_id(name, created_at)
|
||||
root = self.settings.cartridge_dir / cartridge_id
|
||||
eval_dir = root / "eval_proofs"
|
||||
@@ -55,6 +73,7 @@ class CartridgeService:
|
||||
source_count=len(sources),
|
||||
atom_count=len(atoms),
|
||||
token_equivalent=sum(atom.token_equivalent for atom in atoms),
|
||||
kv_fingerprint=compute_fingerprint(self.settings, root / "adapters"),
|
||||
artifacts={
|
||||
"manifest": "manifest.json",
|
||||
"atoms": "atoms.parquet",
|
||||
@@ -64,20 +83,8 @@ class CartridgeService:
|
||||
"eval_proofs": "eval_proofs/",
|
||||
},
|
||||
)
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
dna = self.compute_dna(root)
|
||||
manifest.dna = dna
|
||||
manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
self.db.write_cartridge(
|
||||
cartridge_id=cartridge_id,
|
||||
name=name,
|
||||
root_path=root,
|
||||
manifest_json=manifest.model_dump_json(),
|
||||
dna=dna,
|
||||
token_equivalent=manifest.token_equivalent,
|
||||
created_at=created_at,
|
||||
)
|
||||
self._commit(manifest, root)
|
||||
cartridge_store.set_membership(self.db, cartridge_id, [atom.atom_id for atom in atoms])
|
||||
return manifest
|
||||
|
||||
def compute_dna(self, root: Path) -> str:
|
||||
@@ -96,18 +103,102 @@ class CartridgeService:
|
||||
manifests.append(CartridgeManifest.model_validate_json(row["manifest_json"]))
|
||||
return manifests
|
||||
|
||||
def mount_state(self) -> MountState:
|
||||
mounted = cartridge_store.mounted_ids(self.db)
|
||||
return MountState(
|
||||
mounted=mounted,
|
||||
scoped=bool(mounted),
|
||||
atoms_in_scope=len(cartridge_store.atoms_of_cartridges(self.db, mounted)),
|
||||
)
|
||||
|
||||
def mount(self, cartridge_id: str) -> CartridgeManifest:
|
||||
"""Add one cartridge to the mounted set; a mounted search answers only from it."""
|
||||
root, manifest = self._require(cartridge_id)
|
||||
self._refuse_if_incompatible(manifest, root)
|
||||
mounted = cartridge_store.mounted_ids(self.db)
|
||||
if cartridge_id not in mounted:
|
||||
cartridge_store.set_mounts(self.db, [*mounted, cartridge_id], utc_now())
|
||||
return manifest
|
||||
|
||||
def switch(self, cartridge_id: str) -> CartridgeManifest:
|
||||
"""Mount one cartridge and nothing else."""
|
||||
root, manifest = self._require(cartridge_id)
|
||||
self._refuse_if_incompatible(manifest, root)
|
||||
cartridge_store.set_mounts(self.db, [cartridge_id], utc_now())
|
||||
return manifest
|
||||
|
||||
def unmount(self, cartridge_id: str) -> MountState:
|
||||
self._require(cartridge_id)
|
||||
mounted = cartridge_store.mounted_ids(self.db)
|
||||
if cartridge_id not in mounted:
|
||||
raise ValueError(f"{cartridge_id} is not mounted; nothing to unmount")
|
||||
cartridge_store.set_mounts(
|
||||
self.db, [held for held in mounted if held != cartridge_id], utc_now()
|
||||
)
|
||||
return self.mount_state()
|
||||
|
||||
def append(self, cartridge_id: str, atom_ids: list[str]) -> CartridgeManifest:
|
||||
"""Add memories to an existing cartridge; the bundle and its DNA follow the membership."""
|
||||
self._require(cartridge_id)
|
||||
if not atom_ids:
|
||||
raise ValueError("append needs at least one atom")
|
||||
atoms = self.db.get_atoms(atom_ids)
|
||||
found = {atom.atom_id for atom in atoms}
|
||||
missing = [atom_id for atom_id in atom_ids if atom_id not in found]
|
||||
if missing:
|
||||
raise ValueError(f"cannot append, these atoms are not in the store: {', '.join(missing)}")
|
||||
cartridge_store.add_membership(self.db, cartridge_id, atom_ids)
|
||||
held = self.db.get_atoms(sorted(cartridge_store.atoms_of_cartridge(self.db, cartridge_id)))
|
||||
sources = self.db.get_sources(sorted({atom.source_id for atom in held}))
|
||||
return self.rebuild(cartridge_id, held, sources)
|
||||
|
||||
def _require(self, cartridge_id: str) -> tuple[Path, CartridgeManifest]:
|
||||
rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id]
|
||||
if not rows:
|
||||
raise ValueError(f"Unknown cartridge: {cartridge_id}")
|
||||
row = rows[0]
|
||||
return Path(row["root_path"]), CartridgeManifest.model_validate_json(row["manifest_json"])
|
||||
|
||||
def _refuse_if_incompatible(self, manifest: CartridgeManifest, root: Path) -> None:
|
||||
"""KV bytes are only valid under the settings that produced them."""
|
||||
stored = manifest.kv_fingerprint
|
||||
if stored is None:
|
||||
raise ValueError(f"{manifest.cartridge_id} carries no kv fingerprint; rebuild it before mounting")
|
||||
live = compute_fingerprint(self.settings, root / "adapters")
|
||||
gaps = unverifiable_fields(live)
|
||||
if gaps:
|
||||
raise ValueError(
|
||||
f"cannot verify {manifest.cartridge_id} on this machine: {', '.join(gaps)} are unknown; "
|
||||
"set ELY_EYE_MODEL_DIR to the model snapshot"
|
||||
)
|
||||
differences = fingerprint_diff(stored, live)
|
||||
if differences:
|
||||
raise ValueError(
|
||||
f"{manifest.cartridge_id} was built for a different runtime; refusing to mount: "
|
||||
+ "; ".join(differences)
|
||||
)
|
||||
|
||||
def _commit(self, manifest: CartridgeManifest, root: Path) -> None:
|
||||
"""Re-hash the bundle, then record the manifest that describes those bytes."""
|
||||
manifest.dna = self.compute_dna(root)
|
||||
(root / "manifest.json").write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
self.db.write_cartridge(
|
||||
cartridge_id=manifest.cartridge_id,
|
||||
name=manifest.name,
|
||||
root_path=root,
|
||||
manifest_json=manifest.model_dump_json(),
|
||||
dna=manifest.dna,
|
||||
token_equivalent=manifest.token_equivalent,
|
||||
created_at=manifest.created_at,
|
||||
)
|
||||
|
||||
def rebuild(
|
||||
self,
|
||||
cartridge_id: str,
|
||||
atoms: list[EvidenceAtom],
|
||||
sources: list[SourceRecord],
|
||||
) -> CartridgeManifest:
|
||||
rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id]
|
||||
if not rows:
|
||||
raise ValueError(f"Unknown cartridge: {cartridge_id}")
|
||||
row = rows[0]
|
||||
root = Path(row["root_path"])
|
||||
manifest = CartridgeManifest.model_validate_json(row["manifest_json"])
|
||||
root, manifest = self._require(cartridge_id)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._write_atoms_parquet(root / "atoms.parquet", atoms)
|
||||
@@ -133,24 +224,11 @@ class CartridgeService:
|
||||
)
|
||||
manifest.dna = self.compute_dna(root)
|
||||
(root / "manifest.json").write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
self.db.write_cartridge(
|
||||
cartridge_id=manifest.cartridge_id,
|
||||
name=manifest.name,
|
||||
root_path=root,
|
||||
manifest_json=manifest.model_dump_json(),
|
||||
dna=manifest.dna,
|
||||
token_equivalent=manifest.token_equivalent,
|
||||
created_at=manifest.created_at,
|
||||
)
|
||||
self._commit(manifest, root)
|
||||
return manifest
|
||||
|
||||
def attach_adapter(self, cartridge_id: str, adapter_dir: Path) -> CartridgeManifest:
|
||||
rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id]
|
||||
if not rows:
|
||||
raise ValueError(f"Unknown cartridge: {cartridge_id}")
|
||||
row = rows[0]
|
||||
root = Path(row["root_path"])
|
||||
manifest = CartridgeManifest.model_validate_json(row["manifest_json"])
|
||||
root, manifest = self._require(cartridge_id)
|
||||
adapter_manifest_path = adapter_dir / "adapter_manifest.json"
|
||||
if not adapter_manifest_path.exists():
|
||||
raise ValueError(f"Missing adapter manifest: {adapter_manifest_path}")
|
||||
@@ -163,18 +241,7 @@ class CartridgeService:
|
||||
manifest.artifacts[f"adapter_{adapter_manifest.kind.value}"] = (
|
||||
adapter_root.relative_to(root).as_posix() + "/"
|
||||
)
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest.dna = self.compute_dna(root)
|
||||
manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
self.db.write_cartridge(
|
||||
cartridge_id=manifest.cartridge_id,
|
||||
name=manifest.name,
|
||||
root_path=root,
|
||||
manifest_json=manifest.model_dump_json(),
|
||||
dna=manifest.dna,
|
||||
token_equivalent=manifest.token_equivalent,
|
||||
created_at=manifest.created_at,
|
||||
)
|
||||
self._commit(manifest, root)
|
||||
return manifest
|
||||
|
||||
def update_artifacts(
|
||||
@@ -183,59 +250,34 @@ class CartridgeService:
|
||||
artifacts: dict[str, str],
|
||||
token_equivalent: int | None = None,
|
||||
) -> CartridgeManifest:
|
||||
rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id]
|
||||
if not rows:
|
||||
raise ValueError(f"Unknown cartridge: {cartridge_id}")
|
||||
row = rows[0]
|
||||
root = Path(row["root_path"])
|
||||
manifest = CartridgeManifest.model_validate_json(row["manifest_json"])
|
||||
root, manifest = self._require(cartridge_id)
|
||||
manifest.artifacts.update(artifacts)
|
||||
if token_equivalent is not None:
|
||||
manifest.token_equivalent = token_equivalent
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest.dna = self.compute_dna(root)
|
||||
manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
self.db.write_cartridge(
|
||||
cartridge_id=manifest.cartridge_id,
|
||||
name=manifest.name,
|
||||
root_path=root,
|
||||
manifest_json=manifest.model_dump_json(),
|
||||
dna=manifest.dna,
|
||||
token_equivalent=manifest.token_equivalent,
|
||||
created_at=manifest.created_at,
|
||||
)
|
||||
self._commit(manifest, root)
|
||||
return manifest
|
||||
|
||||
def attach_eval_proof(self, cartridge_id: str, proof_dir: Path) -> CartridgeManifest:
|
||||
rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id]
|
||||
if not rows:
|
||||
raise ValueError(f"Unknown cartridge: {cartridge_id}")
|
||||
root, manifest = self._require(cartridge_id)
|
||||
if not proof_dir.exists() or not proof_dir.is_dir():
|
||||
raise ValueError(f"Missing proof directory: {proof_dir}")
|
||||
row = rows[0]
|
||||
root = Path(row["root_path"])
|
||||
manifest = CartridgeManifest.model_validate_json(row["manifest_json"])
|
||||
target = root / "eval_proofs" / proof_dir.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(proof_dir, target, dirs_exist_ok=True)
|
||||
manifest.artifacts[f"eval_proof_{proof_dir.name}"] = target.relative_to(root).as_posix() + "/"
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest.dna = self.compute_dna(root)
|
||||
manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
self.db.write_cartridge(
|
||||
cartridge_id=manifest.cartridge_id,
|
||||
name=manifest.name,
|
||||
root_path=root,
|
||||
manifest_json=manifest.model_dump_json(),
|
||||
dna=manifest.dna,
|
||||
token_equivalent=manifest.token_equivalent,
|
||||
created_at=manifest.created_at,
|
||||
)
|
||||
self._commit(manifest, root)
|
||||
return manifest
|
||||
|
||||
def _write_atoms_parquet(self, path: Path, atoms: list[EvidenceAtom]) -> None:
|
||||
rows = [atom.model_dump(mode="json") for atom in atoms]
|
||||
table = pa.Table.from_pylist(rows)
|
||||
rows = []
|
||||
for atom in atoms:
|
||||
row = atom.model_dump(mode="json")
|
||||
for field in JSON_PARQUET_COLUMNS:
|
||||
row[field] = json.dumps(row[field], ensure_ascii=False, sort_keys=True)
|
||||
rows.append(row)
|
||||
# An empty struct column has no child field, and Parquet cannot write one: a memory
|
||||
# atom carries metadata={} and would crash the export.
|
||||
table = pa.Table.from_pylist(rows) if rows else pa.Table.from_pydict({"atom_id": []})
|
||||
pq.write_table(table, path, compression="zstd")
|
||||
|
||||
def _write_sparse_index(self, path: Path, atoms: list[EvidenceAtom]) -> None:
|
||||
@@ -289,13 +331,13 @@ class CartridgeService:
|
||||
connection.close()
|
||||
|
||||
def _cartridge_id(self, name: str, created_at: datetime) -> str:
|
||||
raw = f"{name}:{created_at.isoformat()}".encode("utf-8")
|
||||
raw = f"{name}:{created_at.isoformat()}".encode()
|
||||
safe_name = "".join(char if char.isalnum() else "_" for char in name.lower()).strip("_")[:40]
|
||||
return f"{safe_name or 'cartridge'}_{hashlib.sha256(raw).hexdigest()[:12]}"
|
||||
|
||||
|
||||
class _DigestWriter:
|
||||
def __init__(self, digest: "hashlib._Hash") -> None:
|
||||
def __init__(self, digest: hashlib._Hash) -> None:
|
||||
self.digest = digest
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
|
||||
@@ -14,13 +14,12 @@ import pyarrow.parquet as pq
|
||||
import zstandard as zstd
|
||||
from PIL import Image
|
||||
|
||||
from .cartridge import CartridgeService
|
||||
from .cartridge import JSON_PARQUET_COLUMNS, CartridgeService
|
||||
from .config import Settings, get_settings
|
||||
from .db import Database
|
||||
from .schemas import CartridgeAssetReport, CartridgeAssetStatus
|
||||
from .storage import ObjectStore, sha256_file
|
||||
|
||||
|
||||
TEXT_VECTOR_DIMS = 384
|
||||
VISUAL_VECTOR_DIMS = 256
|
||||
MEMORY_CAPSULE_SEGMENTS = 4096
|
||||
@@ -269,7 +268,17 @@ class CartridgeAssetService:
|
||||
def load_cartridge_atoms(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return pq.read_table(path).to_pylist()
|
||||
rows = pq.read_table(path).to_pylist()
|
||||
for row in rows:
|
||||
for field in JSON_PARQUET_COLUMNS:
|
||||
try:
|
||||
row[field] = json.loads(row[field])
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise ValueError(
|
||||
f"{path}: column {field!r} is not readable JSON text ({error}); "
|
||||
"rebuild the cartridge"
|
||||
) from error
|
||||
return rows
|
||||
|
||||
|
||||
def hashed_text_vector(text: str, dims: int) -> np.ndarray:
|
||||
@@ -298,7 +307,7 @@ def visual_image_vector(path: Path, dims: int) -> np.ndarray:
|
||||
|
||||
|
||||
def capsule_hash(seed: str, index: int, label: str) -> str:
|
||||
return hashlib.sha256(f"{seed}:{index}:{label}".encode("utf-8")).hexdigest()
|
||||
return hashlib.sha256(f"{seed}:{index}:{label}".encode()).hexdigest()
|
||||
|
||||
|
||||
def merkle_root(hashes: list[str]) -> str:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Membership and mount state for cartridges, kept out of db.py by the size rule.
|
||||
|
||||
A cartridge used to exist only as files copied out of the store, so "mount this cartridge"
|
||||
had nothing to filter against. Membership now lives beside the atoms, and the mount table
|
||||
is what a search scopes itself to when the caller does not name a scope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from .db import Database
|
||||
from .row_codec import to_json
|
||||
from .schemas import utc_now
|
||||
|
||||
|
||||
def set_membership(database: Database, cartridge_id: str, atom_ids: list[str]) -> None:
|
||||
"""Replace the atom set a cartridge holds."""
|
||||
added_at = utc_now().isoformat()
|
||||
with database.connect() as connection:
|
||||
connection.execute("DELETE FROM cartridge_atoms WHERE cartridge_id = ?", (cartridge_id,))
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO cartridge_atoms(cartridge_id, atom_id, added_at) VALUES (?, ?, ?)",
|
||||
[(cartridge_id, atom_id, added_at) for atom_id in atom_ids],
|
||||
)
|
||||
|
||||
|
||||
def add_membership(database: Database, cartridge_id: str, atom_ids: list[str]) -> int:
|
||||
"""Add atoms to a cartridge; returns how many were new."""
|
||||
added_at = utc_now().isoformat()
|
||||
with database.connect() as connection:
|
||||
before = _count(connection, cartridge_id)
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO cartridge_atoms(cartridge_id, atom_id, added_at) VALUES (?, ?, ?)",
|
||||
[(cartridge_id, atom_id, added_at) for atom_id in atom_ids],
|
||||
)
|
||||
after = _count(connection, cartridge_id)
|
||||
return after - before
|
||||
|
||||
|
||||
def membership_count(database: Database, cartridge_id: str) -> int:
|
||||
with database.connect() as connection:
|
||||
return _count(connection, cartridge_id)
|
||||
|
||||
|
||||
def cartridges_holding_atom(database: Database, atom_id: str) -> list[str]:
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT cartridge_id FROM cartridge_atoms WHERE atom_id = ? ORDER BY cartridge_id",
|
||||
(atom_id,),
|
||||
).fetchall()
|
||||
return [str(row["cartridge_id"]) for row in rows]
|
||||
|
||||
|
||||
def atoms_of_cartridge(database: Database, cartridge_id: str) -> list[str]:
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT atom_id FROM cartridge_atoms WHERE cartridge_id = ? ORDER BY atom_id",
|
||||
(cartridge_id,),
|
||||
).fetchall()
|
||||
return [str(row["atom_id"]) for row in rows]
|
||||
|
||||
|
||||
def atoms_of_cartridges(database: Database, cartridge_ids: list[str]) -> set[str]:
|
||||
"""Every atom the named cartridges hold; the scope a search must not leak outside."""
|
||||
if not cartridge_ids:
|
||||
return set()
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT DISTINCT atom_id FROM cartridge_atoms "
|
||||
"WHERE cartridge_id IN (SELECT value FROM json_each(?))",
|
||||
(to_json(cartridge_ids),),
|
||||
).fetchall()
|
||||
return {str(row["atom_id"]) for row in rows}
|
||||
|
||||
|
||||
def mounted_ids(database: Database) -> list[str]:
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT cartridge_id, mounted_at FROM mounts ORDER BY mounted_at, cartridge_id"
|
||||
).fetchall()
|
||||
return [str(row["cartridge_id"]) for row in rows]
|
||||
|
||||
|
||||
def set_mounts(database: Database, cartridge_ids: list[str], mounted_at: datetime) -> None:
|
||||
"""Replace the mounted set; mounting is a whole-state write, not a toggle."""
|
||||
stamp = mounted_at.isoformat()
|
||||
with database.connect() as connection:
|
||||
connection.execute("DELETE FROM mounts")
|
||||
connection.executemany(
|
||||
"INSERT INTO mounts(cartridge_id, mounted_at) VALUES (?, ?)",
|
||||
[(cartridge_id, stamp) for cartridge_id in cartridge_ids],
|
||||
)
|
||||
|
||||
|
||||
def clear_mounts(database: Database) -> None:
|
||||
with database.connect() as connection:
|
||||
connection.execute("DELETE FROM mounts")
|
||||
|
||||
|
||||
def _count(connection: sqlite3.Connection, cartridge_id: str) -> int:
|
||||
row = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM cartridge_atoms WHERE cartridge_id = ?", (cartridge_id,)
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
@@ -168,7 +168,7 @@ def forget(atom_id: str = "", source_id: str = "", reason: str = "") -> None:
|
||||
def build_dense_index() -> None:
|
||||
index = EmbeddingService(get_settings()).build_index()
|
||||
dimensions = int(index.vectors.shape[1]) if index.vectors.size else 0
|
||||
typer.echo({"atoms": len(index.atom_ids), "dimensions": dimensions})
|
||||
typer.echo(json_dump({"atoms": len(index.atom_ids), "dimensions": dimensions}))
|
||||
|
||||
|
||||
@app.command("list-cartridges")
|
||||
@@ -177,6 +177,38 @@ def list_cartridges() -> None:
|
||||
typer.echo(manifest.model_dump_json(indent=2))
|
||||
|
||||
|
||||
@app.command("mounts")
|
||||
def mounts() -> None:
|
||||
typer.echo(CartridgeService(get_settings()).mount_state().model_dump_json(indent=2))
|
||||
|
||||
|
||||
@app.command()
|
||||
def mount(cartridge_id: str) -> None:
|
||||
"""Add a cartridge to the mounted set; a mounted search answers only from it."""
|
||||
typer.echo(CartridgeService(get_settings()).mount(cartridge_id).model_dump_json(indent=2))
|
||||
|
||||
|
||||
@app.command()
|
||||
def switch(cartridge_id: str) -> None:
|
||||
"""Mount one cartridge and nothing else."""
|
||||
typer.echo(CartridgeService(get_settings()).switch(cartridge_id).model_dump_json(indent=2))
|
||||
|
||||
|
||||
@app.command()
|
||||
def unmount(cartridge_id: str) -> None:
|
||||
typer.echo(CartridgeService(get_settings()).unmount(cartridge_id).model_dump_json(indent=2))
|
||||
|
||||
|
||||
@app.command()
|
||||
def append(
|
||||
cartridge_id: str,
|
||||
atom_id: list[str] = typer.Option(..., "--atom-id"), # noqa: B008 # typer reads option defaults itself
|
||||
) -> None:
|
||||
"""Add memories to an existing cartridge; the bundle and its DNA follow."""
|
||||
service = CartridgeService(get_settings())
|
||||
typer.echo(service.append(cartridge_id, list(atom_id)).model_dump_json(indent=2))
|
||||
|
||||
|
||||
@app.command("hashhop")
|
||||
def hashhop(kind: str = "visual", hops: int = 2, token_equivalent: int = 262_144) -> None:
|
||||
evaluator = HashHopEvaluator(get_settings())
|
||||
|
||||
@@ -14,6 +14,8 @@ class Settings(BaseSettings):
|
||||
home: Path = Field(default_factory=lambda: Path.cwd() / ".ely_eye")
|
||||
default_holder: str = "local"
|
||||
model_id: str = "Qwen/Qwen3.5-9B"
|
||||
model_dir: Path | None = None
|
||||
kv_cache_dtype: str = "auto"
|
||||
planner_model_id: str = "Qwen/Qwen3.5-0.8B"
|
||||
embedding_model_id: str = "Qwen/Qwen3-VL-Embedding-8B"
|
||||
adapter_dir: Path | None = None
|
||||
|
||||
@@ -187,6 +187,7 @@ class Database:
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
min_trust: float | None = None,
|
||||
cartridge_ids: list[str] | None = None,
|
||||
) -> FtsPage:
|
||||
"""BM25 over the FTS index joined to memories that are open right now.
|
||||
|
||||
@@ -215,6 +216,11 @@ class Database:
|
||||
json_extract(a.trust_json, '$.model')
|
||||
) >= ?
|
||||
))
|
||||
AND (? IS NULL OR EXISTS (
|
||||
SELECT 1 FROM cartridge_atoms AS ca
|
||||
WHERE ca.atom_id = f.atom_id
|
||||
AND ca.cartridge_id IN (SELECT value FROM json_each(?))
|
||||
))
|
||||
ORDER BY score
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
@@ -234,6 +240,8 @@ class Database:
|
||||
None if until is None else until.isoformat(),
|
||||
min_trust,
|
||||
min_trust,
|
||||
None if cartridge_ids is None else to_json(cartridge_ids),
|
||||
None if cartridge_ids is None else to_json(cartridge_ids),
|
||||
limit + 1,
|
||||
offset,
|
||||
),
|
||||
@@ -330,6 +338,17 @@ class Database:
|
||||
).fetchall()
|
||||
return [row_to_source(row) for row in rows]
|
||||
|
||||
def get_sources(self, source_ids: list[str]) -> list[SourceRecord]:
|
||||
if not source_ids:
|
||||
return []
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT source_id, path, kind, mime, sha256, size_bytes, created_at, metadata_json "
|
||||
"FROM sources WHERE source_id IN (SELECT value FROM json_each(?))",
|
||||
(to_json(source_ids),),
|
||||
).fetchall()
|
||||
return [row_to_source(row) for row in rows]
|
||||
|
||||
def cartridge_count(self) -> int:
|
||||
with self.connect() as connection:
|
||||
return int(connection.execute("SELECT COUNT(*) FROM cartridges").fetchone()[0])
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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]
|
||||
@@ -14,7 +14,7 @@ import sqlite3
|
||||
|
||||
from .text_index import index_text
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
ATOM_COLUMNS_V2 = """
|
||||
atom_id TEXT PRIMARY KEY,
|
||||
@@ -68,6 +68,20 @@ _BASE_TABLES = """
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Membership lives in the live store, not only in the copied parquet, otherwise
|
||||
-- "mount this cartridge" cannot scope a query against the atoms it holds.
|
||||
CREATE TABLE IF NOT EXISTS cartridge_atoms (
|
||||
cartridge_id TEXT NOT NULL REFERENCES cartridges(cartridge_id) ON DELETE CASCADE,
|
||||
atom_id TEXT NOT NULL REFERENCES atoms(atom_id) ON DELETE CASCADE,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (cartridge_id, atom_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mounts (
|
||||
cartridge_id TEXT PRIMARY KEY REFERENCES cartridges(cartridge_id) ON DELETE CASCADE,
|
||||
mounted_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections import defaultdict
|
||||
|
||||
from . import cartridge_store
|
||||
from .config import Settings, get_settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
@@ -44,11 +45,16 @@ class RetrievalService:
|
||||
if limit <= 0:
|
||||
raise ValueError(f"search needs a positive limit, got {limit}")
|
||||
offset = decode_cursor(cursor)
|
||||
criteria = (filters or SearchFilters()).model_dump(exclude_none=True)
|
||||
scope = criteria.get("cartridge_ids")
|
||||
if scope is None:
|
||||
scope = cartridge_store.mounted_ids(self.db) or None
|
||||
criteria["cartridge_ids"] = scope
|
||||
page = self.db.search_atoms(
|
||||
query,
|
||||
limit=limit * CANDIDATE_WINDOW,
|
||||
offset=offset,
|
||||
**(filters or SearchFilters()).model_dump(exclude_none=True),
|
||||
**criteria,
|
||||
)
|
||||
rankings: list[list[str]] = [[atom_id for atom_id, _ in page.rows]]
|
||||
scores: dict[str, dict[str, float]] = defaultdict(dict)
|
||||
@@ -56,6 +62,9 @@ class RetrievalService:
|
||||
scores[atom_id]["sparse_score"] = bm25
|
||||
if self.embeddings.enabled():
|
||||
dense = self._dense_search(query, limit * CANDIDATE_WINDOW)
|
||||
if scope:
|
||||
allowed = cartridge_store.atoms_of_cartridges(self.db, scope)
|
||||
dense = {atom_id: score for atom_id, score in dense.items() if atom_id in allowed}
|
||||
rankings.append(list(dense))
|
||||
for atom_id, cosine in dense.items():
|
||||
scores[atom_id]["dense_score"] = cosine
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from .kv_fingerprint import KvFingerprint
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -162,6 +164,7 @@ class SearchFilters(BaseModel):
|
||||
scenario: str | None = None
|
||||
source_id: str | None = None
|
||||
modality: Modality | None = None
|
||||
cartridge_ids: list[str] | None = None
|
||||
since: datetime | None = None
|
||||
until: datetime | None = None
|
||||
min_trust: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
@@ -256,6 +259,18 @@ class SystemStatus(BaseModel):
|
||||
profiles: dict[str, dict[str, Any]]
|
||||
data_home: str
|
||||
|
||||
class MountState(BaseModel):
|
||||
"""What is mounted now, and whether that narrows a search."""
|
||||
|
||||
mounted: list[str]
|
||||
scoped: bool
|
||||
atoms_in_scope: int
|
||||
|
||||
|
||||
class AppendRequest(BaseModel):
|
||||
atom_ids: list[str]
|
||||
|
||||
|
||||
class CartridgeManifest(BaseModel):
|
||||
cartridge_id: str
|
||||
name: str
|
||||
@@ -265,6 +280,7 @@ class CartridgeManifest(BaseModel):
|
||||
atom_count: int
|
||||
token_equivalent: int
|
||||
artifacts: dict[str, str]
|
||||
kv_fingerprint: KvFingerprint | None = None
|
||||
dna: str | None = None
|
||||
|
||||
class AdapterManifest(BaseModel):
|
||||
|
||||
@@ -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