Skip to content

Rebuild the advertised category set from config and persisted rows - #249

Closed
oranjeai wants to merge 1 commit into
ClickHouse:mainfrom
oranjeai:oranjeai/rebuild-category-view
Closed

Rebuild the advertised category set from config and persisted rows#249
oranjeai wants to merge 1 commit into
ClickHouse:mainfrom
oranjeai:oranjeai/rebuild-category-view

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Symptom

Two opposite ways the memorize prompt disagrees with the category table.

A category made from the web UI works until the next restart. After it the row is
still in the DB and still shows in the UI, but the memorize LLM is never told it
exists, so it can never assign anything to it: the category silently stops
receiving memories. Recall is unaffected.

In the other direction, a clean first boot advertises every configured category
twice, and nothing rebuilds the list afterwards, so each repeated web-UI create
on an existing name appends again -- three creates give four identical prompt lines
against one DB row.

Root cause

memU shows the LLM exactly one list: the one derived from
MemoryService.category_configs. nerve fills it from config.memory.categories at
construction (memu_bridge.py:1538-1543) and afterwards only ever appends
(:3034), so it is at once missing every category nerve did not configure and
duplicating every one it did. That append was unconditional, and
_create_category_impl reaches it on an existing name too, since
get_or_create_category returns the existing row on a hit.

The fix

_rebuild_category_view() recomputes category_configs, category_config_map and
_category_prompt_str from (effective config, persisted rows), which makes them
idempotent and removes the need for any is-it-present check. It runs after
_ensure_categories, after a create, and after an update -- the last because
_update_category_impl wrote the row but left the advertised description stale.

Three choices, each pinned by a test that fails without it: config descriptions
win over a row's, the baseline is memU's effective memory_categories rather
than nerve's config, and names compare exactly. The commit message carries
the reasoning for each.

_initialize_impl gains one prerequisite: the table is loaded while the repo cache
is cold (Fix 5 short-circuits list_categories on a warm cache) and before the
availability flags, so a failure cannot leave the bridge advertising an
uninitialized service. _attach_engine_pragmas() moves ahead of that load so it
runs with the 30s busy_timeout; that call and its comment are the only
pre-existing lines relocated. No new embedding calls. Two adjacent seeding gaps
are tracked separately.

Validation

21 new cases; 18 fail on a source-only revert with the tests kept. Reverting
only the helper leaves 2 failing and only the load 17, so neither half alone
suffices. 15 mutants, all killed. By name, not count: tests/ goes 7 failed /
2933 passed to 7 failed / 2954, identical failed set (pre-existing timezone
artifacts).

Latent today -- this box's 8 rows match its 8 configured names -- but one web-UI
create arms it permanently.

memU shows the memorize LLM exactly one list of categories: the one built
from MemoryService.category_configs. nerve fills that list from config at
construction and afterwards only ever appends to it, so it drifts from the
DB in both directions.

A category created at runtime (web UI -> bridge.create_category) is
appended in that process only. After a restart its row is in the DB but
absent from the list, so the LLM is never told it exists and can never
assign an item to it: a user-created category silently stops receiving
memories while continuing to appear in the UI. Recall was unaffected -
ctx.category_name_to_id is already rebuilt from the DB.

In the other direction, MemoryService.__init__ already inserted every
configured category, and _ensure_categories then seeds it through
_create_category_impl, whose insertion was unconditional - so a clean
first boot advertised each configured category twice. Worse after init:
get_or_create_category returns the existing row on a name hit and nothing
rebuilds the list, so every repeated create appended again. Three
web-UI creates on one existing name gave four identical prompt lines and
four copies of one ID in ctx.category_ids, against a single DB row.

_rebuild_category_view() recomputes all three derived fields from
(effective config, persisted rows), which makes them idempotent and
removes the need for any is-it-present check. Config entries stay
authoritative and keep their order: a row keeps whatever description it
was created with, so letting the row win would make a config.yaml edit
stop reaching the prompt. The baseline is memU's effective
memory_categories rather than nerve's config, because the two differ on a
deployment with no configured categories, where memU substitutes its own
defaults and reading nerve's config would wipe them. Names are compared
exactly, as memU stores and matches them - the schema has a non-unique
name index, so procedures and PROCEDURES are distinct rows and a
normalized comparison would silently omit one. Rows are appended
name-sorted because list_categories issues no ORDER BY.

_update_category_impl gets the same call: it wrote the row and re-embedded
but left the advertised description stale for the rest of the process.

Two prerequisites in _initialize_impl. The category table is loaded once
while the repo cache is guaranteed cold, because Fix 5 short-circuits
list_categories on a warm cache; that load sits before the availability
flags, so a failure cannot leave the bridge advertising a service it could
not initialize. The existing _attach_engine_pragmas() call moves ahead of
it so the load runs with the 30s busy_timeout - dispose() recycles pooled
connections but cannot re-run a read that already completed. That call
and its comment are the only pre-existing lines this change relocates.

ctx.category_ids is guarded separately, on the ID rather than on config
presence: on first boot a name is already in category_configs while its ID
is not yet in ctx, so a shared predicate would wrongly suppress a needed
append.

Costs no embedding calls: nerve sets ctx.categories_ready = True, which
makes memU's own _initialize_categories unreachable, and a CategoryConfig
built from a row carries no summary_prompt or target_length, so the one
live consumer behaves exactly as it does for a miss today.

Found while investigating the category map; not fixed here: a failed
configured seed still leaves the category advertised with no persisted row
(_ensure_categories discards the create's return value), and a deployment
with no configured categories still seeds nothing.
@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. A probe driving a real MemoryService over a real SQLite store, with a genuine "previous process" preseed through initialize() + create_category: run-probe.sh "A restart" '[["procedures","NEW config desc"]]' normal '[["procedures","OLD db desc"],["my_topic","user made this"]]'. 100% reproducible for all four scenarios (restart, first boot, 3x create on an existing name, description update). No sampling.
b Root cause explained? Yes. memU derives the LLM's category list solely from MemoryService.category_configs. nerve fills it from config at construction (:1538-1543) and afterwards only appends (:3034, the sole write site at base); it also disables memU's own producer via ctx.categories_ready = True (:1586, never reset anywhere) and substitutes a rebuild for ctx but none for category_configs. So the list is at once missing unconfigured categories and duplicating configured ones, and get_or_create_category returning the existing row on a name hit is what makes the unconditional append unbounded after init.
c Fix matches root cause? Yes. One owner for the derived view, recomputed from (effective config, persisted rows) -- idempotent by construction. Rejected alternatives: deduping inside memU's formatter (wrong layer, and category_configs/ctx would still grow), a created-ness predicate (wrong in both directions), a pure DB rebuild (measured: silently reverts a config.yaml description edit), re-enabling memU's producer (re-embeds every category per init). No defensive guard at a symptom site.
d Test intent preserved / new tests added? Yes. No existing test weakened, skipped or removed; 21 new cases, including the anti-over-suppression and precedence controls. The pre-existing FAILED-name set is identical before and after.
e Both directions demonstrated? Yes. Source-only revert with the new tests kept: base 18 failed / 3 passed, all edits 21 passed. Two partial arms prove non-interchangeability: helper only 2 failed, load + pragma move only 17 failed. Plus 15 mutants, all killed, with an unmutated control green at both ends and TREE_RESTORED_OK.
f Fix is general across code paths? Yes. Carriers enumerated at HEAD: the helper is the sole writer of all three derived fields (grep over the file). All three mutation paths call it (_initialize_impl, _create_category_impl, _update_category_impl), and both external update_category entry points (gateway/routes/memory.py:266, agent/tools/handlers/memory.py:521) route through the fixed impl. No delete path exists (delete_category / DELETE FROM memu_memory_categories -> 0 hits across nerve and memU), so the rebuild cannot resurrect a deleted row. Backend-agnostic: all three memU backends expose the same cache + list_categories, and nerve hardcodes sqlite.
g Fix generalizes across inputs (params/datatypes/wrappers)? Yes. Four input shapes are pinned by shipped tests: a case-variant pair, an empty baseline, a non-empty baseline, and a cache emptied between rebuilds. The rest hold by construction rather than by measurement, and are stated that way: a row description=None becomes '' (cat.description or "") so it is prompt-safe, an empty or whitespace description is handled by the formatter's own strip(), _service = None returns before any access, and the sort key is getattr(c, "name", "") so a row without a name cannot raise. Name length and non-ASCII names are irrelevant to the helper, which only compares and sorts strings.
h Backward compatible? (maintainer-approved exception only) Yes. No config-schema change (nerve/config.py not in the diff), no DB-schema change, no migration (SCHEMA_VERSION 0 hits in the diff), no new setting, no serialization-format change, no API change. The only observable difference is an in-memory prompt string regenerated every process. One deliberate change: a list_categories() failure during init now aborts init (returning False, exactly as the existing except already does) rather than being swallowed -- fail-closed, covered by a test.
i Invariants and contracts preserved? Yes. The helper builds a local list and assigns the three fields only afterwards, so a mid-build raise leaves all three at their previous consistent values -- measured with an injected raise, not argued. It writes no ctx state, makes no DB write and no network call, and takes no lock; ctx.category_ids is guarded separately on the ID and ctx.category_name_to_id is untouched. All state is RAM-only and rebuilt every process, so no crash-durability obligation arises. Ordering inside _initialize_impl is strengthened, not weakened: pragmas -> load -> availability flags, adding no raise-capable statement after the flags.

Session id: cron:clickhouse-impl-slot-41:20260803-122200

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review (2 blockers raised, both refuted by measurement; 3 nits fixed) - click to expand

An independent model reviewed this diff cold against the repo and the pinned
memu-py 1.4.0 sources, separately from whoever wrote it. It raised two blockers.
I could not sustain either: both name real defects, but each cites lines this
diff does not touch, and I measured base-vs-head parity for each rather than
arguing from a different mechanism. Both are already owned by separate in-flight
changes. Recording them here so a reviewer can check my reasoning instead of
taking my word.

Both trees were exported with git archive (never a worktree copy) and driven
through the real MemUBridge.initialize() on temporary databases; the live store
was only ever opened read-only.

Blocker 1: an empty category config advertises memU's defaults with no rows and no resolvable IDs

Real, and correctly described. Not introduced here, and not widened.

Every line cited is unchanged context: git diff shows zero added or removed
lines for categories_ready, for the ctx.category_name_to_id build, or for
_ensure_categories' early return (byte-identical before and after).

scenario before after
clean DB, empty config 10 advertised / 0 rows / 0 map keys / 10 unresolvable 10 advertised / 0 rows / 0 map keys / 10 unresolvable
a category persisted by an earlier process, empty config 10 advertised / 0 resolvable / 10 unresolvable 12 advertised / 2 resolvable / 10 unresolvable

The unresolvable set is the same ten memU defaults in both trees. This change
adds exactly two advertised names and both resolve, so it moves the resolvable
count from 0 to 2 and adds no unresolvable name. Seeding rows for memU's defaults
is a different contract from this one ("the advertised set equals effective
config plus persisted rows"), and it is the subject of a separate change already
in review that implements exactly the remedy suggested here - seeding from the
effective category set.

Blocker 2: exact-name dedup advertises case variants that the lowercased ID map collapses

Also real, also pre-existing, and the "memories could be assigned to the wrong
category" half is not introduced here. The map-keying line
(ctx.category_name_to_id[cat.name.lower()]) is byte-identical before and after.

With procedures and PROCEDURES both persisted, on both trees all three
spellings (procedures, PROCEDURES, Procedures) resolve to the same single
row
, and both rows remain in the database. Resolution is bit-for-bit unchanged;
only the advertised set differs. Advertising a name whose row cannot be addressed
individually is a better state than the previous one, where that row was neither
advertised nor addressable.

Normalized dedup is not available as a cheap alternative: it silently omits a
persisted row from the advertised set, which is the write-side loss this change
exists to remove (a mutation test pins that). Case-insensitive identity is the
subject of another change already in review, which introduces the single
normalization rule across both the view and the ID map that this finding asks
for. Merge order matters: if that one lands first, case variants can no
longer coexist and the exact-name comparison here should be revisited.

Nits, all fixed in this round

  • The validation table said ten input shapes were "measured". Four are pinned by
    tests; the other six hold by construction. Corrected to say which is which,
    and to name the construct for each.
  • The description said the pragma call was "the only pre-existing line moved" -
    the call and its comment both move, as the commit message already said.
    The two now agree.
  • The description ran longer than this repo's recent precedent; condensed, with
    the removed reasoning kept in the commit message.

What I checked independently

I enumerated the writers and consumers of memU's advertised category state
myself before reading the author's notes, and reached the same set. Worth
stating explicitly: widening category_config_map with row-derived entries is
free, because its only consumer reads summary_prompt and target_length
through (cfg and cfg.X) or default, and a row-derived entry leaves both None

  • so a hit behaves exactly like today's miss. DatabaseState is per-store, so
    the rebuild cannot pick up another store's rows. There is no delete path in
    either tree, so the rebuild cannot resurrect a removed row; a future one would
    need its own prune.

On test liveness: the 21 new cases are each pinned by at least one mutant, and
the three that pass before the fix are deliberate controls, each independently
killed by a mutant. One mutant initially survived - the pragma-ordering one -
because the original observable could not depend on the ordering. That was a
real hole in the test, and it was closed by observing the timeout seen by each
read in order, then re-running the whole matrix rather than banking the earlier
result.

I re-derived the suite figures from the raw logs rather than trusting the
summary: 7 failed / 2933 passed before, 7 failed / 2954 passed after, identical
failed set by name, and the 7 are pre-existing timezone artifacts.

Verdicts: 3 agreed (all nits, all fixed here), 2 disagreed with the
measurements above. 1 review round.

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