Skip to content

feat: many palaces, one machine — config-dir lever, source-adapter registry - #2111

Closed
joshuafontany wants to merge 5 commits into
MemPalace:developfrom
amorphous-dreams:feat/2110-many-palaces-one-machine
Closed

feat: many palaces, one machine — config-dir lever, source-adapter registry#2111
joshuafontany wants to merge 5 commits into
MemPalace:developfrom
amorphous-dreams:feat/2110-many-palaces-one-machine

Conversation

@joshuafontany

@joshuafontany joshuafontany commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Addresses #2110. Stacked on #2107 — ② calls _hnsw_creation_metadata(), introduced there, so this
branch is cut from fix/2106-hnsw-guard-corrupts-what-it-protects and its diff includes #2107 until
that lands.

Who is asking

Same context as #2107 and #2109. We create instances of your code as libraries in ours:

  sensoriums/memory/content          ← does the job an upstream install does
  sensoriums/mark-twin/content
  sensoriums/kumulipo/content
  ~/.mempalace                       ← the operator's own install, when present

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__ takes config_dir documented "useful for
testing", with no env or CLI lever, while palace_path already honors MEMPALACE_PALACE_PATH. Every
instance 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_path exactly: explicit arg > env > default. Unset changes
nothing.

② Caller-supplied embedding function. get_collection(options.embedding_function) passes the
value verbatim: a function is used, None skips the model entirely and survives reopen, absent leaves
today'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.

A finding worth having even if ② does not land: the modern CreateHNSWConfiguration API leaves
collection.metadata empty — parameters go to configuration_json — which silently blinds the
divergence guard, the cosine detector (ChromaCollection.distance_metric), and
_read_sync_threshold, all of which read collection_metadata. Legacy metadata= stays correct.

③ 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 NAME lets it
deliver 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 queue leaves state that makes
tests/test_sync.py::TestSyncMcpTool fail when tests/test_mine_source_registry.py runs first — 7
failures 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 formatted
  • pytest tests/ --ignore=tests/benchmarks → 3423 passed, 31 skipped, 12 failed: the 7 above, plus 5
    test_init_filters_sys_path_from_leaked_pythonpath that fail identically on the base in the same
    worktree (a leaked PYTHONPATH here — 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.

joshuafontany and others added 5 commits July 31, 2026 18:01
`_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>
@joshuafontany
joshuafontany force-pushed the feat/2110-many-palaces-one-machine branch from 8d37e5f to 79291a1 Compare August 1, 2026 06:44
@joshuafontany joshuafontany changed the title feat: many palaces, one machine — config-dir lever, caller-supplied vectors, source-adapter registry feat: many palaces, one machine — config-dir lever, source-adapter registry Aug 1, 2026
@joshuafontany

Copy link
Copy Markdown
Contributor Author

Dropping the caller-supplied embedding-function commit — it fails to do what it claims.

embedding_function=None reaches only the client-level function. chromadb carries a second at the schema level, defaulting to a live DefaultEmbeddingFunction(), and _embed() falls through to it. On a fresh collection that commit stores a 384-dim ONNX vector — a model loading in the store process, the one outcome it existed to prevent.

The shape that holds needs an EF-less schema at creation. But create_collection rejects metadata= and schema= together, and this backend passes metadata= for the HNSW parameters, so the two cannot coexist. That goes as an issue referencing #1261, which meets the same wall.

--source through the daemon queue comes out too — it touches the single-writer seam while #1976 and #2033 run live there.

Remaining: MEMPALACE_CONFIG_DIR, the source-adapter registry, the ndjson adapter, stored metadata on search hits.

@joshuafontany

Copy link
Copy Markdown
Contributor Author

The caller-vector constraint: #2121.

@joshuafontany

Copy link
Copy Markdown
Contributor Author

Split into two, so each piece meets a reviewer at the size it deserves.

#2173mine --source through the RFC 002 registry, ready for review. It closes #2062 against that issue's acceptance criteria, including the --source × explicit --mode exclusivity.

#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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant