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.
147 lines
5.4 KiB
Go
147 lines
5.4 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
|
|
)
|
|
|
|
const manifestBody = `{"cartridge_id":"home_1","name":"home","created_at":"2026-05-01T12:00:00Z",` +
|
|
`"model_base":"Qwen/Qwen3.5-9B","source_count":1,"atom_count":2,"token_equivalent":40,` +
|
|
`"artifacts":{"atoms":"atoms.parquet"},"dna":"deadbeef",` +
|
|
`"kv_fingerprint":{"format_semver":"1.0.0","rope":{"factor":4.0}}}`
|
|
|
|
func mountServer(t *testing.T, status int, body string, seen *[]string) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*seen = append(*seen, r.Method+" "+r.URL.Path)
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
}
|
|
|
|
func TestMountedCartridgesAsksTheServer(t *testing.T) {
|
|
var seen []string
|
|
server := mountServer(t, http.StatusOK, `{"mounted":["home_1"],"scoped":true,"atoms_in_scope":2}`, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
_, out, err := s.mountsTool(context.Background(), nil, struct{}{})
|
|
if err != nil {
|
|
t.Fatalf("mounted_cartridges failed: %v", err)
|
|
}
|
|
if !out.State.Scoped || out.State.AtomsInScope != 2 || len(out.State.Mounted) != 1 {
|
|
t.Errorf("mount state = %+v, want the server's scope", out.State)
|
|
}
|
|
if len(seen) != 1 || seen[0] != "GET /api/mounts" {
|
|
t.Errorf("requests = %v, want one GET to /api/mounts", seen)
|
|
}
|
|
}
|
|
|
|
func TestMountCartridgeReturnsTheManifestItMounted(t *testing.T) {
|
|
var seen []string
|
|
server := mountServer(t, http.StatusOK, manifestBody, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
_, out, err := s.mountTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"})
|
|
if err != nil {
|
|
t.Fatalf("mount_cartridge failed: %v", err)
|
|
}
|
|
if out.Manifest.CartridgeID != "home_1" || out.Manifest.DNA != "deadbeef" {
|
|
t.Errorf("manifest = %+v, want the mounted cartridge", out.Manifest)
|
|
}
|
|
if out.Manifest.KVFingerprint["rope"] == nil {
|
|
t.Error("manifest lost the kv fingerprint that gated the mount")
|
|
}
|
|
if len(seen) != 1 || seen[0] != "POST /api/cartridges/home_1/mount" {
|
|
t.Errorf("requests = %v, want one POST to the mount route", seen)
|
|
}
|
|
}
|
|
|
|
func TestSwitchAndUnmountUseTheirOwnRoutes(t *testing.T) {
|
|
var seen []string
|
|
server := mountServer(t, http.StatusOK, manifestBody, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
if _, _, err := s.switchTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"}); err != nil {
|
|
t.Fatalf("switch_cartridge failed: %v", err)
|
|
}
|
|
if _, _, err := s.unmountTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"}); err != nil {
|
|
t.Fatalf("unmount_cartridge failed: %v", err)
|
|
}
|
|
want := []string{"POST /api/cartridges/home_1/switch", "POST /api/cartridges/home_1/unmount"}
|
|
if strings.Join(seen, ",") != strings.Join(want, ",") {
|
|
t.Errorf("requests = %v, want %v", seen, want)
|
|
}
|
|
}
|
|
|
|
func TestMountRefusalCarriesTheServersReason(t *testing.T) {
|
|
var seen []string
|
|
server := mountServer(t, http.StatusBadRequest,
|
|
`{"detail":"home_1 was built for a different runtime; refusing to mount: rope: factor: cartridge 4.0 vs local 2.0"}`,
|
|
&seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
_, _, err := s.mountTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"})
|
|
if err == nil {
|
|
t.Fatal("an incompatible cartridge must not mount quietly")
|
|
}
|
|
if !strings.Contains(err.Error(), "rope: factor: cartridge 4.0 vs local 2.0") {
|
|
t.Errorf("error = %v, want the field the server named", err)
|
|
}
|
|
}
|
|
|
|
func TestMountToolsRefuseAnEmptyCartridgeID(t *testing.T) {
|
|
var seen []string
|
|
server := mountServer(t, http.StatusOK, manifestBody, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
if _, _, err := s.mountTool(context.Background(), nil, CartridgeIDInput{}); err == nil {
|
|
t.Error("mount without an id must fail before the network")
|
|
}
|
|
if _, _, err := s.switchTool(context.Background(), nil, CartridgeIDInput{}); err == nil {
|
|
t.Error("switch without an id must fail before the network")
|
|
}
|
|
if _, _, err := s.unmountTool(context.Background(), nil, CartridgeIDInput{}); err == nil {
|
|
t.Error("unmount without an id must fail before the network")
|
|
}
|
|
if _, _, err := s.appendTool(context.Background(), nil, AppendInput{CartridgeID: "home_1"}); err == nil {
|
|
t.Error("append without atoms must fail before the network")
|
|
}
|
|
if len(seen) != 0 {
|
|
t.Errorf("requests = %v, want none", seen)
|
|
}
|
|
}
|
|
|
|
func TestAppendSendsTheAtomsItNames(t *testing.T) {
|
|
var seen []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body := make([]byte, 512)
|
|
n, _ := r.Body.Read(body)
|
|
seen = append(seen, r.URL.Path+" "+string(body[:n]))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(manifestBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
_, out, err := s.appendTool(context.Background(), nil, AppendInput{CartridgeID: "home_1", AtomIDs: []string{"mem-1:text"}})
|
|
if err != nil {
|
|
t.Fatalf("append_to_cartridge failed: %v", err)
|
|
}
|
|
if out.Manifest.AtomCount != 2 {
|
|
t.Errorf("manifest = %+v, want the rewritten atom count", out.Manifest)
|
|
}
|
|
if len(seen) != 1 || !strings.Contains(seen[0], "/append") || !strings.Contains(seen[0], "mem-1:text") {
|
|
t.Errorf("requests = %v, want one append carrying the atom id", seen)
|
|
}
|
|
}
|