feat: many palaces, one machine — config-dir lever, source-adapter registry - #2111
feat: many palaces, one machine — config-dir lever, source-adapter registry#2111joshuafontany wants to merge 5 commits into
Conversation
`_HNSW_BLOAT_GUARD` pinned `hnsw:batch_size=2` / `hnsw:sync_threshold=2` on every collection this backend creates, to answer MemPalace#1579. Three findings on chromadb 1.5.9 (PersistentClient, Rust bindings, single writer, zero concurrency) retire it. **2 sits outside chroma's own declared valid range.** `hnsw_params.py:21-22` validates both knobs as `isinstance(p, int) and p > 2`. **The cost is write amplification, measured.** Bytes written (`/proc/self/io`) for one mine of N records, 64-dim, `num_threads=1`, identical corpus and seed: N 2/2 100/1000 ratio 10,000 47.1 MB 28.1 MB 1.67x 20,000 127.4 MB 60.0 MB 2.12x 40,000 390.3 MB 134.0 MB 2.91x Two runs at N=20,000 reproduced within 0.1%. `sync_threshold` dominates — 3/3 measured identical to 2/2, and 1000/1000 identical to 100/1000, so `batch_size` barely registers. The ratio grows with collection size, so the largest palaces pay the most. **It buys nothing.** A 5-record collection at 100/1000 — far below the threshold, so no persist ever fires — reads back whole from a fresh process: `count()` and the vector query both answer. On that artifact `link_lists.bin` is 0 bytes with no `index_metadata.pickle`, which is MemPalace#1579's trigger shape exactly, and `quarantine_stale_hnsw()` creates no drift directory. The knobs move into `_hnsw_creation_metadata()`, which both `create_collection` paths now call, so per-collection tuning has one home. Legacy `metadata=` is kept deliberately. Measured on 1.5.9: creating with `configuration=` leaves `collection.metadata` as `None` and the sqlite `collection_metadata` table empty, which blinds `_read_sync_threshold` and `ChromaCollection.distance_metric` — both read that table. (`collection.configuration` does read back under both paths; migrating those two readers to it is the prerequisite for ever moving the writer, and belongs in its own change.) Not claimed: that a small `sync_threshold` is a known chroma failure mode. No such report was found in chroma's issues or docs, and chroma's own guidance runs the other way. The Python persist path that MemPalace#1579 and chroma#6975 describe does not execute under the Rust bindings — `hnswlib` is not a dependency of this line.
…verride `MempalaceConfig.__init__` took `config_dir` as a constructor argument documented "useful for testing", with no environment or CLI lever — so the config directory hardwires to `~/.mempalace` for every spawned process, while `palace_path` already honors `MEMPALACE_PALACE_PATH`. A host that embeds mempalace as a sidecar spawns it as a process: it can pass env and argv, and nothing else. The config file supplies `backend`, `collection_name`, `embedding_model`, `write_routing`, `palace_path`, the milvus/qdrant/pgvector sets and ~20 more, so an embedding host inherits every one of them from a user-level file it does not own. Countering key-by-key with env vars does not scale to that surface; one symmetric lever closes it. Resolution order matches `palace_path`: explicit argument > environment > default. `~` expands, a blank value reads as unset, and an unset variable leaves `~/.mempalace` exactly where it stood — the change adds a lever and moves no existing behaviour. Six tests cover the promised order: env redirects both config-dir-derived files; a config.json under the env-named directory actually feeds resolved values; an explicit argument outranks the env; unset and blank both keep the home default; `~` expands. Revert-verified: with the change reverted, 3 of the 6 fail — the guard bites rather than passing vacuously. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NJGuNb4oxv4C8RJH8TJx
Wire `cmd_mine` to the RFC 002 source-adapter registry: when `--source NAME` resolves a registered adapter, mine runs its `ingest()` and files each `DrawerRecord` through a `PalaceContext` (the §1.2 incremental loop, under the palace lock). Opt-in and non-breaking — absent `--source`, the legacy `--mode` dispatch runs unchanged. This wires the seam RFC 002 §3.3/§9 reserve; the scaffolding (BaseSourceAdapter, registry, PalaceContext) was already complete. Adds `_mine_via_source_adapter`, the `--source` flag, and a conformance test (registry-routes-and-files / dry-run / unknown-adapter-exits). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ted records
A generic first-party source adapter (RFC 002 §3.2): each NDJSON line is one
pre-extracted record {content, source_file, metadata, chunk_index} filed as one
verbatim drawer. Byte-preserving (no declared transformations), per-source_file
chunk-index so records sharing a source_file never collide on the deterministic
drawer id, optional --wing routing fallback. Registered first-party in
sources/__init__.py (not the third-party entry-point group). 11 conformance tests.
Carries zero caller-specific vocabulary — any pipeline that pre-extracts records
can mine --source ndjson <spool>.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hits from search_memories() carried only display fields (text, wing, room, similarity, distances); every field a consumer stamped at ingest was dropped in flattening. Downstream filters and re-rankers (session-aware recall, provenance rules, custom pipelines) need those fields to match on. Each hit now carries its stored metadata dict whole. Covered by a seeded round-trip test; internal underscore-prefixed fields still strip as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8d37e5f to
79291a1
Compare
|
Dropping the caller-supplied embedding-function commit — it fails to do what it claims.
The shape that holds needs an EF-less schema at creation. But
Remaining: |
|
The caller-vector constraint: #2121. |
|
Split into two, so each piece meets a reviewer at the size it deserves. #2173 — #2174 — draft, holding the config-dir lever, the ndjson adapter, and stored metadata on hits until #2173 lands. The HNSW defaults that opened this branch merged in #2107, with the test follow-up in #2159. #2110 stays open as the umbrella; the caller-supplied-vectors face of it is filed separately as #2121. |
Addresses #2110. Stacked on #2107 — ② calls
_hnsw_creation_metadata(), introduced there, so thisbranch is cut from
fix/2106-hnsw-guard-corrupts-what-it-protectsand its diff includes #2107 untilthat lands.
Who is asking
Same context as #2107 and #2109. We create instances of your code as libraries in ours:
Three sovereign content palaces, one per sensorium (extendable), plus whatever the operator already
runs. Peers, never satellites. We consume mempalace as a library from our own NDJSON holder
processes, with the embedding contract inverted: vectors arrive on the wire, so no model loads in the
store process.
Everything below answers one question — what does a second, third, fourth instance need?
What this donates
①
MEMPALACE_CONFIG_DIR.MempalaceConfig.__init__takesconfig_dirdocumented "useful fortesting", with no env or CLI lever, while
palace_pathalready honorsMEMPALACE_PALACE_PATH. Everyinstance on a machine therefore reads the same
~/.mempalace/config.json— backend, collection_name,embedding_model, write_routing, the milvus/qdrant/pgvector sets, ~24 keys — so one install configures
the others. Resolution mirrors
palace_pathexactly: explicit arg > env > default. Unset changesnothing.
② Caller-supplied embedding function.
get_collection(options.embedding_function)passes thevalue verbatim: a function is used,
Noneskips the model entirely and survives reopen, absent leavestoday's path unchanged. Running many palaces, a model per store process costs more than the stores do;
a host that embeds upstream hands the vector over and asks the store to hold it.
③ Source-adapter registry, ndjson adapter, daemon-queue routing. Your miners find the content. A
host running its own extraction already holds it, in its own shape, and
--source NAMElets itdeliver records directly. The ndjson adapter proves the shape on a JSON-Lines spool. Pre-extracted
records carry the original words — the verbatim promise holds end to end.
④ Stored metadata on search hits (+5 lines), so one call answers completely.
Each alone reads as a convenience. Together they answer the question a reviewer actually rules on:
does mempalace support standing more than one of itself?
Status: draft
feat: route mine --source NAME through the daemon queueleaves state that makestests/test_sync.py::TestSyncMcpToolfail whentests/test_mine_source_registry.pyruns first — 7failures in a full run, none when either file runs alone. Dropping that one commit makes the branch
green; I would rather fix it. Tell me which you prefer.
Verification
ruff check .clean ·ruff format --check .→ 197 files already formattedpytest tests/ --ignore=tests/benchmarks→ 3423 passed, 31 skipped, 12 failed: the 7 above, plus 5test_init_filters_sys_path_from_leaked_pythonpaththat fail identically on the base in the sameworktree (a leaked
PYTHONPATHhere — what that test asserts about).Local ruff reads 0.15.20 against your pinned 0.15.14; let CI have the final word on lint.