memory: count only resolvable items in memU category totals - #252
memory: count only resolvable items in memU category totals#252oranjeai wants to merge 1 commit into
Conversation
## Symptom `memory_expand_category` advertises more items than it can return, e.g. "Category 'procedures' - showing 20 of 68069 items" when only 65655 are reachable; paging past the reachable end yields nothing. The web memory sidebar badge is inflated the same way. Measured on a live 146030-item store: 6455 of 175114 membership rows are unreturnable, a 3.69% overstatement present in all 8 categories (3.29% to 4.29%). ## Root cause `memu_category_items` is a link table with no foreign key to `memu_memory_items`, so delete-side writers strand membership rows whose item is gone. Two read paths counted those rows directly: - `MemUBridge.expand_category` computed `total` with an unjoined `count(*)`, while the item listing three lines below already joined `memu_memory_items`. So `total` and `items` were computed over two different row sets in the same function and connection. - `_read_memu_snapshot_sync` exported unjoined `(category_id, item_id)` pairs as `category_items`, which the web sidebar counts via `ids.length`. The stranded rows are permanent, not in-flight: they were created between 2026-06-25 and 2026-08-02. ## The fix Add the listing's own join predicate at both sites, so the count and the listing agree by construction rather than by coincidence. Chosen over an equivalent `EXISTS` because it is textually the same join the function already performs, leaving no room for the two to drift. Both queries stay fully index-covered (`SCAN ci USING COVERING INDEX idx_sqlite_category_items_unique` + `SEARCH i USING COVERING INDEX ...`); measured cost is 72.2 -> 102.8 ms for the once-per-page-load snapshot (already in a worker thread) and 9.4 -> 12.9 ms for a per-category total. Reported counts decrease for existing users. That is the point: the new numbers are what the store can actually return. Two pre-existing and distinct mismatches are deliberately NOT addressed here: `total` ignores the `query` filter, and the sidebar badge counts all memory types while the Facts tab renders five of them.
|
|
Internal second-model review (2 findings adjudicated, plus 2 I raised myself)Before opening this PR I ran an independent cold review of the resulting code, then an independent
1 - fixed. The handler can only ever render 2 and 3 - the mechanism is real and I reproduced it, but it is not this change's defect. For finding 3 the stated consequence is specifically falsified. Running the reviewer's own I checked this against the rule that pre-existing behaviour never excuses a PR's own contract. The I also measured the suggested remedy rather than assuming it, and it is not safe to apply here. 4 - noted. Harmless because every consumer is guard-indexed Independently re-derived every published figure rather than carrying them: 6455 dangling relations Gate spend: $8.30 over 2 gate runs (plan approach review + this code review). |
Pre-PR validation gate (a-i)
Cost measured, not assumed: both fixed queries are fully index-covered Full suite: 2935 passed. The 7 failures are pre-existing and timezone-related |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Symptom
memory_expand_categoryadvertises more items than it can return:Only 65655 of those are reachable, so paging past the reachable end returns
nothing. The web memory sidebar's per-category badge is inflated identically.
Measured on a live 146030-item store: 6455 of 175114 membership rows are
unreturnable, a 3.69% overstatement affecting all 8 categories (3.29% to
4.29%).
Root cause
memu_category_itemsis a link table with no foreign key tomemu_memory_items, so delete-side writers strand membership rows whose itemis gone. Two read paths counted those rows directly:
MemUBridge.expand_categorycomputedtotalwith an unjoinedcount(*),while the item listing three lines below already joined
memu_memory_items.So
totalanditemswere computed over two different row sets in the samefunction, connection and transaction.
_read_memu_snapshot_syncexported unjoined(category_id, item_id)pairsas
category_items, which the sidebar counts viaids.length.The stranded rows are permanent, not in-flight: they were created between
2026-06-25 and 2026-08-02, while the newest relation in the store is newer
still.
Fix
Add the listing's own join predicate at both sites, so the count and the
listing agree by construction rather than by coincidence. Chosen over an
equivalent
EXISTSbecause it is textually the same join the function alreadyperforms, leaving no room for the two spellings to drift.
Both queries stay fully index-covered (
SCAN ci USING COVERING INDEX idx_sqlite_category_items_unique+SEARCH i USING COVERING INDEX ...).Measured cost: 72.2 -> 102.8 ms for the once-per-page-load snapshot (already
run in a worker thread) and 9.4 -> 12.9 ms for a per-category total.
Server-side SQL only. No frontend change is needed: the sidebar badge is
repaired by the gateway fix, and the other five
categoryItemsconsumersalready drop unresolvable ids, so they are no-ops under it.
Reported counts decrease for existing users. That is the point - the new
numbers are what the store can actually return.
Two pre-existing, distinct mismatches are deliberately not addressed here:
totalignores thequeryfilter, and the sidebar badge counts all memorytypes while the Facts tab renders five of them.
Tests
Two tests in
tests/test_recall_breadcrumbs.py, one per site, both failing onmain(assert 5 == 3) and passing here. The bridge test pinstotal == len(items)when unpaged - the actual invariant - rather than a magicnumber, and the gateway test asserts cross-site agreement between the badge
count and the agent's
total.Target suite 16 passed (14 before). Full suite 2935 passed; the 7 failures are
pre-existing and timezone-related, with a byte-identical FAILED set before and
after this change.