memory: do not report a semantic reinforce as successful when the row is gone - #248
Conversation
… is gone
nerve's `_semantic_sqlite_reinforce` monkeypatch decides from the in-memory item
cache but writes to the database, and its `return matched` sat OUTSIDE the
`if row:` write guard. When another process deleted the matched row between the
cache being populated and the reinforce, the row lookup returned None, the write
was correctly skipped, and the function nonetheless bumped the CACHED object's
`reinforcement_count` and returned it.
memU's pipeline reads that as "already persisted"
(`memu/app/memorize.py:614`: `if reinforce and
item.extra.get("reinforcement_count", 1) > 1: continue`) and skips both creation
and category linking. Net effect: the memory is silently dropped, the returned
id points at a row that no longer exists, and no exception or warning is raised.
The stale cache entry was also never evicted, so it stayed a dedup magnet: every
later semantically-similar memorize kept matching it and kept being dropped.
An absent row is a cache-invalidation signal, not an authoritative match. Track
whether the write actually committed; on the stale path evict the entry from
`self.items` and from the vector index (resyncing `seen_items_len`, the same two
operations `_indexed_delete_item` performs) and fall through to the real
`create_item_reinforce`, which genuinely creates and returns a persisted row.
The success early-return now lives inside the row-present branch, so it can only
fire when something was written.
The vector index handle is bound once and reused for both `search` and `remove`:
`_vec_index_for` rebuilds when it sees the cache size drift, so looking it up
again after the pop would force an O(n) rebuild on a write path.
The in-memory repo sibling `_semantic_inmemory_reinforce` has the same shape but
is unreachable (the metadata provider is hardcoded to sqlite), so it is left
unchanged.
…isibility Two mutants survived the first cut of these tests, both real weakenings: - deleting `idx.remove(match_id)` was invisible, because the assertion read the index through `_vec_index_for`, which REBUILDS whenever it sees the cache size drift and therefore silently repaired the missing eviction. Observing through `_vec_index_note` (no build) makes the dead entry visible: it stays in `id_to_row` without the remove. - deleting the `seen_items_len` resync was invisible too, and its consequence is worse than a stale entry: with `seen_items_len` (1) equal to `len(items)` (1) no rebuild ever fires, the index stays empty, and the item the fall-through just created is invisible to semantic dedup for the rest of the process, so the next similar memorize inserts a duplicate row instead of reinforcing. Pinned by a third similar reinforce that must land on the created item.
Pre-PR validation gate (click to expand)
Regression, compared by failure name rather than count: whole repo suite 7 failed / 2938 passed vs 7 failed / 2933 passed at Mutation matrix, since a green suite is not coverage: 6 mutants (cache-evict removed, index 50/50 randomized runs: not applicable, this is a Python unit-test repo with no Session id: cron:clickhouse-impl-slot-43:20260803-115000 |
Internal second-model review (click to expand)Before opening this PR I reviewed the resulting code cold, then had an independent model Outcome: 0 blockers, 0 majors, 3 nits. Nothing was found that changes the fix. The three
1 (from the independent pass). Correct and measured: the docstring is 15 lines / 122 2 (from my own pass). 3 (from my own pass, disagreed). Independent-review cost for this PR: $3.67 over 1 round (Gate A + Gate B combined: $6.09). |
Two review nits from PR ClickHouse#248, both test hygiene in tests/test_memu_bridge.py. _semantic_reinforce_store() called MemUBridge._patch_sqlite_bugs() on every invocation, i.e. once per test. Eight of the fourteen attributes that helper reassigns install a wrapper that closes over and calls the value it replaced, and none of the eight has an idempotence guard, so each extra invocation added a layer: after the five tests in TestSemanticReinforceStaleCacheHit the create_item_reinforce chain was six deep, and that nesting persisted for the rest of the pytest process. The helper already memoizes the scoped SQLAlchemy models in a default-arg cache for exactly this once-per-process setup, and the model build is itself one of the patched symbols, so "models cached" already implies "patch applied". Moving the call inside that existing block leaves the patch installed and takes the chain from six back to two. Repatching stays unconditional in production and in the convention test at :1028, which asserts the wrapper is reapplied; only the repeat invocation from this helper goes away. The class docstring restated the root cause at length. That mechanism is recorded in 1f29871's commit message, which is reachable from a checkout and does not rot after merge, so the docstring is now a statement of the contract the tests check; the test names and per-assert messages carry the rest. No production change, no test added or removed. Whole suite unchanged by name (7 failed / 2938 passed before and after, the same pre-existing timezone failures).
Internal second-model review (third commit) (click to expand)The third commit ( Outcome: 0 blockers, 0 majors. The independent pass returned 0 findings. My own
1 (and 4). The commit message and both comments said "8 of the 14 attributes The commit message is left as-is, with "Eight of the fourteen". Amending it would move 2 and 3. Refreshing the description for the third commit had rewritten it rather than 5. Re-checking every remaining figure in the validation-gate comment (a wrong figure The unguarded subscript ( One thing worth stating plainly about the code itself: reverting the moved call leaves the Independent-review cost for the third commit: $9.41 over 1 round (this PR total, Gate A + |
Pre-PR validation gate (third commit: test hygiene)
Suites by name, not count: |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Problem
MemUBridge._patch_sqlite_bugs()'s_semantic_sqlite_reinforcedecides from thein-memory cache but writes to the database, and its
return matchedsatoutside the
if row:write guard. If another process deleted the matched row,the lookup returned
None, the write was correctly skipped, and the cached object'sreinforcement_countwas still bumped and returned.memU reads that as "already persisted" (
memu/app/memorize.py:614:if reinforce and item.extra.get("reinforcement_count", 1) > 1: continue) and skips creation andcategory linking, so the memory is silently dropped, nothing raised or logged,
and the un-evicted stale entry stayed a dedup magnet swallowing every later similar
memorize.Measured at
main(94406ea), reinforcing similar text after deleting the row overa second connection: returned id == the deleted id, count 2, rows in DB
0, dead id still served by
repo.items, the index andlist_items().Fix
An absent row is a cache-invalidation signal, not an authoritative match. Track
whether the write committed; on the stale path evict from
self.itemsand the vectorindex (resyncing
seen_items_len, the two operations_indexed_delete_itemperforms)and fall through to the real
create_item_reinforce, which creates and returns apersisted row. The success early-return now sits inside the row-present branch, so it
fires only when something was written. The index handle is bound once for
searchand
remove, since_vec_index_forrebuilds on cache-size drift and re-fetching itafter the pop would force an O(n) rebuild on a write path.
_semantic_inmemory_reinforcehas the same shape but is unreachable (providerhardcoded to
sqlite), so it is unchanged. The danglingmemu_category_itemsrow isthe external deleter's, out of scope here.
Tests
TestSemanticReinforceStaleCacheHitintests/test_memu_bridge.py(5 cases): astale hit creates a new persisted row; cache and index entries are evicted; the
returned item is always
SELECT-able; the new item stays visible to later dedup;plus a row-present control, byte-identical on both trees.
The 4 stale-path tests fail at
main, pass with the fix. Whole repo suite7 failed / 2938 passed vs 7 / 2933 at
main: failure sets identical by name(all pre-existing), passes
+5= exactly the new tests. Six mutants are each killedby a new test; two initially survived and the tests were strengthened. Both
rufffindings here are byte-identical at
main;memu_bridge.pyis ruff-clean.Third commit, test hygiene only. The helper called
_patch_sqlite_bugs()per test,and 8 of the 17 attributes it reassigns wrap the value they replace, none guarded, so
the
create_item_reinforcechain reached 6 deep for the rest of the pytestprocess. Moving the call into the existing
_models_cachememo takes it to 2,patch still installed. No production guard is added:
:1028asserts unconditionalre-wrapping.