feat(core): the membership store — one vector row per idea-atom, occupancy as a relation (bp-152) - #38
Merged
Merged
Conversation
…ip relation Implements dn-vector-membership-store D1/D2/D3/D8 (bp-152), the atom+membership split. The vector plane becomes an append-only dictionary of distinct idea-atoms keyed `(layer, content_hash)` — path-free, corpus-wide — and everything that used to be duplicated onto those rows once per version moves into a new SQLite membership relation keyed on the occupancy's coordinates `(path, blob_sha, layer, chunk_index)`. A version is the fiber `M(path, blob_sha)`. Why it is shaped this way, in the three places it would be easy to get wrong: * **A re-land is idempotent BECAUSE reconciliation converges, not because the call short-circuits.** `land()` runs D2 step 4 even when step 3 wrote nothing. On A -> B -> A the fiber for blob A already exists carrying `current=false`, so a lander that returns early on "fiber exists" leaves B marked HEAD — silent corruption of every default read, nothing raised. This is the C1 lesson `core/stores/versions.py:22-27` already documents at note grain. The same rule applies one level up: `sync()` reconciles every unchanged HEAD path rather than skipping it. * **The atom side dedups; the membership side must not.** `code_rows` collapses duplicates via `by_id.setdefault` — correct for geometry, wrong for occupancy. `code_memberships` builds its own list, so two byte-identical L0b windows in one blob stay two rows with distinct `chunk_index` (the F5 multiset pin). * **`provenance` stays on the atom row.** The mirror firewall is a row prefilter (`provenance IN (...)`, `prefilter=True`), so shedding the column would not weaken the firewall — it would remove it, with no failing call anywhere. The occupancy columns (`source_path`, `digest`, `title`, `chunk_index`, `qualname`, `line_*`) are shed from CODE-ATOM ROWS, not from the schema: note rows still carry them and no prose-lane consumer changes. `VectorStore.all_rows` gains a structural guard for the consequence the note's §3 Q5 names — an unscoped (all-strata) read excludes shed atom rows, because `group_sources` keys on `digest` and would collapse the whole atom plane into one bogus SourceSet keyed `''` with `MixedProvenanceError` unable to fire. The guard lives on the shed side; `core/kernel/**` is untouched, so `sourceset` stays in the inner-ring fixed point (the C5/D3 pin) and memberships enter the kernel as data through the existing `RowSource` protocol. Amendment A2 lands end-to-end: `CodeChunk.line_start`/`line_end` and the membership columns become `slot_line_start`/`slot_line_end` — the SLOT's declared extent, never the atom's text coverage. The vector-row Arrow columns keep their names (A2.3: that schema is shared with the prose lane, which has no slot concept). The embedder pin (owner confirmation 2026-08-01) is enforced structurally: atom presence is keyed to `(layer, content_hash)` AND `EmbeddingConfig.model` + `dim`, recorded in an `atoms` ledger table. The vector table cannot express this — its Arrow schema is shared with the prose lane and a stored vector recovers `dim` but never `model` — so without the ledger a model change would silently reuse the old geometry and put two geometries in one ANN space. No stored data is migrated and nothing is re-embedded: the rebuild is bp-153. Refs #34 (the A2 coordinate reading, now carried as an assertion rather than a docstring), #27 (the design's blessing and the embedder sub-confirmation). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LZZQPyGsoeGL73cbbEp3U
`ranked_paths` read `h["source_path"]` off a hit. Under the atom+membership split (bp-152 D1) a code hit is an ATOM and carries no `source_path` — occupancy lives in the membership relation — so the M-C3/M-C5 battery silently ranked nothing. `ranked_paths` and `run_mc3` take an OPTIONAL `memberships` store. With it, each hit contributes every path it currently occupies (D3's read join), which is the honest reading of a shared atom: one idea genuinely living in two files ranks both, at the same distance. Without it the pre-split behavior is byte-identical — a row that still carries `source_path` resolves to itself — so this is purely additive. An atom with no membership resolves to no path. That is correct rather than silent: dormant geometry from an interrupted land is not in any file yet, and inventing a coordinate for it would be the exact error D0's consequence note names. `eval/harness/` is outside bp-152's declared write_scope. The plan's investigation did not surface this consumer (it is the D3 read path, one file over the line), and the alternative was to knowingly leave the measurement instrument broken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LZZQPyGsoeGL73cbbEp3U
Session record for the atom+membership split: what was built per item, the one micro-gap in D1 that was decided rather than inferred silently (title is shed), the reach beyond write_scope and why, a table of how each acceptance criterion was made non-vacuous, and the handoff notes bp-153 needs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements bp-152 (
docs/build-plans/bp-152/plan.md) — D1/D2/D3/D8 ofdocs/design-notes/vector-membership-store.md, including Amendment A2. Second of threeplans on the code-ingest track; bp-151 (canonical identity) and bp-155 (path-independence) are
already merged, which is what makes the dedup claims here true.
Objective: store one vector row per idea-atom and carry all occupancy in a membership
relation, so a version is a fiber and a re-land is idempotent.
No stored data is migrated and nothing is re-embedded. Every stored-data item in this plan is
fixture-only; the live-store rebuild is bp-153.
What changed
New —
core/stores/memberships.py. The membership relationM ⊆ V × Oin SQLite, beside thevault catalog. Key = the occupancy's coordinates
(path, blob_sha, layer, chunk_index); columnscontent_id,slot,slot_line_start,slot_line_end,current,tombstoned. A version isthe fiber
M(path, blob_sha). Also: derived lineage (slot_runs/slot_edges, adjacentcollapse, quantified over a chain handed in), the D3 read join (
resolve_occupancies), the D5purge (
purge_atom), and the D8 repair pass (orphan_atom_ids,repair_current_any,current_any_drift).Outer ring, and structurally so:
core/kernel/**is untouched, sosourcesetstays in theinner-ring fixed point (the C5/D3 pin) and memberships enter the kernel as data through the
existing
RowSourceprotocol. A test scanscore/kernel/**for the import and carries a negativecontrol so its emptiness is a fact, not a broken check.
core/ingest/code_corpus.py.atom_id(chunk)=f"{layer}:{content_hash}"— path-free, corpus-wide. Dropping the path fromthe id is the atom model: it promotes the old per-path dedup to corpus-wide dedup (PD-1).
code_rows(chunks, vectors, *, current=False)emits atom rows with the occupancy columns shed.code_memberships(path, blob_sha, chunks)— the fiber, and the A2 translation point. It buildsits own list, one row per chunk, so the atom-side
by_id.setdefaultcollapse is notinherited.
CodeLander.land(path, blob_sha, chunks, *, head_blob_sha=None)— D2's five steps in D8's order,plus
reconcile()andsupersede_path().CodeCorpusSyncgained requiredmembershipsandembedder_identityfields, and itsD-fiber state re-homed from
{(source_path, digest)}tomemberships.fibers().core/stores/vectorstore.py.ATOM_ROW_SHED,is_code_atom_row, theall_rowsshed guard(below),
atom_rows(),set_current_any(ids, value)(batched, chunked predicates),delete_atom(id)(the purge path), and thecurrentcolumn's schema comment corrected to carryboth readings.
eval/harness/code_retrieval.py— see "the one reach beyond write_scope" below.Nine test files repaired; one new (
tests/unit/test_memberships.py, 18 tests).The three things it would have been easy to get wrong
1. A re-land is idempotent BECAUSE reconciliation converges, not because the call
short-circuits.
land()runs D2 step 4 even when step 3 wrote nothing. On A → B → A the fiberfor blob A already exists carrying
current=false, so a lander that returns early on "fiberexists" leaves B marked HEAD — silent corruption of every default read, nothing raised and
nothing logged.
core/stores/versions.py:22-27documents the repo learning this once already atnote grain. The same rule applies one level up:
sync()reconciles every unchanged HEAD pathrather than skipping it, because "unchanged blob ⇒ skip" is the identical short-circuit.
test_a_lander_that_short_circuits_on_an_existing_fiber_leaves_b_currentexhibits the buggylander passing every count assertion and failing the currency ones — so the currency assertions
are known to have teeth.
2. The atom side dedups; the membership side must not. Two byte-identical L0b windows in one
blob are two occupancies with distinct
chunk_index(the F5 multiset pin). The test assertsboth halves at once:
|V| == 1(geometry collapsed) andlen(fiber) == 2(occupancy did not).3.
provenanceSTAYS on the atom row. The mirror firewall is a row prefilter(
provenance IN (...),prefilter=True), so shedding the column would not weaken the firewall —it would remove it, with no failing call anywhere. The test asserts presence and equality to
CODE on every row, not merely "no code leaked". No firewall assertion in
test_code_mirror.pyortest_code_vector_isolation.pywas relaxed; both read through the new join and claim the samething.
The
group_sourcesguard (Item 3 — the note's §3 Q5 gap)source_sets(store)defaults to all strata by design ("a structural grouping utility, not amirror read"), and
group_sourceskeys onr["digest"]. Shed atom rows all carrydigest='', soevery code atom in the corpus would collapse into ONE bogus SourceSet keyed
''— andMixedProvenanceErrorcannot fire, because it needs a digest spanning several provenances andthese rows are uniformly CODE. It fails with no visible error at all.
The guard lives in
VectorStore.all_rows: an unscoped read excludes shed atom rows unless thecaller passes
include_atom_rows=True. It is on the shed side, not in the kernel, exactly as theplan requires. The test reproduces the falsifier first —
group_sources(vs.all_rows(include_atom_rows=True))really does return the''set containingevery atom — and only then asserts the guard; a second test pins that the guard keys on the shed
(
is_code_atom_row), never on the provenance, so the live store's existing pre-D1 code rows keepgrouping until bp-153 rebuilds them.
Two other consumers benefit rather than break:
core/curator/curator.py'sprune_candidateshasthe identical group-by-
digestshape and would have reported the whole atom plane as one orphaneddigest;
core/dreaming/evaluate.py's unscoped read becomes strictly more conservative.Amendment A2, end-to-end
CodeChunk.line_start/line_endand the membership columns becameslot_line_start/slot_line_end. The vector-row Arrow columns keep their names (A2.3 — sharedwith the prose lane, which has no slot concept). Stored values are unchanged; what changes is that
the misreading loses the name it was hiding behind.
The rename without the assertion would be cosmetic, since every leaf-symbol fixture passes
either way — so the fixture carries a class with methods and a module shell, and the test
asserts (i) the span equals the symbol's declared
lineno..end_lineno, (ii) the atom's textcoverage is a strict subset of that span (
def baris inFoo's span, never inFoo's text),(iii) the module shell's extent is the entire file for four lines of preamble, and (iv) — the
control that makes the rest mean anything — for the leaf symbol
topspan and coverage coincideexactly.
The embedder pin
Atom presence is keyed to
(layer, content_hash)andEmbeddingConfig.model+dim, recordedin an
atomsledger table in the same SQLite file. This is not a second truth about occupancy— it stores the one fact the vector table structurally cannot: its Arrow schema is shared with the
prose lane and has no embedder column, and a stored vector's
len()recoversdimbut nevermodel. Without it the owner's pin is unenforceable and a model change would silently mix twogeometries in one ANN space. It is also what makes an orphan observable for §8(e).
Its own test case, with the same-embedder control asserted first — a suite exercising one embedder
cannot see this bug.
Every criterion, and how it was made non-vacuous
|V| == 3 < Σ chunks == 4andn_doc(shared) == 2asserted FIRST; hand-built chunks so "exactly 1 new atom" is exact arithmetic|V|is unmoved; then the purge report is asserted non-zero and the tombstoned rows asserted to still EXISTn_doc == 0); default search excludes it,include_supersededsurfaces it, a shared atom resolves to both homes|V|grew,|M|did not, the fiber is empty,orphan_atom_ids()is non-empty and equals the landed set; the re-land then adopts them at zero embedsfiber()drops superseded rows; the drift invariant by a hand-flipped flagOne correction to the plan's phrasing, found while building: "slotted" is the layer, not a
non-empty name. The L0a module shell carries
qualname=''just like L0b/L1, so readingslottedness off
slot != ''would silently drop the lineage of the one slot every file has.slot_runsfilters onlayer == LAYER_CODE_AST.For the reviewer: two things to rule on
1.
titleis shed, and D1 does not say either way. The note's shed list enumeratessource_path, digest, qualname, line_*, chunk_index; the keep list isid, layer, text, vector, provenance, current.titleis in neither. On a code rowtitlewasset to
path— it issource_pathunder another name — so it is shed with the occupancycolumns. Keeping it would stamp every shared atom with its first-landed path, exactly the
coordinate D0's consequence note says must never be relied on. One-line reversal if you read D1's
enumeration as exhaustive.
2. One reach beyond
write_scope:eval/harness/code_retrieval.py(+~20 lines, additive).ranked_pathsreadsh["source_path"]off a hit; on an atom row that is'', so the M-C3/M-C5battery silently ranked nothing. The plan's §3 did not surface it (it is the D3 read path, one file
outside the enumerated scope).
ranked_pathsandrun_mc3gained an optionalmembershipsparameter: pass it and each hit contributes every path it currently occupies — the honest reading
of a shared atom; omit it and the pre-split behavior is byte-identical. Leaving the instrument
knowingly broken was the alternative.
tests/unit/test_code_lineage.pyis also outside §5's list and was repaired for the same reason(it constructs
CodeCorpusSyncand asserts stored(source_path, digest)).Known and sequenced, not a regression: the daemon's incompleteness probe
(
ops/lifecycle/launcher.py:_code_backfill_incomplete) still reads(source_path, digest)offvector rows.
ops/is out of scope by the plan and §6's re-home is bp-153's work — but it mustland before a rebuilt store exists or it false-positives forever (finding-0166's named
falsifier). Recorded in the journal's "For bp-153" section.
Verification
Each gate leg run separately (never
&&-chained; leg 3 exits 1 by design). Counts reported exactlyas observed.
uv run ruff check .uv run mypy core agents eval ops scheduler scriptsuv run mypy(argless)uv run python -m ops.type_gatetests/unit/test_restart_trustworthy.py(pre-existing, finding-0223)uv run python scripts/check_imports.pyThe "
core/kernel/**imports nocore.stores.memberships" claim is notcheck_imports.py's —that scanner is the network/zone firewall. It is
test_memberships.py::test_the_membership_store_is_never_imported_by_the_kernel, which scans thekernel tree and carries a negative control (the scanner is shown to fire on
code_corpus.py, whichdoes import it) so the empty result is a fact rather than a broken check.
tests/unit/test_inner_ring.pyis green, somemberships.pycomputes OUTER and the fixed point isunmoved — no
INNERedit, and none would have been legal.|
uv run pytest -q| 5 failed, 2450 passed, 15 skipped in 369.27s |The 5 failures are exactly the three known-red-locally classes, unchanged from the baseline —
no new red, and +23 passing:
Baseline on the clean base (68d8d39), before any edit:
5 failed, 2427 passed, 15 skipped—exactly the three known-red-locally classes: the finding-0103 core-self-containment ratchet,
tests/e2e/test_dream_v2_live.py, andtests/integration/test_worktree_enforcement.py×3(issue #13 / finding-0280). Both runs used the same worktree and the same
uv sync --extra devenvironment, so the two numbers are directly comparable.
Refs #34 (the A2 coordinate reading, now an assertion rather than a docstring), #27 (the design's
blessing and the embedder sub-confirmation). Files #37 as a consequence this plan surfaced but was
scoped out of fixing.
🤖 Generated with Claude Code
https://claude.ai/code/session_011LZZQPyGsoeGL73cbbEp3U