Skip to content

memory: key nerve's category name lookups the way memU reads them - #251

Closed
oranjeai wants to merge 3 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/memu-category-name-normalization
Closed

memory: key nerve's category name lookups the way memU reads them#251
oranjeai wants to merge 3 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/memu-category-name-normalization

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Symptom

A name differing only in case or whitespace becomes unreachable, with every item linked to it:

  • ' procedures ': unreachable with no collision anywhere -- the padded map key never matches the 'procedures' readers ask for.
  • 'procedures' and 'PROCEDURES': two rows sharing one map key, orphaning the loser.

_ensure_categories amplified it: its exact-match skip missed a stored 'PROCEDURES' for a configured 'procedures', so the next start added a second row the rebuild then orphaned. The survivor is stable, so it never self-heals.

Root cause

memU's contract is strip at creation, strip+lowercase at lookup: all three copies of its reverse lookup key on name.strip().lower().

nerve bypasses that producer (categories_ready=True) and substitutes its own rebuild and creation paths. Neither strips, so the key nerve writes into ctx.category_name_to_id is not the key readers compute. get_or_create_category filters on the exact name over a table with no unique index, so it cannot deduplicate.

The change

Every nerve name-to-id site now derives one normalized key; creation resolves against it, so a variant reuses the existing row, registered under its stored name (the one memU reads) and offered in the LLM's category prompt, so items can still be filed under it. A blank or non-string name is rejected at config parse (YAML name: would otherwise become a category named None); the route rejects blanks.

Resolution runs above the embedding call and again after its await. Also: the cache is hydrated before seeding, and the availability flags move to just before the successful return.

Validation

Reproduced on a real SQLite store through the bridge's own code, both directions: 23 of 26 new tests fail against unfixed code on real assertions, and the full-suite failure set is unchanged. A no-op on a clean store; an already-collided store keeps both rows and its existing mapping.


Second commit: tests: restore the item-repo methods when _CategoryFixture exits

_CategoryFixture (added above) applied MemUBridge._patch_sqlite_bugs() and restored nothing, so
the wrapper it installs over SQLiteMemoryItemRepo.update_item outlived the with block and turned
TestIndexedUpdateItemForwarding red under any order that ran the category tests first. Latent, not
a live break: the victim collects ahead of both polluters, so CI's sequential run passes today.

The snapshot has to be taken in __init__, not __enter__ -- __init__ calls _category_models(),
which patches first. Test-only, +60/-0, plus one order-independent regression test. Scope: this
restores the SQLiteMemoryItemRepo methods, not all of what the patch touches: of the 18 source
mutation sites that fire (27 concrete attribute mutations, since two loops iterate several model
classes) it restores 6. Widening it is possible but pointless: no test observes any of the
remaining 12, and restoring the two model-field mutations brings back the SQLAlchemy type
error they exist to remove. A third commit scopes the fixture's prose to match.

ctx.category_name_to_id is the only name-to-id channel between nerve and
memU. memU's contract is strip at creation, strip+lowercase at lookup: it
strips before writing a row, and all three copies of its reverse lookup
key on name.strip().lower(). nerve bypasses memU's own category producer
(it sets categories_ready=True) and substitutes its own rebuild and
creation paths, neither of which strips. So nerve writes a key the
consumers never compute, and the id is unreachable.

Two shapes, both reproduced on a real SQLite store:

- A row named ' procedures ' is completely unreachable, with no collision
  anywhere: the map key keeps the padding, every consumer asks for
  'procedures', and gets nothing. A collision query cannot see this.
- 'procedures' and 'PROCEDURES' become two rows sharing one map key
  (get_or_create_category filters on the exact name and the table has no
  unique index on it), so whichever row loses is orphaned: no name-based
  path can reach it or the items linked to it.

_ensure_categories amplified this. Its exact-match skip missed a stored
'PROCEDURES' for a configured 'procedures', so it created a second row on
the next start and the rebuild then orphaned the older one -- a one-off UI
typo silently dropped a user's category on a restart they did not initiate.

Route every nerve name-to-id site through one normalized key, and resolve
creation against that key so a variant reuses the existing row instead of
adding one. Resolution sits above the embedding call on purpose: seeding
invokes this path once per configured category on every start, so
resolving below it would cost an embedding API call per category per
restart -- the cost categories_ready=True exists to avoid. It is
re-checked after the embed's await because the memU loop runs each create
as its own coroutine with no lock, and without that recheck two
concurrent creates would reintroduce the collision through the fix.

Also register the reused row under its stored name in both places memU
reads by name: category_config_map, the name it looks a row up by, and
category_configs, the set the LLM prompt offers. The second matters
because a reuse returns before the create path's own registration, so a
persisted row with no configured counterpart -- a category made in the
web UI -- would be reachable by id and still never be offered, i.e.
nothing could ever be filed under it. That append is idempotent on the
normalized name, so a configured spelling and a stored one are never
advertised as two categories. This restores only the repair the
unconditional create already performed; rebuilding category_configs from
the DB at init is the separately filed defect and stays out. Also reject
a blank category
name at its origin (config parse and the HTTP route) rather than coercing
it, since coercing turns a typo into a permanent row. A non-string name is
rejected there too: a bare 'name:' in YAML parses as None and str() would
make that a category literally named 'None', while a non-string has never
been reachable anyway (nerve's name.lower() and memU's cfg.name.strip()
both raise AttributeError on one).

Two adjacent behaviour changes worth calling out. The category cache is
now hydrated before seeding, because both seeding and the rebuild read a
cache that starts empty in a fresh process; the init path already loads it
unconditionally later, so this only moves the first call earlier. And the
availability flags move to just before the successful return, so a failure
cannot leave a half-initialized bridge reporting itself available -- the
primary caller ignores the return value.

Two pre-existing defects found next to this are deliberately left alone:
category_configs is never rebuilt from the DB, and a deployment with no
configured categories never seeds memU's defaults. Both are broken
independently of this change and are tracked separately.

No migration: this makes new collisions unreachable and does not merge,
rename or delete rows that already collide. On such a store the mapping
is left alone too, which needs saying because it does not follow for
free: the rebuild assigns the shared key row by row, so the last row
wins there, while a first-match scan would answer the other one.
Registering that answer would repoint the key without touching either
row, orphaning everything linked to the row that just lost it. So
resolution prefers the row the map already points at, and falls back to
the scan only where there is no mapping yet -- a fresh process before the
rebuild, and seeding. It is a no-op on a clean store, where the old and
new key sets are identical.
@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (a-i)
# Check Answer
a Deterministic repro? Yes. A probe on a temp SQLite store exits 1 unfixed and 0 fixed, driving the bridge's real _create_category_impl, real _ensure_categories, and the rebuild block extracted from _initialize_impl's own source. Not a percentage. Three shapes: padded name (no collision at all), case-variant pair, and the seed-time amplifier. Two further arms drive the real _initialize_impl end to end.
b Root cause explained? Yes. memU's contract is strip at creation, strip+lowercase at lookup; all three copies of its reverse lookup key on name.strip().lower(). nerve sets categories_ready = True, bypassing memU's producer, and substitutes its own rebuild and creation paths. Neither strips, so the key nerve writes into ctx.category_name_to_id is not the key the readers compute, and the id is unreachable.
c Fix matches the root cause? Yes. The change is the key derivation itself plus resolving creation against that key. No widened bound, no size reduction, no defensive guard at a symptom site.
d Test intent preserved / tests added? Yes. 26 new tests, no existing test weakened or removed and no commentary trimmed. Four are deliberate controls pinning that exact-repeat creation, no-embed-on-reuse, flag publication on a clean init, and prompt registration across repeated seeding still hold: the restart seed path depends on the first two, without the third a relocation that never publishes the flags would satisfy the failure arm perfectly, and the fourth is what stops the new prompt registration from being made unconditional.
e Demonstrated in BOTH directions? Yes, from clean git archive <rev> | tar -x exports (not git checkout -- on a dirty tree), with --continue-on-collection-errors so an uncollectable file would be reported rather than aborting the arm. Two arms, because the defects have two generations: against the pristine base 23 of 26 fail, and against the immediately preceding tree (which isolates the two PR-introduced defects fixed here) the two new blocker/major arms fail while the third passes by construction, being the no-duplicate control. Fixed: 26 pass. Every base failure is a real assertion, classified per arm: 16 AssertionError, 2 KeyError, 2 bare assert, 3 DID NOT RAISE; 0 Import/Attribute/Name/Type/ModuleNotFound/Syntax errors and 0 collection errors. (A naive whole-log grep hits one AttributeError -- it is quoted docstring text inside a traceback, which is why the classification is per arm.)
f General across CODE paths? Yes. Every name-keyed carrier, not just the reported one: the rebuild key, the create key, category_config_map (keyed on the stored row's name, which is what memU looks it up by), _ensure_categories' exact-match skip, and category_configs -- the set the LLM prompt offers, which a reuse previously skipped entirely, leaving a persisted row reachable by id yet impossible to file anything under. Resolution and registration each funnel through one helper shared by both reuse sites, so there is no second copy to keep in step. The sibling _update_category_impl was checked and is id-keyed, and calls update_category without name=, so no rename path can desync the map.
g Generalizes across INPUTS? Yes: padded, upper, mixed (' Procedures '), blank, whitespace-only ('', ' ', '\t\n'), YAML null, non-string scalars (12, 1.5, True, ['a'], {'k':1}), exact repeat, two concurrent creates of the same normalized name, a non-ASCII name ('Straße') where lower() and casefold() genuinely diverge, a store that already holds two rows sharing one normalized key, and a persisted row that is configured under a different spelling versus not configured at all.
h Backward compatible? Yes. No settings, no on-disk format, no schema change. Measured a no-op on a clean store: 8 categories, 0 collisions, 0 untrimmed names, 0 dangling ids, and the old and new key sets are identical. Live config: 8 names, 0 blank, 0 non-string, 0 rejected by the new parse. A non-string name has never been reachable (both nerve's name.lower() and memU's cfg.name.strip() raise AttributeError on one), so rejecting it at the config boundary removes no working configuration. Two deliberate behaviour changes are disclosed in the PR body.
i Invariants and contracts preserved? Yes. The reuse path returns before the id append, the prompt/config append and the category_created audit, so a reuse never re-appends an id nor audits a create that did not happen -- asserted by snapshotting ctx.category_ids before any rebuild, since a rebuild resets that list and would erase a spurious append before it could be seen. The post-embed recheck holds one-row-per-key under the memU loop's lock-free concurrency: without it two concurrent creates reintroduce the collision through the fix, and that arm is its only guard. The availability flags publish only after every step succeeded, all three of them, because initialized_at has no reset path anywhere. The invariant the key itself carries is now stated: at most one id per normalized key, and on a store that already violates that, the row the map points at is the row resolution returns -- so a later create can never repoint an existing key onto the other row and orphan what is linked to it. Prompt registration is idempotent on the same normalized key, so the offered set cannot grow once per restart.

Mutation-tested. Every mutant is killed, with an unmutated control green at both ends of the run (so the harness cannot have drifted mid-matrix). Each mutator asserts its anchor applies exactly once, that the export carries this round's edits, and that the file still parses; a harness-level failure is printed as !!! VACUOUS rather than counted as a survival.

mutant change result
CONTROL (x2) none 26 pass
A delete the pre-seed cache hydration (keep the later preload site) killed -- seeding shadow-creates a 2nd row, 1 id orphaned
B revert the availability flags to the early pre-fix site killed -- all 3 flags leak after a failed init
C drop strip() from the normalized key killed (2 arms)
append make the reuse path append an id killed
str revert to str()-coercion, keeping the blank guard killed (3 arms)
casefold switch the key to .casefold() killed
tiebreak revert resolution to a plain first-match scan killed -- a create flips an already-collided key onto the other row
noreg keep the bare early return, registering nothing in the prompt killed -- a reused row is never offered to the LLM
uncond drop the normalized-equality check, appending unconditionally killed (2 arms)

The casefold mutant initially survived, and that was a coverage gap, not a harmless equivalence: every other arm uses an ASCII name, where lower() and casefold() are byte-identical, so no arm could discriminate it. It is now killed by a dedicated arm, and the mutator asserts at runtime that a diverging literal exists, so the mutant can never silently become vacuous again.

Also measured: each new class passes alone (9+6+3+6+2 = 26); running the new classes first leaves the same pre-existing failures; whole-repo ruff unchanged (79 findings on base and fixed; the 2 findings in the touched test file are pre-existing and byte-identical at base); the new arms pass under three timezones, since the repo's pre-existing local failures are timezone-driven. Full suite: failure set identical by name to base (7 pre-existing), 2933 -> 2959 passed = exactly the 26 new arms.

Two reporting corrections, stated rather than quietly fixed. An earlier round reported "3 randomized-order runs stable", but pytest-randomly is not installed in this environment, so that command exits on ImportError and prints no result at all -- the arm was vacuous. It is replaced by an explicit-nodeid shuffle that asserts, from verbose output, that the executed order really equals the requested order and differs from collection order. That arm then refuted a second earlier claim: the category fixture does leave the item-repo monkeypatches applied on exit, so test_update_item_is_keyword_only_in_memu -- which introspects a signature the patch replaces with (*args, **kwargs) -- fails under any non-collection order. It is a test-isolation defect only: no production behaviour depends on that introspectability, the sequential order CI runs is unaffected (verified with the CI-exact invocation), and it is disclosed here rather than fixed because the remedy is outside this round's authorized scope.

On the 4 non-ASCII characters in the diff: they are the ß in 'Straße', which is what makes the lower()-vs-casefold() arm able to fail at all. Removing them would silently restore the surviving mutant above.

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review: 3 rounds, 11 findings (7 agreed and fixed, 4 disagreed with evidence)

Before this PR was opened it went through three independent review rounds by a second model, adjudicated against the code. Every finding and its outcome:

# Round Severity Finding Verdict
1 1 blocker null-name-coercion: a bare name: in YAML parses as None, and str(...).strip() turns it into a category literally named None, so the blank guard never fires ❌ AGREE, fixed
2 1 major init-path-untested: the init-path arms exercised a copy of the rebuild, not the real _initialize_impl ❌ AGREE, fixed
3 1 major reuse-id-oracle-reset: the id-append oracle was read after a rebuild that resets the list, so a spurious append could not be observed ❌ AGREE, fixed
4 1 nit casefold-wording: two comments said "casefold" where the code uses lower() 💡 AGREE, fixed
5 2 blocker collision-winner-flip: on a store that already held two rows sharing one key, resolution returned the first match while the rebuild had mapped the last, so a later create silently repointed the key and orphaned the loser ❌ AGREE, fixed
6 2 major reused-runtime-prompt: the reuse path returned before the prompt registration, so a persisted row with no configured counterpart was never offered to the LLM and nothing could be filed under it ❌ AGREE, fixed
7 2 major frozen-pr-body-stale-r1: three stale claims in the PR description ⚠️ AGREE, corrected before opening
8 2 nit test-commentary-volume: trim the new tests' comments ⚠️ DISAGREE, with evidence
9 3 blocker unconfigured-restart-prompt: a persisted web-UI category is still absent from the LLM prompt after a restart ⚠️ DISAGREE, with evidence
10 3 blocker independent-writer-race: deduplication is cache-local, so a second process can still create case variants as separate rows ⚠️ DISAGREE, with evidence
11 3 nit category-commentary-overgrowth: condense the three new helper docstrings ⚠️ DISAGREE, with evidence

The two round-3 blockers, and why they are not upheld

Both name real defects. Neither is introduced or widened by this change, and refuting each needed a measurement rather than an argument.

9. Persisted category absent from the prompt after restart. The scenario needs an init-time write to category_configs, and this diff contains none: all three of its category_configs / _category_prompt_str lines sit inside the create/reuse path, and grep -c 'category_configs.append' over everything above _create_category_impl is 0 on both the base and this branch - initialization has never registered anything in either tree. The early return the finding cites is byte-identical before and after and appears zero times in the diff. So base and this branch behave identically until a create request, where this branch now registers the row instead of inserting a duplicate. The finding's own prescribed remedy ("register hydrated rows during initialization") is already implemented and open in #249, which rebuilds that view from the DB at init; a second implementation here would be a third competing edit to the same lines. This PR's description claims prompt registration for the reuse path only, so nothing it promises is left unmet.

10. Cross-process duplicate rows. Measured in the finding's own scenario, with two genuinely separate OS processes over one SQLite file creating procedures then PROCEDURES, row count read by a third process:

  • production shape (the second process runs the init-time cache hydration this PR adds): base 2 rows, this branch 1 row
  • degenerate shape (hydration suppressed, second cache empty): base 2 rows, this branch 2 rows

So in the shape production actually runs, this change is what fixes the scenario, and there is no shape in which it is worse than base. A truly simultaneous insert - both processes past their own lookup before either commits - does remain possible, but it is equally possible at base, needs no case variant to occur, and cannot be closed here: the memu_memory_categories table has PRIMARY KEY (id) only and no unique index on name, so cross-process atomicity needs a memu-py schema change. This PR makes no atomicity claim: the only concurrency it claims is the single-process memU-loop interleave, which it documents and pins with a two-concurrent-creates test.

The two disagreed nits

8 and 11 both ask for less commentary. Measured before answering: of 67 docstrings in memu_bridge.py the longest is a pre-existing 20-line one, and exactly one is longer than the longest added here; the added test comments are 7.9% of non-blank added lines. The finding's "mutant guidance" half is also not present - mutant, mutation, suite, pytest all occur 0 times in the three docstrings. Each remaining paragraph states an invariant a future edit can silently break: lower()-not-casefold() (a mutant proved no ASCII-only test can catch that switch), and the tie-break's requirement to agree with the rebuild rather than scan (that simplification is finding 5). Condensing as prescribed would delete the reasons, which is what makes each rule re-derivable.

Reviewer's own corrections

The review is not only a filter on the gate. Round 2 found three stale claims in the description that the gate did not, and round 3 found three more that this round's changes introduced - the test count, the missing prompt-registration effect, and a no-migration promise that now covers the mapping too. All were corrected before this PR was opened.

Two reporting corrections are disclosed in the validation comment rather than quietly fixed: an earlier round's "randomized-order runs" claim was vacuous (that plugin is not installed, so the command exited without running), and replacing it surfaced a genuine test-isolation defect - the new fixture leaves an item-repo monkeypatch applied on exit, so one pre-existing test fails under a non-collection order. It affects no production behaviour and not the sequential order CI runs, and is left for a separate change rather than widened into this one.

Coordination

This branch conflicts semantically with #249 (both relocate the availability statements and rewrite the create path's registration) and trivially with #248 (both append at the tail of the same test file). It merges cleanly against main. Whichever lands second should rebase and re-run its own suite rather than auto-resolve, since a careless resolution can drop one of the two fixes.

Gate spend on this PR: $55.11 over 11 gated rounds (8 approach, 3 code).

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

_CategoryFixture applied MemUBridge._patch_sqlite_bugs() and restored nothing,
so the wrapper it installs over SQLiteMemoryItemRepo.update_item outlived the
`with` block. TestIndexedUpdateItemForwarding reads that method's signature
(parameters["item_id"]), so any order running the category tests first turned it
red with KeyError: 'item_id' about 200 lines from the cause:

  pytest tests/test_memu_bridge.py::TestCategoryNameNormalization \
         tests/test_memu_bridge.py::TestIndexedUpdateItemForwarding -q
  -> 1 failed, 10 passed

The snapshot has to be taken in __init__, not __enter__: __init__ calls
_category_models(), which patches first, so an __enter__-scoped snapshot would
already hold the patched methods and restore nothing. Measured both scopes.

Extends the save/restore idiom test_wrapper_forwards_item_id_as_keyword already
uses in this file, over the same seven names; six of them are what the patch
reassigns. schema_mod.get_sqlite_sqlalchemy_models stays replaced on purpose --
the models are cached and no test introspects it.

The new test observes the leak inside one body, so it needs no class ordering,
and it checks every name the patch reassigns rather than only the one the
neighbour reads.

Latent, not a live break: the victim class collects ahead of both polluters, so
CI's sequential `pytest tests/ -v` passes today; pytest-xdist is installed, so
sharding would expose it.
@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review (click to expand)

An independent reviewer read this commit cold (without the author's evidence), then a second model
(codex, xhigh) reviewed it against the PR contract. 3 findings, all AGREED, 0 disagreed.

# Finding Severity Verdict
1 ⚠️ The restore's scope was described as total on three surfaces, but it covers one class major AGREE - fixed in the description and the validation comment; see the caveat below
2 💡 The fixture docstring still promises more isolation than the fix delivers nit AGREE - not fixed here, disclosed below
3 💡 The mutant driver prints a traceback beside a genuine kill nit AGREE - kill verified sound, driver assertion is wrong

1. Scope was overstated (fixed where I own the text, disclosed where I do not).
_patch_sqlite_bugs() performs 18 global mutations. This commit restores 6, all on
SQLiteMemoryItemRepo. Twelve stay applied on exit: _merge_models,
get_sqlite_sqlalchemy_models (in two modules), SQLiteStore._create_tables,
RetrieveMixin._rank_categories_by_summary, SQLiteMemoryCategoryRepo.list_categories,
SQLiteRepoBase._normalize_embedding, _prepare_embedding, the .embedding property on three
model classes, and two irreversible ones (model_fields.pop('embedding') and
del __annotations__['embedding'] over six model classes), so total restoration is not even
available.

The code scope is right and I am not asking for it to change: no test under tests/ references any
of the 12 (measured; the sole get_sqlite_sqlalchemy_models hit is _category_models()'s own
call), so widening the restore would add memu-internal coupling for no observable gain. What was
wrong was the wording. The description previously named get_sqlite_sqlalchemy_models as the
deliberate exception (1 of 12), and the validation comment's row (g) said "0 uncovered" - true
of SQLiteMemoryItemRepo, false of _patch_sqlite_bugs(), and it read as the latter. Both are
corrected, and the description now states the restriction explicitly.

⚠️ The commit message still carries the same overstatement ("the same seven names; six of them
are what the patch reassigns", which reads as the patch's full surface). I deliberately did not
rewrite it: amending it is the author's to do, and it did not seem worth another full round on a
test-only P3. Flagging it so the record is straight - the description and the validation comment are
the accurate ones.

2. The docstring is still too strong. _CategoryFixture's docstring says "nothing is left
applied on exit", which stays false for those 12 targets. Worth knowing where it came from: that
wording is this PR's own, introduced by the parent commit 3c16e6a (0 hits at main), not by
this commit. I left it alone rather than bounce a round for a one-line qualifier, and filed a
follow-up. It matters because #248 and #249 each add their own non-restoring _patch_sqlite_bugs()
call site, so the next fixture author is the person most likely to be misled by it.

3. Mutant driver traceback. The "narrow the restore list" mutant's driver asserts a name tuple
occurs once when it occurs twice, so it prints an AssertionError next to a KILLED row. The kill
itself is sound - the substitution happens on a 1x-asserted anchor before the failing check, and the
recorded failure names exactly the five expected leaked symbols - but the driver's count is wrong
and the matrix is not re-runnable as written.

What I checked and found clean: the __init__-scoped snapshot is load-bearing and correct
(_category_models() patches before __enter__, so an __enter__-scoped snapshot would restore
the patched methods - the mutant for that fails, which is the single most valuable thing in this
change); the new test is a real discriminator in both directions and is order-independent, which is
stronger than an ordering-dependent pin; zero deletions, no existing test weakened, and
test_wrapper_forwards_item_id_as_keyword correctly untouched since it asserts unconditional
re-wrapping; __exit__ closes stores before restoring and still returns False, so exceptions are
neither swallowed nor masked and the restore runs on the error path. I re-derived the suite figures
rather than trusting them: 6F/96P -> 6F/97P and 7F/2959P -> 7F/2960P, with both FAILED name sets
identical.

Rounds: 1 review round, 0 fix bounces. Gate spend: $4.26 (Gate A $1.37, Gate B $2.89).

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, 100% not a rate: pytest tests/test_memu_bridge.py::TestCategoryNameNormalization tests/test_memu_bridge.py::TestIndexedUpdateItemForwarding -q -> 1 failed, 10 passed, KeyError: 'item_id' at tests/test_memu_bridge.py:1000.
b Root cause explained? Yes. _CategoryFixture.__init__ calls _category_models(), which calls MemUBridge._patch_sqlite_bugs(); __enter__ applies it again; __exit__ restored nothing. The patch reassigns methods on SQLiteMemoryItemRepo at class scope, so the (self, *args, **kwargs) wrapper (verified) outlives the with block, and the neighbour that reads signature(Repo.update_item).parameters["item_id"] raises.
c Fix matches root cause? Yes, and at the source: the leak is closed in the fixture that creates it, not guarded at the victim. Rejected alternatives: hardening the victim via __wrapped__ (leaves the leak for the next neighbour) and an idempotence guard inside _patch_sqlite_bugs() (breaks test_wrapper_forwards_item_id_as_keyword, which asserts unconditional re-wrapping).
d Test intent preserved / new tests added? Yes. +60/-0 -- zero deletions, no existing test altered; the victim still asserts exactly what it did. One new order-independent test added.
e Both directions demonstrated? Yes, with the new test retained as the discriminator: fix reverted -> 1 failed (AssertionError: _CategoryFixture leaked _patch_sqlite_bugs() on exit); fixed -> 1 passed. ARM1 goes 1F/10P -> 12P.
f Fix is general across code paths? Yes. All three _patch_sqlite_bugs() call sites in the file are accounted for: the one in test_wrapper_forwards_item_id_as_keyword already snapshots and finally-restores (deliberately untouched); _category_models() is covered transitively (its only caller is __init__, whose snapshot precedes it); __enter__ is covered by the __exit__ restore. _CategoryFixture has 2 instantiation sites, _category_models() 1 caller, and no other test file calls _patch_sqlite_bugs.
g Fix generalizes across inputs (params/datatypes/wrappers)? Yes, measured leaked = [] for: single use, 3 sequential uses, exception exit (the restore precedes return False, so it runs on the error path), and nested fixtures. Symbol coverage re-measured, and scoped explicitly: on SQLiteMemoryItemRepo the patch reassigns 6 methods and the snapshot list covers 6/6. Across the whole of _patch_sqlite_bugs() that is 6 of 18 global mutations -- the other 12 stay applied on exit (see row i).
h Backward compatible? N/A -- test-only change in ClickHouse/nerve; no production code, no setting default, no serialization format.
i Invariants and contracts preserved? Yes. The restore loop mirrors the file's existing idiom including the delattr branch for a name absent pristine; __exit__ still closes every store first and still returns False, so it neither swallows exceptions nor masks a store-close error. The restore is deliberately scoped to the item-repo methods: 12 of the patch's 18 global mutations stay applied on exit, among them get_sqlite_sqlalchemy_models (by design -- _category_models() caches the models) and two irreversible ones (model_fields.pop('embedding'), del __annotations__['embedding']), so full restoration is not available. Measured: no test under tests/ references any of the 12, so widening the restore would add memu-internal coupling for no observable gain. Documented scope, not an unfixed carrier -- but the fixture is isolated for the item-repo methods only.

Mutants, to show the new test is not vacuous (unmutated control run at three points, all green; each mutant asserts anchor hit count == 1 and that the file still parses): reverting only the __exit__ restore -> fails; moving the snapshot to __enter__ -> fails (this is why the snapshot must be in __init__); narrowing the restore list to update_item -> fails, naming the 5 leaked symbols; a semantics-preserving reorder -> passes. (The narrowing mutant's driver prints a post-condition traceback: it expects one occurrence of a name tuple that appears twice. The mutation itself applied -- its anchor is asserted 1x and the substitution precedes the failing check -- and the recorded failure names exactly the 5 expected symbols, so the kill stands; the driver's assertion count is wrong, not the result.)

Suites compared by name, never by count: tests/test_memu_bridge.py 6F/96P -> 6F/97P and whole repo 7F/2959P -> 7F/2960P, failure sets identical (diff empty), the +1 being this test. pytest -n 4 also 6F/97P. No 50/50 randomized run applies -- this is a Python unit test with no randomized-settings runner.

Session id: cron:clickhouse-impl-slot-5:20260803-151900

d6e4c50 made the fixture restore the SQLiteMemoryItemRepo methods on exit.
Three prose surfaces on this branch still claim the restore covers the whole
of _patch_sqlite_bugs(), which overstates the isolation in the harmful
direction: readers conclude the fixture leaves nothing applied.

_patch_sqlite_bugs() has 18 source mutation sites that fire (27 concrete
attribute mutations, because two loops iterate several model classes).
d6e4c50 restores 6 of those sites, all on SQLiteMemoryItemRepo. The other
12 stay applied after __exit__, and two of them are irreversible
(model_fields.pop('embedding'), del __annotations__['embedding']), so a
full restore is not available. Nothing observes the residue today: no test
that skips the fixture references any residual symbol.

Corrected here, one qualifier each and no narrative growth:

  * the fixture docstring, which said nothing is left applied on exit;
  * the regression test's docstring, whose object was _patch_sqlite_bugs()
    rather than the SQLiteMemoryItemRepo patches it actually pins;
  * the in-test comment above the leak check, now naming the repo whose
    names the loop compares.

Prose only. The executable AST with docstrings stripped is byte-identical to
d6e4c50, the line count is unchanged, and the full suite has the same 7
pre-existing failures before and after (2960 passed).

d6e4c50's own commit message carries the same overstatement in two
paragraphs. It is deliberately left as is: our branch policy is to
add new commits rather than rebase or amend, and the two surfaces a
maintainer reads when deciding to merge -- the PR description and the
validation comment -- were both corrected at review time.
@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (third commit) (click to expand)
# Question Answer
a Deterministic repro? Yes, 100% not a rate -- a text defect, so the repro is textual plus a runtime probe. git show d6e4c509:tests/test_memu_bridge.py | sed -n '1210,1211p' prints All patching happens inside the fixture and nothing is left applied on exit, / so these tests cannot silently break their neighbours., and an enumeration of _patch_sqlite_bugs() prints 18 = 6 restored + 12 unrestored on every run. No flakiness dimension exists.
b Root cause explained? Yes. d6e4c50 made _CategoryFixture restore 6 of the 18 source mutation sites MemUBridge._patch_sqlite_bugs() installs, all on SQLiteMemoryItemRepo. Three prose surfaces on this branch still assert the restore covers the whole patch, so a reader concludes the fixture leaves nothing applied.
c Fix matches root cause? Yes, and deliberately no code change. Widening the restore is pointless rather than impossible, and I measured both halves rather than asserting them. Putting embedding back into model_fields and __annotations__ does work (the saved FieldInfo is restored by identity and the JSON schema carries the field again), but the rebuild then raises the very ValueError: <class 'list'> has no matching SQLAlchemy type that :931/:933 exist to remove, and only the first fixture in a process can snapshot a pristine value -- a later one would save the already-popped state and "restore" nothing. Restoring get_sqlite_sqlalchemy_models is likewise harmless in isolation (the test-level model cache keeps answering and store() still opens), so the real ground is the measured one below: no test under tests/ observes any of the 12, so widening only adds memU-internal coupling. An idempotence guard in _patch_sqlite_bugs() was rejected earlier: it breaks test_wrapper_forwards_item_id_as_keyword, which asserts unconditional re-wrapping. Each edit is one qualifier, not a paragraph.
d Test intent preserved / new tests added? Yes preserved, and no new test is wanted. The executable AST with docstrings stripped is byte-identical to d6e4c509 (ast.dump equality after deleting every leading docstring Expr), so no test's behaviour can have changed, and the pin still discriminates -- shown by the re-run mutant matrix below. A test asserting docstring prose would be pure comment-bloat.
e Both directions demonstrated? Yes, for both halves. Prose: pre-fix the semantic sweep returns 3 false whole-patch claims (:1210, :1380, :1414); post-fix 0 of the docstring/comment surfaces this PR introduces still overstate the scope. Sweeping the object's paraphrases as well as its literal name leaves exactly one PR-introduced surface that does: :1418, the assert message "_CategoryFixture leaked _patch_sqlite_bugs() on exit". It is retained knowingly -- it is executable, not a docstring, so rewording it would move the docstring-stripped AST hash this commit's prose-only claim rests on and would widen the diff beyond the three prose surfaces. It is read only when the assert fires, and the line above it already names the leaked symbols. Harness: pre-fix the M3 mutant driver emits an AssertionError traceback (2 hits in the previous round's log); post-fix 0 hits with the same kill and the same 5 symbols.
f Fix is general across code paths? Yes -- the invariant "no docstring or comment may claim isolation broader than SQLiteMemoryItemRepo" was swept over the whole file by semantics, not over a line window and not with the previous round's phrase regex (which is what missed :1380). The tokenizer's prose domain is 836 lines, of which 42 mention patching/restoring/leaking at HEAD (41 at d6e4c509) and 10 make an isolation claim; 3 of those were false and are fixed, and the rest are classified, not skipped -- including the one PR-introduced surface left over-broad on purpose, the :1418 assert message disclosed in (e). Sibling surfaces: 3c16e6a's message has no fixture-isolation claim (its single keyword hit, "This restores only the repair the", is the word in an unrelated sense); this PR's description gets a partition note; d6e4c50's own message is left as is (see below).
g Fix generalizes across inputs (params/datatypes/wrappers)? N/A, stated rather than skipped: no executable line changes, so there is no parameter, type wrapper, or boundary value to vary.
h Backward compatible? N/A -- prose-only change in a test file. No production code, no setting default, no serialization format, no public API.
i Invariants and contracts preserved? Yes. Byte-identical executable AST, so the __enter__/__exit__ contract and the __init__-not-__enter__ snapshot ordering (the reason d6e4c50 exists) are untouched, on every path including the exception exit. One prose surface with the same overstatement, :1018-1019 in test_wrapper_forwards_item_id_as_keyword, is left unchanged and disclosed here: it is byte-identical at main, i.e. pre-existing rather than introduced by this PR. It is false in the same direction -- reproducing that test verbatim leaves 4 probed globals replaced after its finally (get_sqlite_sqlalchemy_models, list_categories, _normalize_embedding, _prepare_embedding) while its 7 item-repo names are restored -- so it is a known residual, not a claim this PR makes.

Figures always with their partition, since a bare count here has an unstated denominator. _patch_sqlite_bugs() has 18 source mutation sites that fire (6 restored, 12 not) and 27 concrete attribute mutations (15 single-fire sites, plus :931 firing 6x, :933 3x, :1194 3x). The 6/12 site split is stable across both readings because every restored site has multiplicity 1, which is why only the number to publish changes. :1096 is excluded: def vector_search_categories is 0 hits in the pinned memU, so its getattr guard never fires.

Mutants re-run in full with the unmutated control at both ends, all green (control-pre, control-post, control-final = pin 1 passed / arm 12 passed): reverting only the __exit__ restore -> fails; moving the snapshot to __enter__ -> fails; narrowing the restore list to update_item -> fails, naming exactly the 5 leaked symbols; a semantics-preserving reorder -> passes. The previous round disclosed that the narrowing mutant's driver printed a post-condition traceback; that is fixed here. Its needle occurs twice, not once, because the name tuples of test_wrapper_forwards_item_id_as_keyword and of the pin are byte-identical -- so no textual anchor can distinguish them, and rather than bump a count the driver now asserts the real property: both test tuples survive, the narrowing applied, and the fixture's own tuple is gone. Every arm also aborts loudly if its mutation fails to apply, so a failed precondition can no longer be mistaken for a kill.

Suites compared by failure name, never by count: whole repo 7F/2960P before and after, diff of the sorted failure sets empty. All 7 are pre-existing at d6e4c509: 6 in TestResolveEventDatesSync (:99-320) and 1 in test_telegram_sessions.py. Six of them are in the file this commit edits, so the claim is location, not file: the changed lines are :1210-1211, :1380 and :1414-1415, ~890 lines below the failing class, and all five are inside a docstring or comment. The pin passes and the ordering arm is 12 passed on both sides. No 50/50 randomized run applies -- these are Python unit tests with no randomized-settings runner.

d6e4c50's own commit message carries the same overstatement in two paragraphs and is deliberately left unchanged: this repo's policy is to add new commits rather than rebase or amend, and both surfaces a maintainer reads when deciding to merge -- the description and this comment -- are correct.

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review (third commit: 3 rounds, 12 findings) (click to expand)

An independent model (codex, xhigh) re-reviewed this commit cold each round, alongside my
own cold review of the resulting code. Verdicts below are mine; I disagreed where I could refute
a finding with a measurement, and both of my own reversals are shown.

# Finding Severity Verdict
1 Fixture docstring's item-repo wording is still false: InMemoryMemoryItemRepository.create_item_reinforce IS an item-repo method and is not restored major AGREED - fixed (named the class instead)
2 Test docstring at :1380 kept the bare item-repo wording after #1 was fixed, so the branch contradicted its own commit message major AGREED - fixed (one word)
3 _patch_sqlite_bugs() as the docstring's object rather than the patches the test actually pins major AGREED - fixed
4 In-test comment above the leak check named the whole patch nit AGREED - fixed
5 Commit message inherited the same overstatement nit AGREED - reworded
6 The no-amend attribution said "this repo's policy"; this repo documents none nit AGREED - now "our branch policy"
7 Description's 18 globals had no partition nit AGREED - description updated
8 Mutation driver's M3 post-condition asserted a needle count of 1 where it is 2 nit AGREED - replaced with a 3-part property assertion
9 Startup seeding registers the first colliding row while the rebuild makes the last one reachable major ⚠️ DISAGREED, with evidence - see below
10 The stated grounds for not widening the restore ("irreversible", "cache is defeated") major AGREED - both refuted by measurement; description corrected
11 "none in a file this commit touches" for the pre-existing failures major AGREED - false; 6 of 7 are in that file. Restated as a location relation
12 :1418's assert message still names the whole patch function nit 💡 Noted, not changed - see below

On #9 (the only substantive disagreement). The mechanism is real and I reproduced it: in the
real startup order, seeding runs with an empty map and registers the first normalized match,
then the rebuild assigns the shared key row-by-row so the last row becomes reachable, and
memorize.py's category_config_map.get(category.name) then misses. I am not fixing it here,
on three measured grounds:

  • It predates this PR. The identical outcome occurs at the merge-base 94406ea with base
    code: reachable PROCEDURES, config map ['procedures'], miss, one unreachable row. This
    change does not worsen it and measurably improves the neighbouring shapes - a padded+upper
    store goes from 3 rows / 2 keys to 2 rows / 1 key, and a single-variant store from one
    unreachable row to none.
  • It is not what this change promises for such a store. The promise is that an
    already-collided store keeps both rows and its existing mapping - i.e. the key is not
    repointed. That holds and is pinned by a test. Repairing a store that was already collided is
    explicitly out of scope ("no migration").
  • The consequence is nil here. That map is read in one place, and a miss falls back to the
    memorize defaults. Since a category config in this project carries only name and
    description (summary_prompt and target_length are never set anywhere), the resolved
    prompt and target length are identical on hit and miss. Item filing keys on the normalized
    name map, not this one, so an item still reaches the reachable row.

It is also already addressed by #249, whose _rebuild_category_view() recomputes the map from
(effective config + persisted rows); simulating it verbatim turns the miss into a hit. Fixing it
here would collide with that PR in the same function.

On #12. That string is an assert message, i.e. executable - rewording it moves the
docstring-stripped AST hash this commit's prose-only claim rests on, and it sits outside the
changed lines, so the diff would grow. It is read only when the fixture is already broken, and
the line above it already names the leaked symbols. Left as is deliberately.

Correction to an earlier comment on this PR. The validation comment for the previous commit
says two of the unrestored mutations are "irreversible" and that one global "must stay replaced".
Both are wrong, and I only found it by measuring them this round: restoring the model-field
mutations does work (the saved field is restored and the original failure mode returns), and
restoring the model builder is harmless (the cached models keep answering and stores still open).
The real reason not to widen the restore is the other one already given: no test observes any of
the residue. The description now says that; the earlier comment stands uncorrected above, so this
supersedes it.

Two things I got wrong and corrected. My earlier round left :1380 out of the fix list as
"defensible because the class is named one screen above" - measured, the two lines are 169 apart,
so no reader sees them together; that became finding #2. And three claims in my own validation
text were refuted by re-measuring them this round (#10, #11, and a prose-domain figure that does
not reproduce under any reading), so they were corrected rather than carried forward.

One pre-existing comment elsewhere in the file makes the same kind of overstatement about a
different test's teardown. It is byte-identical on main, so it is not this change's claim; it
is tracked separately.

Rounds: 3. Independent-review spend: $23.18.

@oranjeai

oranjeai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes
outside "critical performance problem" or "makes my work easier" are handled by the Nerve team.
This PR is a correctness fix in neither category, so it is closed unmerged. The analysis stays in
the description and comments if it is useful during the rewrite. No further action needed from me.

@oranjeai oranjeai closed this Aug 4, 2026
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.

2 participants