Skip to content

memory: seed the categories memU actually advertises - #259

Closed
oranjeai wants to merge 5 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/seed-effective-memu-categories
Closed

memory: seed the categories memU actually advertises#259
oranjeai wants to merge 5 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/seed-effective-memu-categories

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Symptom

With no memory.categories in config, the memorize LLM is told 10 categories exist while the
store holds none, so every name it emits is silently dropped and category recall stays
permanently empty. Measured on an empty DB and config: advertised 10, rows 0, map 0, resolvable
0 of 10. That is the default: config.example.yaml ships no categories key.

Root cause

nerve seeded from config.memory.categories, memU advertised from service.category_configs;
they coincide only when nerve configures its own. Otherwise memU's 10 defaults reach the prompt,
_ensure_categories early-returned, and categories_ready=True suppressed memU's initializer.
The list_categories() warming the rebuild's cache sat behind that return too, so a
runtime-created category was unresolvable after restart.

The fix

Seed from service.category_configs and load persisted rows unconditionally. Seeding writes
through the category repository, not _create_category_impl: that also appends a
CategoryConfig for an already-advertised name, doubling the advertised set -- live today on
the configured path too
.

Embeddings are computed in one batched call before any write, and a failure propagates: a
null-embedding row is never repaired, so a None fallback would trade one silent failure for a
permanent one.

_available moves to the end of _initialize_impl from ~290 lines early, before seeding:
engine.py discards initialize()'s return, so it is the agent's only failure signal.

Behaviour changes: a default install creates 10 rows on first boot (idempotent by name); a row
whose stored name is not memU's form is renamed in place, keeping id and items, unless another
row already owns its lookup key; and an embedding outage or a category read error now fails
memory init loudly. Out of scope: null-embedding rows (#70); runtime-created categories stay
unadvertised.

Validation

44 new tests, 35 failing against unmodified source (41 at file scope, six pre-existing).
Suite 2933 -> 2977 passed, identical 7-name pre-existing failure set.

nerve forwards its own memory categories to memU only when config.memory.categories
is non-empty; otherwise memU falls back to its 10 built-in defaults. Those defaults
are formatted into the memorize prompt, so the LLM is told they exist. But
_ensure_categories keyed its work on nerve's config, early-returning when it was
empty, and nerve also sets categories_ready=True, which suppresses memU's own
category initializer. On a default-config install nothing was ever created: 10
categories advertised, 0 rows, an empty name-to-id map, and every category name the
LLM emits silently dropped by _map_category_names_to_ids. Category-scoped recall and
every category summary stay permanently empty with no error anywhere.

config.example.yaml ships a memory: block with no categories key and
MemoryConfig.categories defaults to an empty list, so only wizard-created installs
avoid this.

Seed from self._service.category_configs, the effective set memU advertises, and
load the persisted rows unconditionally so the name-to-id rebuild sees a warm cache
even when nothing needs creating.

Seeding writes through the category repository rather than _create_category_impl.
That method appends a CategoryConfig for a name memU already advertises, so using it
here doubles the advertised set. This is also live today on the configured path: a
cold start with 3 configured categories left 6 advertised entries and listed each
one twice in the memorize prompt. Runtime creation keeps using
_create_category_impl, which is correct for a category memU does not yet advertise.

Embeddings are computed in one batched call before any write, and a failure
propagates. A row persisted with a null embedding is never repaired, because
get_or_create_category returns an existing row untouched and a later boot skips the
name, while both category rankers drop null vectors. Falling back to None would
trade one silent failure for a permanent one. embedding=None remains correct when no
provider is configured, where retrieval does not rank by vector.

Availability moves to the end of _initialize_impl. It was published ~290 lines
early, before category seeding, and engine.py discards initialize()'s return value,
so _available is the only failure signal the running agent sees.

A category created at runtime is resolvable again after a restart, but memU rebuilds
the advertised set from config at construction, so the LLM is still not told it
exists. That half needs the category_configs lifecycle and is not addressed here.
zip(..., strict=True) raises as the loop advances, so a provider returning fewer
or more vectors than categories left rows already written before the error. Pair
up front instead: nothing is written unless every category has a vector.
The three end-to-end arms in TestInitializeCategoryInvariant run a real
MemUBridge.initialize(), whose warmup loop opens three LLM clients.  Nothing
pointed those at a stub, so each arm dialled api.anthropic.com: measured with
strace -f -e trace=connect, tests/test_memu_bridge.py made 12 connections to
port 443 and took 20.4s, against 0 and 3.7s at the base commit.  A unit suite
must not depend on a third-party endpoint, and this one passed quickly only
because that endpoint rejects a fake key promptly; behind a blackholing proxy
each arm would instead burn the warmup's 15s-per-profile timeout.

Neutralize MemoryService._get_llm_base_client for the duration of
_initialize_impl.  It is the only client factory the warmup calls and the loop
already swallows its exceptions, so no observable behaviour changes: every
existing assertion in all three arms is unchanged and still passes.

Make the offline property an assertion rather than a comment.  The probe
installs a socket.socket.connect that records and rejects, and reports
offline = (nothing dialled) and (the blocker is installed).  Both conjuncts are
load-bearing: httpx reaches the network through socket.socket.connect and never
through socket.create_connection (measured), the warmup's except Exception
swallows a raise so only the record can fail a test, and without the second
conjunct an empty record would also be satisfied by a blocker that was never
armed.  Mutation-checked in both directions: removing the warmup stub or the
blocker fails the arms, a semantically identical no-op edit does not.

tests/test_memu_bridge.py: 6 failed / 87 passed, 0 connections to :443, 5.6s.
Whole suite: 2957 collected, 7 failed / 2950 passed, with the failing-name set
unchanged (all pre-existing timezone artifacts).

## Coordination

The sibling branch oranjeai/memu-category-name-normalization rewrites the same
function and conflicts with this one: git merge-tree --write-tree reports
CONFLICT on both nerve/memory/memu_bridge.py and tests/test_memu_bridge.py.
The two changes are complementary in intent but incompatible in code -- that
branch keeps the per-category create loop and normalizes names, while this one
replaces the loop with a repo-level seed, and it still iterates the configured
categories so it does not fix the empty-config case.  Whichever lands second
must rebase rather than auto-resolve, since a careless resolution can reinstate
the double-advertising or drop one of the two fixes.
_seed_categories persisted, embedded and mapped the configured name verbatim,
and _ensure_categories compared raw names for its already-exists skip.  memU
does not: _format_categories_for_prompt advertises name.strip() or "Untitled"
(memu/app/memorize.py:930-938), its own initializer persists that same form
(:663-665), and all three copies of the reverse lookup key on
name.strip().lower() (crud.py, patch.py, memorize.py).  So a configured name
that is not already normalized produced a row under a key no consumer computes.

Measured with a full initialize() against an empty store and
categories = [{name: "  alpha  "}, {name: "   "}]: the prompt advertises alpha
and Untitled, the rows and the name-to-id map hold '  alpha  ' and '   ', and
mapping the advertised names returns [] while initialize() reports success.
That is the failure mode this branch exists to remove -- 0 of the advertised
categories can receive anything -- reached by a padded name instead of an empty
config, so the invariant the branch states does not hold on its own tree.

Route the four sites through one _memu_cat_name helper matching memU exactly:
the existing-row comparison, the missing-config test, the embedding text, and
the persisted name and description.  The embedding text now matches
_category_embedding_text byte for byte, including choosing the desc-less form on
the stripped description, so seeded vectors stay in the space cosine_topk ranks
in.  The log line and the audit record name the stored row rather than the raw
config text, so the audit target_id identifies what was written.

The name-to-id rebuild in _initialize_impl and _create_category_impl are
deliberately untouched: once the row is stored normalized, the rebuild's
cat.name.lower() already yields memU's lookup key, and both sites are contested
with the sibling branch below.

A row already stored under a raw name is RENAMED to memU's form, unless another
row already owns the name memU would look it up by, so one row ends up carrying
the key every consumer computes.  Merely recognising such a row is not enough:
normalizing only the comparison suppresses the seed AND leaves the raw row
unresolvable, which is worse than not normalizing at all.  nerve's own
update_category wrapper forwards summary and description only, but the repo
layer does rename in all three backends, and the row id survives it, so existing
category_items stay linked.

Two key domains are in play, and they are not the same.  memU's own
resolver computes name.strip().lower() (memu/app/memorize.py:682), while the
name-to-id rebuild in _initialize_impl keys on raw cat.name.lower(), without
stripping.  The repair guard compares in the resolver's domain, because that is
what decides whether two rows are one category: a padded '  Alpha  ' normalizes
to a display name no row holds while sharing the resolver key of a stored
'alpha', so renaming it would leave two live rows behind ONE
category_name_to_id entry, with the winner decided by repo.categories order
(list_categories applies no ORDER BY).  A key owned by more than one row is
therefore left entirely alone: base's rows survive, and no items are discarded.

Occupancy, though, is judged in the REBUILD's domain, and over the names the
rows carry AFTER those repairs.  A key that exists only as some padded row's
stripped form is not one any consumer computes, so treating it as taken
suppresses the seed that would supply it and leaves the advertised category
unresolvable.  That is reachable whenever two or more stored rows share one
resolver key and none of them is already normalized, for instance an upgrade
from a config that once carried a trailing-space and a leading-space spelling
of the same name.  base seeds a third row in that shape and does resolve, so
judging occupancy in the wrong domain is a regression against base rather than
an unfixed gap.  The same post-repair comparison drives the already-exists
test, so a padded configured name still does not seed a second row beside a
case-differing one.

Repairs and seeds are planned first and written together, after ONE batched
embedding call.  A renamed row is re-embedded from its normalized text for the
same reason its name is normalized: both rankers read the stored vector, so a
name-only rename would leave that row ranked in the space its raw text occupied.
Writing the rename before the batch also made an embedding outage leave a
half-migrated store -- renamed row, unseeded category -- so the write now
happens only once every vector is in hand.  The rename is audited as
category_updated against the row id, matching _update_category_impl.  A
repaired row keeps its own description: it may have been edited through the
API, and resolution does not depend on it.

tests/test_memu_bridge.py: 118 collected, 6 failed / 112 passed; the same 6
fail on unmodified origin/main.  Against origin/main source with these tests, 39
fail at file scope -- 33 of the 42 tests this branch adds, plus those 6.
Against the previous commit's source, 11 fail: the same 6, plus the 5 arms this
round adds for the multi-owner unaddressable-key defect.  Those 5 also fail at
origin/main, but for a different reason: base seeds the third row and does
resolve, so there they fail on the row set rather than on resolution.  Whole
suite: 7 failed / 2975 passed, against 7 failed / 2933 passed on origin/main --
an identical 7-name failure set (pre-existing timezone artifacts), and +42
passing, matching the 42 collected names added exactly (0 removed).  17 of 18
mutants killed, the eighteenth a no-op control that passes; the mutant
restoring the previous commit's occupancy set is killed by the new arms in both
insertion orders.

## Coordination

The sibling branch oranjeai/memu-category-name-normalization (PR ClickHouse#251, now
de6932f) rewrites the same function and still conflicts with this one:
git merge-tree --write-tree reports CONFLICT (content) on both
nerve/memory/memu_bridge.py and tests/test_memu_bridge.py.  That branch fixes
normalization too, by a different and incompatible mechanism: a
_norm_category_name that strips and lowercases, applied to the rebuild, to
_create_category_impl and to the runtime lookup, while keeping the per-category
create loop this branch replaces -- at de6932f it drops the pre-filter this
branch normalizes.  Whoever lands second must keep exactly one of the two rather
than auto-resolve, because a careless resolution can reinstate the
double-advertising or drop one of the fixes.

Re-derived at handoff time against origin/main 94406ea: nine open PRs touch
these two files, and six of them conflict.  ClickHouse#251 conflicts semantically -- it
rewrites the same function.  ClickHouse#249 edits _ensure_categories and the runtime map
write, so read its resolution rather than auto-merging.  ClickHouse#254, ClickHouse#255, ClickHouse#248 and
ClickHouse#70 conflict mechanically only, on test-file layout or nerve/bootstrap.py,
without touching the seeding path; ClickHouse#254 and ClickHouse#255 conflict in
tests/test_memu_bridge.py alone.  ClickHouse#256, ClickHouse#252 and ClickHouse#247 merge cleanly.  This
table moves hourly; re-derive it before resolving anything.
…lize()

Two properties the branch publishes had no arm that could observe them.

_available moves to the end of _initialize_impl, 273 lines after seeding,
because engine.py discards initialize()'s return value so _available is the
only failure signal the running agent sees.  The one failure arm injected at
_ensure_categories, which raises before either the old or the new publication
point, so it passed identically at both positions.  A new "fail-late" probe mode
raises at the interceptor registration instead: that site runs after seeding and
after _instrument_llm_timeouts, and is not inside the swallowing try that opens
below it, so the raise reaches the outer except and initialize() returns False.
The new arm asserts initialize/available/service_available are all False AND
that the 10 advertised defaults were already seeded, which is what distinguishes
it from the pre-existing arm rather than duplicating it.  Verified by moving
_available/service_available/initialized_at back to immediately after the
_ensure_categories call: the new arm fails while the pre-existing one still
passes, and that asymmetry is the proof.

The restart property -- a row created at runtime is resolvable again after a
restart -- was covered only through a test-local helper that re-implements the
name-to-ID rebuild instead of executing it, so no arm observed the production
rebuild that actually makes the row addressable.  Every full-initialize arm that
pre-stored a row also configured the same name, so none exercised a persisted
row the config does not advertise.  A new full-initialize arm pre-stores "work"
with no configured categories and asserts through the real
ctx.category_name_to_id: "work" is mapped, keeps its pre-store id so its
category_items stay linked, is still absent from the advertised set and the
prompt, and does not disturb the seeding of the 10 defaults.  On a clean
git archive export of origin/main it fails on exactly that map assertion
(assert 'work' in {}), because the list_categories() warming the rebuild's cache
sits behind the empty-config early return.  Two further mutants pin it: deleting
the rebuild loop body, and restoring the base shape (the early return plus the
preload list_categories the branch removed), which is what shows the arm pins
the unconditional load and not merely the rebuild.

Test-only.  nerve/memory/memu_bridge.py is byte-identical to the previous
commit.  The inherited 18-mutant matrix was re-run against the amended tree
alongside the three new mutants; controls green at both ends.  The file-scope
FAILED-name set is unchanged (the same 6 pre-existing timezone artifacts), and
ruff reports the same two pre-existing findings in the test file.
@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
Internal second-model review -- 7 rounds, 37 findings (27 agreed and fixed, 6 disagreed with evidence, 4 dropped)

Before opening this PR I ran it through an independent review harness: each round a second
model reviewed the diff cold against the PR body, and a reviewer that never edits source
adjudicated every finding. Six rounds bounced back for fixes; this is the round that passed.
The point of publishing the ledger is that a maintainer can see what was challenged, what I
accepted, and -- more usefully -- where I was wrong and had to reverse myself.

Six rounds each found a real defect in the previous round's fix. That is the honest
summary: the original filed defect was round 1, and every round after it was a defect in my
own remedy. Worth knowing before reading the diff, because it explains why the change is
larger than "seed from the other list".

# Finding Verdict
1 init-probe-network -- the new tests made 12 real outbound calls; artifacts claimed offline ❌ agreed, fixed
2 category-name-normalization -- nerve stored a name memU's resolver could never look up ❌ agreed, fixed (reversed, see below)
3 category-key-invariant -- normalizing only the comparison made two cells worse than base ❌ agreed, fixed
4 rename-claim-false -- my claim "update_category cannot rename" was false in all three backends ❌ agreed, fixed
5 lookup-key-collision -- the repair guard compared in the wrong key domain; two cells worse than base ❌ agreed, fixed
6 repair-embedding-order -- a repaired row kept a vector embedded from its raw text, contradicting this PR's own seeding rule ❌ agreed, fixed
7 multi-owner-key-unaddressable -- the key-domain move itself regressed against base when two rows share a key ❌ agreed, fixed
8 availability-late-failure-test -- the _available move was pinned only by a failure raised before both publication points ⚠️ agreed, fixed
9 runtime-category-restart-test -- the restart arm re-implemented the rebuild instead of executing it ⚠️ agreed, fixed
10 repair-audit-gap, seed-comment-inverted, rebuild-citations-stale, category-comment-overload, inert-regression-arms, conflict-census-incomplete, validation-count-stale (+ 11 more) 💡 agreed, fixed

Where I was wrong, in public:

  • category-name-normalization was raised as a major in round 1 and I disagreed. It came
    back as a blocker in round 2 with a citation I had not had -- memU shows the LLM
    name.strip() or "Untitled" -- and I reversed: the name the model is told to use was not
    the name nerve persisted or mapped. One of my three original grounds was measurably wrong
    (I claimed a strip() here would collide with a sibling branch; the function it lives in is
    introduced by this PR, so its interior is uncontested).
  • Round 3's evidence argued a legacy un-resolvable row was "not a regression". It compared the
    wrong cell pair -- with the config padded, base is equally broken; with the config
    normalized (the real post-upgrade shape) base seeds a resolvable row and my code did not.
    That refutation is what surfaced the strictly better remedy.
  • Rounds 5 and 6 each found the round before had introduced a fresh regression against base.
    Both were caught by measuring a store/config grid on all three trees rather than by reading.

Disagreed, with evidence (recorded rather than silently dropped):

  • ⚠️ unavailable-recall-empty (blocker, this round). The mechanism is real and I confirmed
    it
    : memory_recall_handler is the only one of nine memory handlers that does not gate on
    memory_bridge.available, so an unavailable bridge is rendered to the agent as "No relevant
    memories found." rather than as an outage. It is not this PR's, on three counts:
    nerve/agent/tools/handlers/memory.py is byte-identical to the merge-base and the
    missing gate is present there at the same line; the muted state was already reachable at base
    from eight pre-publication failure sites, and this PR's _available move makes matters
    better (base published availability early, so a late failure advertised memory as usable on
    a half-initialized service -- this PR routes those failures into the state where eight of nine
    handlers say so explicitly). Decisively, the remedy the reviewer asked for is already
    implemented on a sibling branch
    , both halves: recall()'s guard raises
    MemoryBackendUnavailable instead of returning [], and
    test_t13_uninitialized_bridge_renders_unavailable asserts through the real handler that the
    response says "MEMORY RECALL UNAVAILABLE" and does not say "No relevant memories found."
  • ⚠️ embed-init-timeout (raised three times). The unbounded startup embed is real and
    unbounded, but this branch does not create or worsen it: on the configured path base ran one
    unbounded embed per category inside _create_category_impl, so one batched call is
    strictly fewer, and the only alternative producer (memU's own initializer) embeds unbounded
    too. It is owned by a separate PR that bounds all three call sites, which is the right place
    -- bounding is a property of the call sites, not of who calls them.
  • ⚠️ runtime-normalization-gap (blocker). Real, and byte-identical to base:
    _create_category_impl is unchanged here and nerve/gateway/ is untouched. Owned by open
    PR memory: key nerve's category name lookups the way memU reads them #251, which normalizes exactly those sites.
  • 💡 base-passing-tests, startup-embed-timeout -- refuted by mutation rather than argument:
    each named arm is killed by at least one per-site mutant, and the three that pass on both
    trees are deliberate no-regression guards, labelled as such.

Coordination. This branch conflicts in both files with open PRs #251 and #249, and #251 is
the semantic collision: it fixes the same double-advertising problem by keeping the seed loop
and avoiding the re-append, where this PR replaces the loop. Whoever lands second should keep
exactly one of the two mechanisms, not merge both. #248 and #70 overlap mechanically only.
Nothing was rebased, resolved or stacked.

Review cost for this PR: $90.28 across 13 gated runs (6 approach rounds, 7 diff rounds).
Two of the seven diff reviews completed but lost their ledger row to a harness reap; their
findings were recovered from the surviving raw response and used only to drive fix rounds,
never to authorize publication -- this round's pass row is a real one.

@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. Empty memory.categories + empty DB, one process: advertised_to_LLM = 10, _ensure_categories body ran = False, map_size = 0, db_rows = 0, resolvable = 0 of 10. Second, independent defect on the configured path (cold store): 3 configured categories give advertised 3 -> 6, duplicated = 3, prompt_lines = 6.
b Root cause explained? memU's MemorizeConfig default factory supplies 10 CategoryConfigs and MemoryService.__init__ formats category_configs into the memorize prompt, so those 10 are what the LLM is told. nerve forwards memory_categories only when its own config is non-empty, sets categories_ready = True (suppressing memU's initializer), and _ensure_categories early-returned on an empty config. The set that was advertised and the set that was seeded were different objects. The name-to-id rebuild could not compensate: it reads the in-memory repo cache, and the only list_categories() warming it sat behind that early return. Unmapped names are dropped with no error.
c Fix matches root cause? Yes: seed from the effective advertised set through a non-advertising repository write, load persisted rows unconditionally, embed before any write, publish availability last. No widened bound, no defensive guard, no suppression.
d Test intent preserved / new tests added? 44 new cases. Count agrees two independent ways: --collect-only on the file 76 -> 120 (sorted NAME-set delta +44 added, 0 removed), and suite passed 2933 -> 2977, both +44. One existing arm was replaced rather than removed: test_a_legacy_raw_row_is_left_alone_not_duplicated became test_a_legacy_raw_row_is_repaired_not_duplicated, because the fix changed that behaviour from recognise-and-leave to repair-by-rename. No existing test removed or weakened.
e Both directions demonstrated? Yes. Base source 94406ea with the final tests kept: 41 failed / 79 passed at file scope; with the fix all 118 collect and only the 6 pre-existing timezone artifacts fail, the same 6 that fail on unmodified origin/main. Against the tree before the normalization round (5efb96d): 5 failed / 18 passed, i.e. exactly the 5 arms that round adds, the end-to-end one failing on the resolution mismatch (assert 0 == 2) rather than any incidental error. Every base-arm failure was read and lands on the intended assertion (assert [] == ['cat_0'...], assert 'work' in {}, DID NOT RAISE, 0 == 1, True is False). The 3 base-passing cases are pinned non-vacuous by mutation. 12 mutants, all killed, each asserting its anchor applied exactly once, with an unmodified control at both ends of the matrix. A later fix round added 3 more mutants for the probes' offline guard (warmup stub removed, socket blocker removed, and a semantically identical no-op), all resolved as expected. The normalization round adds 6 more (one per normalization site, one identity-helper, one no-op): all resolved as expected, control green at both ends, TREE_RESTORED_OK. The per-site mutant for the already-exists comparison first survived, which was not a harness artifact: it exposed an uncovered reachable state (a store already holding a raw-named row). Review then measured that normalizing only the comparison made that state worse than base -- the seed base performed is suppressed while the row stays unresolvable -- so the fix now renames the legacy row instead. The r3 matrix is 9 mutants, all resolved as expected (control 27 passed at both ends, TREE_RESTORED_OK), including three for the repair itself: the repo.update_category(...) call deleted, a no-op rename to the raw name, and the repair guard keyed on the rows walked so far instead of the whole set -- that last one killed by exactly one arm, the test written for a real order-dependence defect found while implementing (list_categories() applies no ORDER BY, so the prescribed guard produced two identically-named rows when the raw row came first). The r4 matrix is 15 mutants, all resolved as expected (control 37 passed at both ends, TREE_RESTORED_OK): 6 new -- occupancy keyed on the exact name, the single-owner test dropped, the lookup-key helper not lowercasing, the repair written before the embed batch, repairs embedded from their raw name, and the repair audit removed -- plus the whole r3 set re-expressed against the amended source, each preserving the defect it modelled rather than its literal text, because a surviving inherited mutant would be a coverage regression this round caused. None survived. Because this round fixes defects of two generations, both directions are shown per generation on clean git archive exports: against origin/main 41 failed / 79 passed, against the previous commit b475ffd 19 failed / 99 passed, and on the amended tree 6 failed / 112 passed -- figures re-measured with the final test set, in which that round's 8 discriminating arms account for 8 of the b475ffd failures alongside the 6 artifacts. 3 of the 10 new arms fail only at b475ffd -- the key-collision arms, whose defect this branch itself introduced -- so a single base arm could not have discriminated them. Two more pass in both arms by construction and are labelled preservation guards / behaviour pins rather than reported as coverage. A fifth round then found that the previous round's key-domain move had itself introduced a regression against base: with two or more stored rows sharing one lookup key and none of them already normalized, occupancy was judged in a domain the name-to-id rebuild does not use, so the advertised name was marked present, no row was seeded, and nothing resolved -- while base seeds a third row and does resolve. Occupancy now uses the rebuild's own key over post-repair names. Its 5 new arms (two parametrized pairs over both insertion orders, plus an end-to-end upgrade) fail at BOTH earlier generations and, deliberately measured, for different reasons: at the previous commit resolved_prompt is empty (0 == 1), while at origin/main resolution succeeds and the arm fails on the row set instead -- which is the measurement proving the defect was a regression rather than an unfixed gap. Whole-file per generation: origin/main 41 failed / 79 passed, previous commit 11 failed / 107 passed, amended 6 failed / 114 passed. The r5 matrix is 18 mutants, all resolved as expected (control 42 passed at both ends, TREE_RESTORED_OK): 3 new -- occupancy reverted to the pre-repair key set, occupancy over post-repair names but stripped, and occupancy in the rebuild's domain but over pre-repair names -- plus the whole inherited set. The first is killed by the new arms in both insertion orders; the third is killed only by pre-existing repair arms, reported rather than smoothed over, since it shows the two halves of the fix are pinned by different arm sets. The matrix also asserts all three r5 arms are present in the tests it runs, so a survival cannot be a vacuous-export artifact. The 18-cell store/advertised census was re-run against the REAL amended source rather than the prototype model: the 14 pre-existing cells are byte-identical in rows, row count, key count and unresolved set, the 4 regressing cells all go to 0 unresolved, and no cell is left unresolvable. A sixth round adds the two arms that pin properties the PR body publishes but no arm could observe. _available's move to the end of _initialize_impl was pinned only by a failure injected INSIDE _ensure_categories, which raises before both the old and the new publication point, so that arm passed at either position; a new fail-late probe mode raises at the interceptor registration instead -- after seeding, before publication, and outside the swallowing try -- and asserts seeding had already succeeded, which is what distinguishes it from the pre-existing arm. Its mutant moves the three publication statements back to immediately after the seeding call and kills exactly one arm, the new one, while the pre-existing arm still passes: that asymmetry is the proof, and the first version of the mutant (which DELETED rather than moved) was rejected because it killed three arms for the wrong reason. The restart property was covered only through a test-local re-implementation of the name-to-ID rebuild, so no arm observed the production rebuild; every full-initialize arm that pre-stored a row also configured the same name. A new full-initialize arm pre-stores an unadvertised row and asserts through the real ctx.category_name_to_id, failing at base on exactly assert 'work' in {}. The r6 matrix is 21 mutants, all resolved as expected (control 44 passed at both ends, TREE_RESTORED_OK): the inherited 18 carried in byte-verbatim, every anchor still matching once, plus the availability move, the rebuild loop body deleted, and the base shape restored (early return plus the removed preload list_categories()). That last kills 29 arms -- reported rather than normalised, because restoring base's shape restores the whole defect, which also shows the arm pins the unconditional load and not merely the rebuild.
f Fix is general across code paths? Two category-creation paths exist in nerve/. Startup seeding is fixed; runtime create (_create_category_impl, reached from /api/memory) is byte-identical to base -- whole-method diff vs origin/main is empty -- because it must advertise, which is exactly why seeding must not use it. All 14 references to the advertised set and the map live in one module; xmemory_bridge.py has 0 category references. The normalization round is confined to _ensure_categories / _seed_categories. Occupancy is now judged in ONE domain everywhere it is judged -- the lookup key name.strip().lower() that the rebuild and memu/app/memorize.py:682 both compute -- via a single _memu_cat_key helper, and the embedding text via a single _memu_cat_embed_text that is memU's _category_embedding_text in one place instead of inline, so seeds and repairs provably share one text function. The name-to-id rebuild and _create_category_impl are deliberately left raw and stay byte-identical to base: once the row is stored normalized the rebuild's cat.name.lower() already yields memU's lookup key, and both sites are contested with the sibling branch (see Coordination). Runtime path re-verified alongside the fix: after init (10/10/10), creating a category gives advertised 11, 0 duplicates, rows 11, map 11, invariant 11 of 11.
g Fix generalizes across inputs (params/datatypes/wrappers)? Probed through the real store: 0 categories, 1 category, empty description, case-differing names, duplicate advertised name, unicode/CJK, 300-char name + 900-char description, //:/space in names, and embed returning too few / too many vectors. This found a real gap: the original lazy zip(strict=True) wrote 1 row before raising on a length mismatch. Fixed by materializing the pairs before the write loop -- now 0 rows -- pinned by two parametrized cases and mutant M12. The normalization round re-ran the same shape over the name domain -- padded, whitespace-only, empty, already-normalized, and a stored row that is itself padded -- and every differing case was enumerated before a fixture was written rather than sampled. The r4 round adds the class those fixtures structurally could not reach: padding COMBINED with a case difference. Measured on all three trees over a 4-cell table and a 36-cell grid (6 stored x 3 configured x 2 config modes), reporting rows - keys per cell because a bare key count is not comparable across trees that legitimately end with different row counts.
h Backward compatible? No setting default, serialization or format change. get_or_create_category is idempotent by name, so existing stores are untouched (same row ids across two runs). Two deliberate behaviour changes, both disclosed in the PR body: a default-config install now creates 10 rows on first boot, and an embedding outage on a provider-configured install now fails init loudly instead of persisting permanently-unrankable rows.
i Invariants and contracts preserved? Advertised set left exactly as memU built it (10 before / 10 after, 0 duplicates, one prompt line per category, both paths). Nothing half-written: embed precedes every write and pairing precedes the loop, so both failure modes leave the store empty -- asserted as the absence of rows, not merely a raised error. Availability now means fully initialized: implemented as a move, not a rollback, because a rollback in the generic handler would miss the dedicated except ImportError reachable via two later imports; verified no statement in the moved range reads _available and none of the five methods called there gates on it. Audit contract kept (category_created / source="bridge" per seed on both paths, zero on an idempotent re-init, no new audit source); the audit target_id and the log line now name the stored row rather than the raw config text, so they identify what was written. A row persisted earlier under a raw name is now repaired: it is renamed to memU's form so its rebuild key becomes the one every consumer computes. nerve's own update_category wrapper cannot rename (it forwards summary/description only, and _update_category_impl pins new_name = cat.name), but the repo layer does, in all three backends, and the row id survives, so linked category_items are not orphaned -- asserted in three arms and end to end. Measured over a 24-cell grid (4 stored x 3 configured x 2 config modes): cells resolving every advertised name go base 12/24 -> 24/24, with no cell worse than base. A raw row is repaired only when the rename can neither duplicate a name NOR land on a lookup key another row already owns: ' Alpha ' normalizes to a display name no row holds while sharing the key of a stored 'alpha', so renaming it would leave two live rows behind ONE category_name_to_id entry with the winner decided by cache order. Such a key is left entirely alone, so base's rows survive and no items are discarded. Measured: rows - keys per cell is at most 1 at base and at the previous commit, and 0 on this tree; over the 36-cell grid, 0 cells collapse more than base and 0 rows are unaddressable, while cells resolving every advertised name are 36/36 against base's 20/36. Case-only pairs (Alpha / alpha) stay out of scope -- both are already normalized, base produces the same two rows, and merging them would have to discard one row's items -- pinned by its own arm. Repairs and seeds are planned first, embedded in ONE batched call, and only then written, so an embedding outage can no longer leave a renamed row beside an unseeded category; a repaired row is re-embedded from its normalized text, because both rankers read the stored vector and a name-only rename would leave that one row ranked in the space its raw text occupied. The rename is audited as category_updated against the row id, matching _update_category_impl, so this file has no unaudited category mutation. A repaired row keeps its own description: it may have been edited through the API, and the rename is not a reconciliation point -- pinned by an arm so a later widening has to change it deliberately.

Session id: cron:clickhouse-impl-slot-4:20260803-221500

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@pufit could you take a look? You own most of nerve/memory/memu_bridge.py (10 of the last 20
commits there, including cfc236f), and this touches _ensure_categories plus the availability
publication point in _initialize_impl.

The two things worth your judgment rather than my measurement:

  1. Repairing persisted rows. A row stored under a non-normalized name (" work ") is renamed
    in place so its lookup key becomes the one memU computes. It keeps its id, so linked
    category_items survive, and it is skipped entirely when another row already owns the target
    lookup key. That is a write to existing user data on upgrade, which felt like the right call over
    leaving permanently unresolvable rows behind, but it is a call.
  2. Failing init loudly. An embedding outage or a category-table read error now leaves memory
    unavailable instead of proceeding with a half-built map. Base swallowed both. Since
    engine.py:459 discards initialize()'s return, _available is the only signal the agent gets.

Note it conflicts with #251 in both files, and that conflict is semantic, not textual: #251 keeps the
_create_category_impl seed loop and avoids the re-append, this replaces the loop. Whichever lands
second should keep exactly one of the two mechanisms. #249 also conflicts (it adds a second consumer
of the rows seeded here); #248 and #70 overlap mechanically only.

@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