fix(searcher): scope neighbor expansion by parent_drawer_id (#1580) - #1582
Merged
igorls merged 1 commit intoJun 14, 2026
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to isolate memory chunks by parent_drawer_id when they share the same source_file. By adding the _scoped_source_filter helper and updating _expand_with_neighbors and search_memories, the system now correctly scopes neighbor expansion and total drawer counts to specific logical groups, preventing data leakage between unrelated drawers. Extensive regression tests have been added to ensure correct behavior for both new and legacy data. I have no feedback to provide as there were no review comments to assess.
mvalentsev
marked this pull request as ready for review
May 22, 2026 00:15
mvalentsev
force-pushed
the
fix/1580-neighbor-scope-parent-drawer-id
branch
5 times, most recently
from
May 24, 2026 23:26
7913288 to
08d8ba2
Compare
Open
9 tasks
milla-jovovich
added a commit
that referenced
this pull request
May 29, 2026
… second site) @fatkobra cold-read the searcher on PR #1628 and caught a missed #1580 site: the drawer-grep enrichment block in `search_memories` (`searcher.py:1054`) still fetched by `source_file` alone after amendment-2 scoped `_expand_with_neighbors`. Two unrelated chunked drawer groups sharing the same `source_file` could still leak across the enrichment boundary into a single rendered result. @mvalentsev had filed PR #1582 carrying the same fix in a cleaner shape — a shared `_scoped_source_filter` helper applied at both `_expand_with_neighbors` and the enrichment block, with `_parent_drawer_id` threaded through internal search hits and stripped before return. We carry his patch into this amendment verbatim; PR #1582 to be closed after merge. Three corrections / honest acknowledgments 1. @mvalentsev also flagged the amendment-2 commit body's claim that `.documents` attribute access "raised AttributeError in every code path." That was empirically wrong. `_expand_with_neighbors` receives a `ChromaCollection` wrapper from `palace.get_collection()`, whose `get()` returns a `GetResult` dataclass that inherits `_DictCompatMixin` — both `.documents` and `["documents"]` resolve. The crash I observed was in my amendment-2 test against a raw `chromadb.PersistentClient(...)`; I extrapolated to production without tracing `drawers_col` back through the wrapper. /bt miss by me. The subscript change can stay in (the test does guard against the wrapper ever dropping the mixin), but the rationale in the amendment-2 body is wrong. 2. The new amendment-2 test (test_neighbors_do_not_cross_parent_drawer_id in tests/test_additive_mining_preservation.py) was updated to use `palace.get_collection()` (the production path) instead of raw chromadb. Matches the production exercise; passes against the wrapper as it should. 3. mvalentsev's filter uses a 3-clause `$and` in `_expand_with_neighbors` (source_file + chunk_index + parent_drawer_id). Gemini's note on amendment-2 about a "2-only $and limit" turned out to be overcautious — empirically verified by both mvalentsev's test in PR #1582 and our new search_memories test running green against the wrapper. We adopt mvalentsev's 3-clause shape. §1.8 styleguide audit — every bare `where={"source_file": ...}` in mempalace/ Per the new §1.8 styleguide panel (partial-scope-key-migration), a fix that introduces a new scope key must grep every query against the OLD coarser key and either migrate it or document why it is exempt. Full repo audit, six external sites: - searcher.py Two sites already in scope (both call `_scoped_source_filter` via mvalentsev's patch). Strict grep for bare `where={"source_file":` in searcher.py returns empty. VERDICT: clean. - sync.py:305 `closets_col.get(where={"source_file": {"$in": ...}})` — closet bulk purge during sync. Closets don't carry parent_drawer_id (their `upsert` at `palace.py:421` only takes the base metadata dict). `$in` over a list explicitly signals bulk file-global intent. VERDICT: not a candidate. - diary_ingest.py:235 `drawers_col.delete(where={"source_file": source_file})` — inside the `if full_rebuild:` branch. Comment is explicit: must clear ALL drawers for the source (legacy `drawer_diary_` prefix, v2 prior-pass orphans, entry-boundary shifts). File-global is the whole point of full_rebuild. VERDICT: not a candidate. - palace.py:395 `closets_col.delete(where={"source_file": source_file})` — closet purge before re-mine. Same family as sync.py:305 above: closets don't carry parent_drawer_id. VERDICT: not a candidate. - palace.py:763 `collection.get(where={"source_file": source_file}, limit=1)` in `file_already_mined()` (extract_mode-is-None branch). Under PR #1593's additive mining, multiple parent_drawer_id groups for the same source_file can exist (one per mining pass); `limit=1` returns whichever ChromaDB happens to order first, and the function then checks `source_mtime` against that arbitrary group. If a stale group is picked when the latest group's mtime matches, the function returns False and the additive miner writes yet another duplicate layer. VERDICT: real bug, separate scope. Fixing correctly requires either iterating all groups or a fuzzy-mtime query; ChromaDB's `where` doesn't support fuzzy float equality, so the fix is bigger than a one-line patch and exceeds this amendment's scope. Filing follow-up issue. - palace.py:771 Paginated `collection.get(where={"source_file": source_file}, limit=1000)` in `file_already_mined()` (extract_mode-is-set branch). Full paginated scan with Python-side mode filtering downstream. File-global by design. VERDICT: not a candidate. - convo_miner.py:118 Paginated `collection.get(where={"source_file": source_file}, limit=1000)`. Same shape as palace.py:771. Comment confirms file-global intent ("deleting newer general-mode drawers for the same transcript"). VERDICT: not a candidate. Verification - macOS Python 3.12 (local) full pytest : 2281 passed, 0 failed - Linux Python 3.9.25 (OrbStack) full pytest : 2273 passed, 0 failed (one isolation flake in test_mcp_server.py on first run, passed on rerun in isolation AND on full-suite rerun — unrelated to this amendment) - Linux Python 3.11.15 (OrbStack) full pytest : 2275 passed, 0 failed - Linux Python 3.13.13 (OrbStack) full pytest : 2275 passed, 0 failed - ruff check + ruff format --check : all clean - empirically verified 3-clause `$and` works against ChromaCollection wrapper (both new tests pass) Two new tests pin the failure space - test_expand_isolates_chunks_by_parent_drawer_id_when_source_file_shared Adapted from mvalentsev's PR #1582 test — `_expand_with_neighbors` with two parent_drawer_id groups sharing source_file. - test_search_memories_enrichment_isolates_by_parent_drawer_id New Cedar test — the search_memories enrichment site fatkobra flagged. Same fixture pattern as mvalentsev's test plus a closet pointing at group A's chunk to trigger `matched_via == "drawer+closet"` and exercise the enrichment block. Confirmed RED on broken code (BRAVO_GROUP_B leaks into ALPHA_GROUP_A's enriched result), GREEN on the fixed code. Credit - @fatkobra — cold-read review caught the missed enrichment site - @mvalentsev — pre-existing PR #1582 with the cleaner helper shape, the dict-compat-mixin correction, and the regression test for `_expand_with_neighbors` - Wick (cold-read instance) — sphere walk of the styleguide §1.8 requirement, the 6-external-site audit, and identification of palace.py:763 as the worth-second-look site Closes #1580 (the second site).
milla-jovovich
added a commit
that referenced
this pull request
May 29, 2026
…nd-limit claim Amendment-3 carried @mvalentsev's `_scoped_source_filter` patch but only one of his five regression tests. @mvalentsev surfaced the gap in his follow-up review on PR #1628 and offered to have them carried here. This amendment carries the three remaining ones verbatim. Three new tests (all in tests/test_closets.py, TestDrawerGrepExpansion): - test_expand_backwards_compat_no_parent_drawer_id_returns_all_source_neighbors Pins legacy-drawer behaviour: when a hit has no `parent_drawer_id` in its metadata (drawers written before the field existed), neighbor expansion still returns ALL chunks under the `source_file` scope. Catches a regression where the new scoping helper would accidentally narrow the legacy path. - test_expand_isolates_asymmetric_groups_under_shared_source_file Asymmetric coverage — group A has 1 chunk, group B has 3 chunks, shared source_file. Defensive edge case that catches a regression where the helper would over-count or under-fetch when one group is much smaller than the other. - test_expand_empty_string_parent_drawer_id_treated_as_absent Contract pin — an empty-string `parent_drawer_id` value (`""`, e.g. from a stripped JSON field) degrades to the 2-clause file-global filter, matching the `if parent_id:` truthiness check in `_scoped_source_filter`. Without this pin, a future refactor that switched the check to `if parent_id is not None:` would silently change behaviour for the empty-string case. Cedar's amendment-3 test (`test_search_memories_enrichment_isolates_by_parent_drawer_id`) overlaps with @mvalentsev's `test_hybrid_search_enrichment_isolates_chunks_across_drawers_sharing_source_file` in surface and intent. Both stay — different fixture setups exercise the same code path slightly differently, and the redundancy is the cheap kind. Net new in this amendment: 3 tests. Honest correction on the `$and` limit claim — round 2 The amendment-2 commit body said gemini's "ChromaDB $and only accepts exactly two dictionaries" finding was the reason I rewrote _expand_with_neighbors to drop `source_file` when `parent_id` is present. The amendment-3 body softened this to "overcautious for the wrapper layer" and adopted @mvalentsev's 3-clause shape. @mvalentsev went further and cited the actual ChromaDB source — `validate_where` at chromadb/api/types.py requires `$and`/`$or` to hold AT LEAST two expressions (`len(value) <= 1 -> raise`), with NO upper bound. The codebase already builds a 3-clause $and at `mcp_server.py:1764` (diary list with wing + room + agent), through the same wrapper. So gemini was directionally wrong (it's a minimum, not a maximum), and my 2-clause amendment-2 workaround was unnecessary all along. @mvalentsev's 3-clause shape (which we already carried in amendment-3) is the correct production form. Keeping `source_file` in the neighbor filter does not lean on `parent_drawer_id` always being derived from `source_file`, and costs nothing. The styleguide §1.8 panel referenced gemini's 2-only claim as the amplifier of the partial-scope-key-migration defect class. That reference is now wrong and should be removed or rewritten when someone next touches §1.8. Filing a separate small PR to update the private styleguide; not in scope for this amendment to mempalace. Verification - macOS Python 3.12 (local) full pytest : 2284 passed, 0 failed - Linux Python 3.9.25 (OrbStack) : 2277 passed, 0 failed - Linux Python 3.11.15 (OrbStack) : 2278 passed, 0 failed - Linux Python 3.13.13 (OrbStack) : 2278 passed, 0 failed - ruff check + ruff format --check : all clean - TestDrawerGrepExpansion class (11 tests, was 8 before this amendment): all green Credit - @mvalentsev — PR #1582 owner, author of all 3 tests carried here, empirical $and-limit verification with chromadb source citation, and the lifting reminder that we carry not just the helper but the regression coverage that goes with it.
mvalentsev
force-pushed
the
fix/1580-neighbor-scope-parent-drawer-id
branch
from
May 30, 2026 17:45
08d8ba2 to
fb0c2a1
Compare
mvalentsev
force-pushed
the
fix/1580-neighbor-scope-parent-drawer-id
branch
from
June 6, 2026 13:48
fb0c2a1 to
c4da6d5
Compare
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.
What does this PR do?
Closes #1580. After #1539 the chunked
tool_add_drawerpath (mempalace/mcp_server.py:1212) writes per-chunk drawers tagged with aparent_drawer_idmetadata field linking them to their logical group. Neighbor enrichment insearch_memories(the inlined block atmempalace/searcher.py:967+, marked# Drawer-grep enrichment) and the sibling helper_expand_with_neighbors(mempalace/searcher.py:219+) filtered only bysource_file + chunk_index, so two unrelated logical drawers that happened to pass the samesource_filevalue (for example two pastes tagged"chat.log") had their chunks stitched together as if they were sequential context. Pre-#1539 the same call wrote a single drawer, so this could not happen.Triggering condition. The bug fires when the matched chunk has a non-empty
source_fileshared with anotherparent_drawer_idgroup and the source is closet-boosted (the live enrichment block runs only formatched_via == "drawer+closet"hits). The repro shape in the issue (tool_add_drawer(content=...)with the defaultsource_file) does not fire ondevelopbecause both call sites already guard withif not src(mempalace/searcher.py:239) andif not full_source: continue(mempalace/searcher.py:977); the defaultsource_file or ""frommempalace/mcp_server.py:1150is falsy and the enrichment short-circuits before the bad query runs. The repro that does fire passes an explicit shared non-emptysource_filevalue.Fix. A new module-private
_scoped_source_filter(mempalace/searcher.py:194) builds the Chromawhereclause:parent_drawer_idis supplied, returns a 2-clause$andscoping by(source_file, parent_drawer_id);{"source_file": ...}shape.This mirrors the conditional-
$andprecedent inbuild_where_filter(mempalace/searcher.py:169). Both the inlined block insearch_memoriesand the helper_expand_with_neighborsroute through it. The neighbor fetch inside_expand_with_neighborskeeps its existingchunk_index $in target_indexesclause and conditionally appends theparent_drawer_idclause, building a 3-clause$andwhen present.total_drawersis scoped consistently so the returned count matches the returned text for chunked drawers (for exampletotal_drawers == 2for a 2-chunk group, not the full source-file row count).Backwards compatibility. Drawers without
parent_drawer_id(single-chunktool_add_drawerwrites, pre-#1539 palaces, anddiary_ingestchunks grouped by real file path) keep the file-global 1-clause query shape. Verified bytest_expand_backwards_compat_no_parent_drawer_id_returns_all_source_neighborsand the pre-existingtest_expand_returns_matched_plus_neighbors. An empty-stringparent_drawer_idvalue is treated the same way as absent, pinned bytest_expand_empty_string_parent_drawer_id_treated_as_absent.Scope. This PR addresses the chunked
tool_add_drawerpath only.tool_diary_write(mempalace/mcp_server.py:1705) chunks tag a different metadata key (parent_entry_id) and are written without asource_filefield, so the existingif not src/if not full_sourceguards already prevent them from entering this enrichment path; no change there. Cross-parent_drawer_id closet boost behavior (one closet's source key boosts every drawer sharingsource_file, including chunks from a different logical group) is orthogonal to the neighbor-stitching bug and not changed by this PR.How to test
New tests in
tests/test_closets.pyclass TestDrawerGrepExpansion:test_expand_isolates_chunks_by_parent_drawer_id_when_source_file_shared(tests/test_closets.py:1337): helper-level RED ondevelop, GREEN on this branch. Twoparent_drawer_idgroups (2 chunks each) under sharedsource_file="shared.log"; asserts_expand_with_neighborsreturns only group A's chunks andtotal_drawers == 2(scoped to group).test_expand_backwards_compat_no_parent_drawer_id_returns_all_source_neighbors(tests/test_closets.py:1420): passes pre- and post-fix; pins the 1-clause fallback for legacy drawers anddiary_ingestchunks.test_hybrid_search_enrichment_isolates_chunks_across_drawers_sharing_source_file(tests/test_closets.py:1443): end-to-end throughsearch_memories. Two groups under sharedsource_file, closet boost on group A, search query targeting group A content. Asserts the returnedtextcontains only group A's content (no group B leakage),top["total_drawers"] == 2(scoped count on the live path), and that the internal scoring-loop keys (_parent_drawer_id,_source_file_full,_chunk_index,_sort_key) are scrubbed from every returned hit.test_expand_isolates_asymmetric_groups_under_shared_source_file(tests/test_closets.py:1551): asymmetric coverage (group A has 1 chunk, group B has 3) under sharedsource_file. Catches a regression wheretotal_drawersaccidentally drifts back to the unscoped file-global count when one group dominates the row mix.test_expand_empty_string_parent_drawer_id_treated_as_absent(tests/test_closets.py:1613): contract pin. An empty-stringparent_drawer_idvalue degrades to the 2-clause file-global filter, mirroring the empty-string handling forsource_filein the entry guard.Checklist
uv run pytest tests/ -v --ignore=tests/benchmarks)uvx --from 'ruff==0.15.9' ruff check . && uvx --from 'ruff==0.15.9' ruff format --check .)