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:
ZacharyZhang-NY committed 2026-10-07 16:40:27 -04:00
1 parent 674c88e84e
commit 9fe13fffb3
21 files changed
+1492 -153

No files matched your search

+107
View File
@@ -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