Skip to content

memory: merge the content-hash reinforce's extra write into the live row - #255

Closed
oranjeai wants to merge 2 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/memu-hash-reinforce-atomic-extra
Closed

memory: merge the content-hash reinforce's extra write into the live row#255
oranjeai wants to merge 2 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/memu-hash-reinforce-atomic-extra

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Description

memu-py 1.4.0's SQLiteMemoryItemRepo.create_item_reinforce reinforces an item by reading
extra, mutating a dict copy and flushing the whole JSON column back
(memory_item_repo.py:320-333). The plain SELECT takes no write reservation, so any key another
connection commits in between is silently replaced -- such as the event-date sweep's
mentioned_at, on 97% of 152k live rows. Injecting a write there: unpatched loses it, patched
keeps it.

The fix merges server-side with json_set and reads back inside the same still-open write
transaction: json_extract on the same column increments without a snapshot, and the UPDATE
promotes the session to a write transaction. No RETURNING, avoiding an unchecked SQLite >= 3.35
requirement; rowcount separates hit from miss.

Three details are reachable, not defensive. content_hash has no unique index, so a bare
hash-filtered UPDATE would bump every duplicate where .first() reinforces one; the arm resolves
one id with limit(1). A writer can blank that target inside the window, where coalesce alone
feeds '' to json_set and raises malformed JSON, hence NULLIF. Blanking also drops
content_hash -- upstream rewrote it only as a side effect of the flush this arm removes -- so an
innermost json_set re-asserts it, else the row stops matching the arm's filter and the next
reinforce duplicates it.

The wrapper installs before the semantic-dedup patch binds the original, so the order stays
semantic -> this arm -> upstream create; hash-first would change which row a memorize reinforces,
and a test pins that.

update_item has that shape too and is untouched: its only extra-writing caller is gated behind
enable_item_references (default False, unreachable from nerve), and 0 live rows carry its keys.

Validation: 9 new tests; an 8-mutant matrix kills all 8, with a no-op control surviving at both
ends. The whole-suite FAILED-test-name set is byte-identical to clean main; passed 2933 -> 2942,
exactly the new tests.

memu-py 1.4.0's SQLiteMemoryItemRepo.create_item_reinforce reinforces an
existing item by reading `extra`, mutating a dict copy and flushing the whole
JSON column back. A plain SELECT takes no SQLite write reservation, so every
key another connection commits between the read and the flush is silently
replaced. The event-date sweep's `mentioned_at` is such a key, and 145,995 of
149,964 rows in a live store carry it while 17,935 have reinforcement_count > 1,
so the two writers genuinely co-occur.

Compute the merge server-side with json_set and read it back inside the same
still-open write transaction. json_extract on the same column in the same
statement expresses count = count + 1 without a snapshot, and the UPDATE
promotes the session to a write transaction so the read-back cannot observe an
interleaved write.

Two details are load-bearing rather than defensive:

- content_hash has no uniqueness index (the only indexes on the table are the
  id PK autoindex and ix_memu_memory_items_id), so a bare hash-filtered UPDATE
  would bump every duplicate where upstream's .first() reinforces exactly one.
  The arm resolves one target id with limit(1) and updates by id. Measured on a
  3-row duplicate set: 1 row bumped, and it is the row .first() picks.
- NULLIF is reachable, not paranoia: a writer can blank the target's `extra`
  between the id resolve and the UPDATE, and coalesce alone would then feed ''
  to json_set, which raises "malformed JSON".

The wrapper is installed before the existing semantic-dedup patch binds the
original, so the order stays semantic -> this arm -> upstream create. A
hash-first arm would change dedup precedence: with a fixture holding the query's
text under an orthogonal embedding and unrelated text under the query's
embedding, semantic-first reinforces the semantic match while hash-first
reinforces the other row.

`update_item` carries the same defective shape and is deliberately left alone:
its only extra-writing caller is gated behind enable_item_references, which
defaults False and has no environment or nerve config source, nerve's own
memory_update path passes extra=None so the column is never written at all, and
0 of 149,964 live rows carry any key that path would write.

Measured both directions with the concurrent write injected inside the window:
unpatched loses `mentioned_at`, patched keeps it, and both reach
reinforcement_count 2. Nine new tests in tests/test_memu_bridge.py cover the
window (row, returned item and cache), the NULLIF window, an increment from a
seeded count of 7, a row deleted inside the window, dedup precedence, the
duplicate-hash set, the genuine-miss create, the absence of double counting on a
semantic hit, and the patch order.
…the window

Review round 1 on the previous commit. Two defects, both in the same two places.

1. The json_set chain set only $.reinforcement_count and $.last_reinforced_at,
   so when a writer blanks the already-chosen target's `extra` between the
   limit(1) id resolve and the UPDATE, `live` degrades to '{}' and the row is
   rewritten WITHOUT content_hash. Measured consequence: the row permanently
   stops satisfying the arm's own hash filter, so the next reinforce of the same
   text creates a duplicate (rows 1 -> 2). This is introduced by the previous
   commit, not pre-existing: upstream rewrote content_hash as a side effect of
   the whole-column flush that commit removes, so the value has to be asserted
   explicitly. Fixed by one more innermost json_set. The increment still reads
   the ORIGINAL live column, so it stays live-valued rather than reading the
   rewritten expression.

2. test_preserves_a_concurrent_extra_write injected from a patched Repo._now().
   That is correct for upstream, which calls _now() after its entity SELECT, but
   this arm calls _now() before it opens its session and before the id resolve,
   so the injected write committed BEFORE the arm's first statement. The test
   therefore only proved the arm does not clobber a key written before it
   started, which a plain Python read-modify-write also satisfies: a mutant that
   keeps the id resolve, the rowcount fall-through and the read-back but merges
   in Python left the suite green. The fixed arm now injects immediately before
   its own UPDATE. The base arm keeps its _now() hook, because upstream flushes
   via session.add/commit and never issues an explicit session.execute("UPDATE"),
   so the pre-UPDATE hook cannot fire there. That asymmetry is the finding, so it
   is stated in the test rather than papered over by asserting one hook for both.
   The in-test comment that asserted the wrong arm's ordering is replaced with
   the measured one.

The load-bearing details are now three, not the two the previous commit's
message enumerates: the content_hash carry joins limit(1) and NULLIF.

Test count is unchanged at nine: the content_hash assertion and its
reachability check (a third cold reinforce must still dedup, rows == 1) extend
the existing NULLIF window test rather than adding a near-duplicate.

The mutation matrix grows from six mutants to eight. M7 removes only the inner
content_hash json_set; M8 is the Python read-modify-write above. Both are
killed, M1-M6 stay killed, and the no-op control still survives at both ends.
@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.

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (a-i)
# check answer
a Deterministic repro? Yes. .scratch/repro-c1loss-base.py exports a clean main and injects the concurrent write via _now(), which upstream calls after the row lookup (memory_item_repo.py:320) and before the commit (:333), so the window is opened by construction rather than raced. It asserts the injection fired AND that the verdict matches the arm the given tree installs, so it cannot silently drift into testing the patched arm; 100% reproducible, prints mentioned_at LOST on base and SURVIVED on this branch.
b Root cause explained? A plain SELECT takes no SQLite write reservation, and the ORM then flushes the WHOLE extra column from the value read before the window, so any key another connection commits in between is replaced. reinforcement_count is an increment-on-read, which makes the snapshot load-bearing.
c Fix matches root cause? The mutation is computed server-side by json_set and read back inside the same still-open write transaction. No widened bound, no retry loop, no defensive guard at a symptom site.
d Test intent preserved / tests added? 9 new tests. No existing test weakened or removed: the whole-suite FAILED-test-name set is byte-identical to clean main.
e Demonstrated in BOTH directions? Yes. test_preserves_a_concurrent_extra_write runs the unpatched arm (loaded from a pristine module copy) and the shipped arm on the same fixture, asserting BOTH reinforce to count 2 so the only measured difference is whether mentioned_at survived: unpatched LOST, patched SURVIVED. The two arms inject at DIFFERENT points on purpose, and the asymmetry is stated in the test: upstream calls _now() after its SELECT so a _now hook lands mid-window there, while this arm calls _now() at memu_bridge.py:1196, above its session open (:1228) and id resolve (:1233), so the fixed arm hooks its own first UPDATE instead. Measured interleaving: base [SELECT, INJECT, SELECT], fixed [SELECT, INJECT, UPDATE, SELECT].
f General across CODE paths? 8 carriers enumerated; 1 fixed, 7 with a stated reason. update_item has the same shape but is unreachable three independent ways (gate defaults False with no env or nerve config source; nerve's memory_update passes extra=None so the column is never written and an injected key SURVIVES; 0 live rows carry its keys) - disclosed in the body rather than wrapped, since a wrapper no test can observe is scaffolding. The delegate's CREATE arm reads no row; the Postgres twins are unreachable ("provider": "sqlite" hardcoded); the in-memory repo has no second connection.
g Generalizes across INPUTS? Absent extra key (coalesce(..., 1)), empty-string extra (NULLIF), an extra blanked mid-window so the merge input degrades to {} (the content_hash carry), count already > 1 (seeded 7 -> 8), 1 row, 3 hash-duplicate rows, row deleted mid-window, genuine miss. No type-wrapper matrix applies: one JSON column, no user-supplied types.
h Backward compatible? No schema, settings, default or serialization change. json_set has been in SQLite since 3.9 (2015) and main already uses this idiom family. Deliberately no RETURNING, which would impose an unchecked SQLite >= 3.35 requirement (install.sh never checks libsqlite3); rowcount already separates hit from miss.
i Invariants and contracts preserved? Dedup precedence unchanged (measured, plus a mutant that reorders the install is killed); the merged row still carries content_hash, so it keeps satisfying the arm's own hash filter and a later reinforce of the same text still dedups instead of creating a duplicate; exactly one row bumped and it is the row .first() picks; a genuine miss still creates; rowcount == 0 falls through instead of silently no-opping; the returned item AND the item cache carry the merged value, not a reconstructed dict; session.rollback() on both fall-through paths so no partial write is held.

An 8-mutant matrix backs rows (d)/(i): all 8 killed, with a no-op control surviving at both ends of the run and an exact tree restore asserted after every arm. Five mutants have survived a pass at some point on this branch; each was a real coverage gap and each is now closed by a named test. The two newest are attributable rather than assumed: M7 (removes only the inner content_hash json_set, leaving the hash FILTER intact) and M8 (keeps the id resolve, the rowcount fall-through and the read-back but merges in Python from a pre-read value) both SURVIVE the previous round's tests and are KILLED by this round's.

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review (2 rounds, 9 findings, $11.63)

An independent model reviewed this change cold against the frozen PR description, and I
adjudicated every finding with evidence. Both rounds are listed, including the two findings I
first got wrong.

# finding severity verdict
1 blank-drops-hash - a writer blanking the chosen target inside the window left the row rewritten WITHOUT content_hash blocker AGREE - fixed
2 window-test-injects-before-the-arms-own-read - the headline window test hooked _now(), which this arm calls above its own session open, so it only proved the arm does not clobber a key written before it started blocker AGREE - fixed
3 concurrency-hook-before-read - same defect seen from the gate's side major AGREE - fixed
4 test-docstring-window-claim-false - the in-test comment asserting that ordering was false, and is what hid finding 2 major AGREE - fixed
5 semantic-stale-extra - the semantic-dedup branch replaces live extra from a cached copy blocker AGREE - owned elsewhere, see below
6 sweep-reverse-loss - the event-date sweep replaces extra from a snapshot taken before an LLM call blocker AGREE - owned elsewhere, see below
7 pr-body-two-details-undercount - the description said "two details" after a third, more reachable one had been added major AGREE - fixed in the description
8 pr-body-mutant-count-stale - the description said 6 mutants; the shipped matrix is 8 major AGREE - fixed in the description
9 nonobject-extra-readback-raises - a valid but non-object extra written into the window would pass through json_set unchanged and then fail the read-back nit noted, not blocking

Findings 1-4 changed the code. Finding 1 was worse than reported: losing content_hash
makes the row permanently stop matching this arm's own hash filter, so the next reinforce
creates a duplicate (measured: subsequent reinforce -> same row: False | rows 1 -> 2). It is
introduced by this change, because upstream preserved the hash only as a side effect of the
whole-column flush being removed here. Finding 2 was the sharper one: mutant M8 keeps the id
resolve, the rowcount fall-through and the read-back but merges in Python from a pre-read
value, reinstating the exact defect this change exists to remove, and it left the earlier test
suite fully green. Both are now pinned, and both mutants survive the previous round's tests
and are killed by this round's, so the coverage gain is attributed rather than assumed.

Findings 5 and 6 I first disagreed with, and that was the wrong call to leave standing.
The gate raised them in both rounds. The mechanisms are real and I did not try to refute them
again. They are outside this change: its entire source diff is one hunk spanning lines
1177-1288, and both cited regions are byte-identical to main (verified with diff at the
hunk's exact +106 line offset, after my first offset arithmetic was wrong). Each is being
fixed at its own site right now - #247 replaces the sweep's SET extra = ? with a json_set
against the live column, and #254 removes the cached-copy line the gate cites and merges by
match_id server-side. Editing those same lines here would turn two currently clean merges
into conflicts between three open PRs of mine, so the fix belongs there and not in
this diff. Recorded as agreed so neither is written off.

Finding 9 stays open as a note. json_set leaves a valid non-object JSON document
unchanged ([] -> [], 5 -> 5, null -> null), so such a value written into the window
would be neither merged nor rejected, and the read-back would raise. Reachability is nil: no
writer in memU or here emits a non-object, json_extract returns NULL for one so it cannot
satisfy the hash filter in the first place, and all 152,655 live rows hold objects. The one
reachable degenerate shape, an empty string, is what NULLIF handles and what a test pins.
It is recorded so a future third-party writer has a trail.

Gate spend: $11.63 over 2 rounds ($8.16 + $3.47).

@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.

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