fix(memory): refresh content-derived fields on compress, restore, and refine - #193
Conversation
… refine Content changes outside smem_edit re-saved the old content_hash and the old embedding vector next to the new text. acidkill#166 fixed the edit tool; the compression engine's compress/decompress/recover paths and the instruction refine handler kept the pattern, so a memory stayed retrievable by content that had since been compressed away. Refreshing is batched. Compression walks every neuron of a fiber from a background pass under a time budget, in a path that already counts database round-trips as its dominant cost, so the helper takes a list: hashes are recomputed locally and the neurons carrying a vector are embedded together in one embed_batch behind one brain lookup, mirroring how encoder.py embeds on create. A fiber costs two round-trips regardless of its size. A provider returning fewer vectors than texts is rejected before any of it is assigned - assigning positionally over a short list would leave the tail neurons holding their old vectors with no error and no warning, the same silent staleness this change removes. A tombstone must carry no fingerprint on any axis. Every GRAPH_ONLY neuron shares the placeholder as content, so anything derived from it is one constant brain-wide, and every consumer that compares derived fields pairs unrelated tombstones: the dedup census by Hamming distance, interference detection via its recompute-on-missing-hash fallback, semantic discovery by cosine on the stored vector, and recall's anchor selection, where identical tombstone vectors crowd genuine memories out of the top-k slots. The placeholder therefore carries content_hash 0 (the established no-fingerprint value the census, the dedup pipeline and the context optimizer already skip), and every consumer whose comparison against a tombstone fingerprint would persist a wrong outcome skips placeholder content: interference, discovery, recall anchor selection, the census, the save-time dedup pipeline's candidate filter (a legacy tombstone's stale hash could canonicalise a brand-new save onto a placeholder anchor), entity reuse at encode time, and novelty scoring. The census content guard is what covers tombstones written before the sentinel existed: a fiber parked at GRAPH_ONLY never re-enters compression, so their stale hashes cannot be re-stamped - they are made inert instead. When a partially recovered fiber does re-enter, the stale hash is normalised to the sentinel without a provider call. The batch helper accepts a pre-fetched brain so a whole compress_all pass pays one brain lookup instead of one per fiber, and embeds each distinct text once, so a GRAPH_ONLY fiber costs one embedding rather than N copies of the placeholder. Clearing the vector instead was not available: embedding_vec carries an HNSW index with a fixed dimension, so an empty array is rejected outright and takes the whole update with it, leaving content unchanged too. The helper moves to utils/content_refresh.py; engine/ does not import mcp/, and reusing it in place would have added a backwards cross-layer edge.
acidkill
left a comment
There was a problem hiding this comment.
Reviewed against main@ae8e8743 across four passes — general correctness, silent-failure hunting, Python/type review, and a dedicated test-quality audit. No correctness defects found, and nothing blocking. Four follow-up items below, each with a note on whether it is introduced here or pre-existing, because that distinction changes what is owed.
What was verified, not just read
- Batch alignment is correct. The dedup-then-fan-out (
unique_index/texts/targets) pairs every neuron with the vector derived from its own text;zip(..., strict=True)and thelen(vectors) != len(unique_texts)pre-assignment check together close the misalignment and short-response holes.test_batched_recover_pairs_each_neuron_with_its_own_text_vectorandtest_short_provider_output_warns_instead_of_silently_keeping_old_vectorsare both causally dependent on that logic. - The
dc_replace-then-mutate-the-shared-dict pattern is sound, not a latent bug:Neuronis a frozen dataclass with no__post_init__, anddataclasses.replacedoes not copymetadata, so the latermetas[i]["_embedding"] = ...genuinely reaches the returned neuron. #188survived the move intact._structureis re-derived and removed (not overwritten) when the new content has no structure, andtest_editing_structure_away_drops_it_entirelystill asserts exactly what it did before. The repointed AST probes now searchcontents_refreshed, which is where the logic actually lives — a correct re-point, not a weakening.- No pre-existing test was weakened or deleted. Every hunk in the test files is either a pure addition or one of two mechanical repoints (the logger name in
caplog.at_level, and the import paths). - The provider path mirrors
encoder.py— sameembed_batch+_inline_embed_timeout+TimeoutError-before-Exceptionshape — so it inherits the write path's established behaviour rather than inventing a second convention. - Restore-path tests genuinely seed drift. You flagged in the description that the naive version of these tests passes on unfixed code; the tests do the right thing and overwrite content, hash and vector with a mismatched triple before restoring, so the post-restore assertion can only come from a real refresh.
Follow-ups
1. "[graph-only]" is now a magic string across eight files — worth a named constant. (Introduced here.) On main the literal appears twice, in one file. This PR takes it to fourteen occurrences across eight. All seven new guards compare against a bare literal with nothing importing a shared symbol, so changing the placeholder text in compression.py would type-check, lint clean, and silently disable every guard — reintroducing in one commit the exact failure modes this PR documents. A GRAPH_ONLY_PLACEHOLDER constant imported at each site would make that change fail loudly instead.
2. The new brain pre-fetch in run() swallows its exception without logging. (Introduced here.) compression.py around the try: brain = await self._storage.get_brain(...) / except Exception: brain = None block is the only unlogged except in a file where all ten others log. It is correctly fail-soft — the helper re-fetches per fiber — but two consequences are invisible: the one-lookup-per-pass optimisation silently degrades to N lookups with no way to find out why the pass got slower, and if the storage problem persists, the warning the operator does see comes from content_refresh and blames the embedding provider for what is actually a brain-fetch failure. A logger.warning(..., exc_info=True) before the fallback would fix both.
3. The legacy-tombstone repair normalises the hash but leaves a stale _structure. (Pre-existing on main; this PR reduces it rather than causing it.) The refreshed.extend(dc_replace(n, content_hash=0) ...) branch bypasses contents_refreshed, so a tombstone written before this change keeps metadata["_structure"] describing its deleted original text — which the recall handler echoes as item["structure"]. To be explicit about attribution: pre-PR GRAPH_ONLY compression did a bare dc_replace(neuron, content="[graph-only]") and never touched metadata, so this staleness already exists today; after this PR newly compressed tombstones have it correctly removed, and only rows written earlier are affected. Popping _structure in that branch is a pure local computation, so it would keep the "a repeat pass stays provider-free" property you designed for. Reasonable either here or in the one-shot migration you already propose.
4. test_graph_only_tombstone_anchors_are_never_aliased passes on unfixed code. Both seeded anchors carry content_hash=0, which is caught by the content_hash is None or content_hash == 0 skip that predates this PR, so the loop never reaches the new content guard. It is not wrong — it pins a pre-existing invariant that this PR's design now leans on — but the new guard's actual regression cover comes from its sibling test_legacy_stale_hash_tombstone_is_never_aliased, which uses a non-zero legacy hash and is correctly targeted. Worth a docstring tweak so a future reader does not mistake the redundant one for the load-bearing one.
Also noted, not asked for: contents_refreshed runs roughly 95 executable lines against the project's <50 guideline, and smem_edit/smem_refine still return a clean {"status": "edited"} when re-embedding failed, with the warning reaching only the server log and never the calling agent. The second is inherited from #166, not introduced here, but it is the same shape of invisibility this PR exists to remove, so it may be worth a warning field in the tool response at some point.
On the two judgement calls you flagged
Leaving server/routes/memory.py out was right — different surface, different test story, and the enumeration backing the claim that it is the last instance checks out. Same for deferring the neuron_snapshots column normalisation in #192.
The conflict resolution against #188 is the part worth calling out: taking either side wholesale would have been wrong, and the failure mode you avoided — a helper that silently stops calling detect_structure on the edit path, with nothing failing loudly — is precisely the kind that survives review. Absorbing it into the shared helper and keeping the regression tests pointed at the new location was the right call.
Approving. None of the four items above changes behaviour today; they are worth a follow-up rather than another round here.
Summary
content_hashand the embedding when they replace a neuron's content, instead of re-saving the old ones next to the new text - the same class fix(mcp): smem_edit refreshes content_hash and the embedding on a content change #166 fixed forsmem_edit, on five of the six call sites it left standing (the sixth is a server route, noted below).embed_batchper compression step, not one per neuron. Compression walks every neuron of a fiber under a time budget, so a per-neuron round-trip was not an acceptable way to fix this.utils/content_refresh.pyso both layers can use it without a new cross-layer import.Rebased onto v3.8.0, and what that changed
This branch was rebuilt on
ae8e8743(v3.8.0). One file conflicted, and the resolution is worthdescribing because taking either side wholesale would have been wrong.
#188landed after this work was written. It kept_content_refreshedlocal tomcp/lifecycle_handler.pyand taught it a third derived field:metadata["_structure"], whichrecall reads back to answer with the memory's fields. This branch does the opposite - it removes
that local function and moves it to
utils/content_refresh.py, so the nine other call sites canshare it.
Resolving in this branch's favour alone would have silently dropped
#188: the handler woulddelegate to a helper that knew nothing about
_structure, and an edited memory would keepdescribing text that no longer exists - the exact defect
#188fixed. Nothing would have failedloudly;
detect_structurewould simply have stopped being called on the edit path.So the shared helper absorbs it.
contents_refreshednow re-derives_structurefor everyneuron whose content changes, and removes the key when the new content has no structure at
all - preserving
#188's deliberate choice of removal over overwrite, because an overwrite-on-hitwould leave recall surfacing fields that exist nowhere.
#188's own regression tests are kept and repointed at the new location (two direct imports andtwo AST probes). They pass unchanged in substance, including
test_editing_structure_away_drops_it_entirely- which is the proof that the move did not costthe fix.
Why
#166 fixed
smem_edit's content-update paths but left the same pattern everywhere else that overwrites a neuron'scontent:engine/compression.py's compress/decompress/recover, andmcp/instruction_handler.py's refine handler. Each diddc_replace(neuron, content=new)straight intoupdate_neuron, which writescontent_hashfrom the object and the vector frommetadata["_embedding"]- so the old fingerprint and the old vector were re-saved against the new content.Compression makes the effect automatic rather than opt-in. A neuron reaching the GRAPH_ONLY tier has its content replaced with the placeholder
"[graph-only]"while its vector and hash keep describing the full text that was there before - the memory stays retrievable by content that has since been deleted. Tier 1-3 compression has the same problem against the compressed excerpt. Decompression and snapshot recovery restore the original text without checking the derived fields match it, so drift from an intervening compress cycle survives the restore silently.Worth flagging for anyone testing this: for the restore paths the obvious test - restore the original, assert the hash matches - passes on the unfixed code. The field was never touched since creation and you are restoring exactly the text it was derived from, so it agrees by coincidence. Two of the tests below seed a drifted hash and vector first, so a post-restore match can only come from an actual refresh.
Batching, and why the vector is refreshed rather than cleared
The obvious cheap fix - clear
embedding_vecand letreindex --missing-onlyrepair it - does not work, and fails in a way worth recording.embedding_veccarries an HNSW index with a fixed dimension (ensure_schemadefines it on every connect, defaulting the dimension when unset, so any schema-initialised deployment has it), and under that index writing an empty array is rejected:The error takes the whole statement with it: the row keeps its old content as well as its old vector, so the compression step silently does nothing. (
embedding_vec: NONEis accepted, butupdate_neuroncannot express it - aNoneinmetadata["_embedding"]means "leave alone", so clearing would need a storage-layer change.) Refreshing the vector is therefore the only in-scope fix.That makes the cost question real, because compression is not an interactive edit: it runs from a background consolidation pass, in loops over every neuron of a fiber, and
compress_fiberalready treats database round-trips as its dominant cost whilecompress_alldefers fibers when it exhausts its time budget. Embedding one neuron at a time would have added a provider round-trip per neuron to a path that makes none today.So the helper takes a batch: hashes are recomputed locally for everything, and the neurons that actually carry a vector are embedded together in a single
embed_batchbehind a single brain lookup - the same shapeencoder.pyalready uses when embedding on create. Each distinct text is embedded once and its vector fanned out, so a GRAPH_ONLY fiber costs one embedding rather than one per neuron of the identical placeholder;compress_allfetches the brain once for the whole pass rather than once per fiber. A fiber of any size costs two round-trips, not two per neuron.The single-neuron entry point is a thin wrapper over the batch one, so the edit and refine paths keep #166's functional behaviour: the edit succeeds without a provider, the old vector is kept and a warning names
smem reindex --allon provider failure, and the timeout branch stays separate. Two cosmetic deltas from #166, called out for anyone filtering logs: the warning now comes from thesurreal_memory.utils.content_refreshlogger rather thansurreal_memory.mcp.lifecycle_handler, and its text no longer begins with "smem_edit" (the helper serves several tools now).A tombstone must carry no fingerprint on any axis
One class of consequence only shows up once the derived fields are actually correct: every neuron compressed to GRAPH_ONLY ends up with the same content,
"[graph-only]", so anything derived from that content is a single constant shared brain-wide. Each downstream consumer that compares derived fields then pairs unrelated tombstones with each other:duplicate ofedge. So the placeholder is written withcontent_hash = 0- the codebase's existing "no meaningful fingerprint" value, which the census, the dedup pipeline and the context optimizer already skip - and the census and the save-time dedup pipeline additionally refuse to fingerprint placeholder content at all. Those content guards are what cover pre-existing tombstones: rows written before this change carry the SimHash of their deleted original text, a fiber parked at GRAPH_ONLY never re-enters the compression path (the tier early-return), andreindexrepairs vectors, not hashes. Without them, a genuine memory that near-duplicates the deleted text would be aliased to a tombstone by the census - or, worse, a brand-new save would be canonicalised onto one: the dedup candidate search reaches tombstones (the FTS analyzer tokenises the placeholder intograph/only), tier-1 would match the stale hash, and the new fiber would anchor to"[graph-only]"with its actual text surviving only as an alias neuron. Interference detection gets the same placeholder skip: its recompute-on-missing-hash fallback exists for real content that lost its hash, and would otherwise resurrect exactly the shared constant the sentinel suppresses - one save landing in the interference window wouldCONTRADICTS-link to every tag-overlapping tombstone at once.SIMILAR_TOedge) and recall's embedding anchor selection (where a brain's worth of identical tombstone vectors would otherwise tie with each other and crowd genuine memories out of the top-k anchor slots for any query near that region). The stale fields were accidentally hiding all of these interactions, since they still described the different original texts.The same reasoning covers two smaller hash consumers: entity reuse at encode time (its near-duplicate fallback could bind a brand-new entity reference to a placeholder neuron - a persisted wrong link) and novelty scoring (a tombstone's stale hash would make a new memory resembling the deleted text look familiar, storing it with deflated surprise). Both now skip placeholder content.
When a legacy tombstone does meet the compression path again - a partially recovered fiber, reset to FULL while its snapshot-less neurons kept the placeholder - the pass normalises its stale hash to the sentinel, without touching its vector and without a provider call. But that path is opportunistic, not a remediation: the guards above are what make legacy fingerprints inert. These seven guarded sites (census, save-time dedup, interference, discovery, recall anchors, entity reuse, novelty scoring) are, to the best of my enumeration, every consumer whose comparison against a tombstone fingerprint would persist a wrong outcome; the storage-level KNN search has no callers in the recall path. Two places still see legacy fingerprints, disclosed rather than stretched into this diff: sync's insert dedup compares exact hashes inside the storage query (a re-synced copy of text identical to a tombstone's deleted original is treated as already present - pre-existing behaviour, and arguably the intended dedup of the same aged-out memory; the sharp edge is a tombstone whose snapshot is missing, where the peer's copy is the only surviving full text and is still dropped), and the recall context optimizer's in-flight duplicate filter (no persisted state). A one-shot migration stamping the sentinel onto legacy rows (
content = '[graph-only]' AND content_hash != 0) would retire both and is a natural follow-up.One instance deliberately left out
server/routes/memory.py's neuron update route has the same bug: it buildsreplace(neuron, **updates)with a newcontentand hands it toupdate_neuron, so a content edit through the HTTP API re-saves the old hash and vector exactly as the paths here did. I have left it out rather than quietly widen the diff - it is a different surface with its own test story (routes, dashboard), and this PR is already the compression-and-refine change. It is the last instance I can find: I enumerated everyupdate_neuroncall insrc/, and the rest are metadata-only stamps (conflict_detection,supersession,reconsolidation,conflict_handler,integration/mapper.py), vector writes already derived from current content (encoder,doc_trainer), or peer-state replication (sync_engine). Happy to follow up with the route in a separate PR if you would rather have it handled the same way.Where the helper lives
mcp/already importsengine/lazily at the call site, butengine/does not importmcp/; reusing the helper fromengine/compression.pywould have added a backwardsengine → mcpedge. It is content-and-storage logic, not MCP-protocol logic, so it moved toutils/content_refresh.py- the homesimhashalready has, for the same reason. Its imports of the embedding provider stay function-local, exactly as before, so nothing changes about when that code runs.Test plan
pytest -m "not stress" -n autopasses locally - 7103 passed, 146 skipped, 1 xfailed (baseline onmainis 7079 passed with the same skip/xfail counts; the delta is exactly this PR's twenty-four new tests).ruff check src/ tests/clean;ruff format --check src/ tests/clean.mypy src/ --ignore-missing-importsclean - no issues in 354 source files.main: each compression site (GRAPH_ONLY compress, tier-1 compress, decompress, snapshot recovery) and the refine site saved a neuron carrying the oldcontent_hashand vector next to the new content; the provider-unavailable tests saw no warning logged.embed_batch- so a per-neuron round-trip cannot creep back in unnoticed. Separate tests cover a fiber mixing vectored and vectorless neurons, an install with no vectors never reaching for an embedder, and the bounded-wait branch.engine/,mcp/,utils/(the new shared helper) andtests/only; no server routes or dashboard assets.Verified by
@RobertSigmundsson
A note of thanks that is also context: #188 landed the day before this branch was rebuilt, and it
is a good fix - it is precisely why the conflict here had to be resolved as a merge rather than
a cherry-pick, and why the shared helper now carries the
_structurehandling instead of droppingit. Your regression tests for it are kept and repointed at the new location; they pass unchanged
in substance.
Thanks as well for closing #174, #175 and #176. This PR is the remaining half of that same class -
the call sites outside the MCP layer that
_content_refreshednever reached.