Stop dream consolidation from destroying memories - #506
Conversation
Consolidation handed the entire corpus to the LLM every cycle with an open licence to delete, then hard-deleted whatever came back. Exposure compounds: at the default twice-daily cadence a one-in-a-thousand misjudgement per entry per cycle loses roughly half the corpus in a year. A live agent lost 26% of its entries in 3.5 days that way, including ones reinforced 214x at 0.99 importance. Two structural changes: Archive instead of delete. MemoryEntry gains ArchivedAt/ArchiveReason and ILongTermMemory.ArchiveAsync hides an entry from search while keeping it on disk and retrievable by id. Recovery and retention sit on a separate optional IArchivedMemoryMaintenance capability; a purge pass hard-deletes after Dream:MemoryArchiveRetention (default 90 days). Archives log at Information with the content inline so a bad cycle is reviewable. Gate what the LLM sees. An entry is eligible only if it is new or changed since its last review, or sits in a near-duplicate cluster. Review state is a content fingerprint, not a timestamp, so importance decay (which rewrites score and UpdatedAt but not content) does not re-open the whole corpus. Clustering lives behind IMemoryDuplicateCandidates on the store: cosine where embeddings exist, Jaccard otherwise, so BM25-only deployments still dedupe. Enforcement is in code, not just the prompt -- merge arithmetic is keyed on the eligible set, so an id outside it resolves to nothing. Also fixes a latent data-loss bug: the pass deleted all sources up front and saved afterwards, so a toSave with blank content destroyed its sources with nothing written. Merged entries are now saved first and only sources whose replacement persisted are retired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Archiving made bad consolidation decisions recoverable, and gating shrank how many entries are exposed to one. Neither prevents the specific failure that was observed, because high-value entries are high-value precisely because they are frequently reinforced and have near-duplicate siblings -- which is what keeps them eligible every cycle. Three safeguards, all deterministic. The prompt-level equivalents were already in dream.md and did not hold: it said reinforcement signals importance, and a live corpus still lost entries reinforced 214, 106 and 80 times; it said to keep the most specific detail, and a merge still dropped a person's legal name while keeping the account list around it. Merge coverage check: proper nouns, acronyms and multi-digit numbers present in a merge's sources must appear in the merged text, or the merge is rejected and the sources are left alone. Biased toward rejection because the costs are not symmetric -- a false rejection leaves a duplicate alive one more cycle, a false acceptance destroys the only record of how a fact was worded. Measured on a real 148-entry corpus: rejects 0% of content-preserving merges, catches 83% of merges that drop a source outright. High-value pruning floor: entries at or above PruningProtectionImportance (0.80) or PruningProtectionReinforcementCount (5) can be merged, but are never archived as standalone ephemeral. Provenance: merged entries record mergedFrom and mergedAt. Source text is not duplicated -- sources are archived rather than deleted, so the IDs resolve for the retention window. Metadata is outside the search surface, so ranking is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by running a real dream cycle against the live corpus, not by the unit tests. dream.md requires every sourceId to also appear in toDelete, so when the coverage check rejected a merge, its sources were still sitting in the standalone-removal list and got archived as "flagged ephemeral" anyway. The rejection achieved nothing -- in fact it was strictly worse than allowing the lossy merge, because the sources were removed with no replacement at all. Two rejected merges took 13 entries with them. Sources of a rejected merge are now excluded from the ephemeral path. Also expands the common-word list. The live run flagged "Candidate", "Adding", "Flagged" and "Validated" as proper nouns because they opened a sentence, which rejected an otherwise sound merge. False positives are the cheap direction -- they only cost a duplicate surviving another cycle -- but not for free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live validation on the production agent (0.14.7 → 0.14.8)Deployed to the cluster and watched two real dream cycles against a 275-entry corpus. The first run found a defect the unit tests could not. Cycle 1 — 0.14.7, cold start
Bug found: the rejected merges' sources were archived anyway, as The unit tests test All 13 recovered by clearing their archive stamps — which is the archive tier doing its job. Under the old code they would have been destroyed silently. Fixed in Cycle 2 — 0.14.8, fix applied
Merge quality. A 13-source Allen Conway merge passed the coverage check and kept every specific — Coverage-check precision. 8 rejections; 6 caught a genuinely dropped specific ( CaveatTwo cycles on one corpus is not proof of general calibration. Cycle 1 already showed a shape that unit tests missed. Worth watching several more cycles before treating the safeguards as settled. |
ConsolidationMaxClusterSize was documented as capping merge fan-in. It does
not. It bounds which entries are shown to the model; the model may then merge
any subset of them. Production ran a 13-source merge under the default of 3.
The behaviour is right and the documentation was wrong: an arbitrary fan-in cap
would have blocked that 13-source merge, which was excellent -- it preserved
both email addresses, the phone number, the title and every other specific
across 13 fragmentary entries. Large merges are constrained by the coverage
check, which judges whether detail survived rather than guessing from a count.
The same cycle accepted the 13-source merge and rejected a 6-source one that
dropped 28 specifics including three people's names.
Also adds "ids", "enjoys" and "downloading" to the common-word list after a
second live run. Kept deliberately short: other words from the same rejections
-- Personal, Power, Social, Code, Class, Benefit, Extended -- read as generic
but are load-bearing here ("OneDrive Personal", "Blazor Online Class", "MVP
Azure Extended Benefit"), and stoplisting them would blunt a correct rejection.
A test pins that distinction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The word list was hardcoded, which is wrong: vocabulary is deployment-specific
and the built-in list is only defensible for an operational assistant.
It fails badly in the other direction. The baseline contains "may", "will",
"some", "first" and "last", so a storytelling agent with a character named May,
Will or Rose would have those names silently stripped of coverage protection --
precisely the population that must never be lost in a merge, and exactly the
class of failure this safeguard was built to stop.
Vocabulary now loads from merge-coverage-vocabulary.json on the agent profile
volume, alongside tier-selector.json, re-read at the top of every dream cycle so
edits take effect without a restart:
extraCommonWords suppress domain noise
alwaysSpecificWords reclaim baseline words that are actually names; wins over
everything else
The generic-English baseline stays in code as the default, so behaviour is
unchanged when no file is present. A malformed file falls back to the baseline
with a warning -- bad config must never silently disable coverage checking.
Ships a documented example with both lists empty, and tests pinning the
storytelling case, precedence, JSON round-trip and the malformed-file fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vocabulary file was added as a profile file but never wired into delivery, so no deployment would ever have received it. Two gaps, both silent: the agent csproj copies agent\**\*.md plus individually-listed JSON, and the chart's init container seeds *.md plus individually-listed JSON. A .json added to agent/ matches neither pattern. Behaviour degraded gracefully -- a missing file falls back to the built-in baseline -- which is exactly why this would not have been noticed. Operators would simply never have discovered the file existed. Adds the csproj entry (verified in the publish output) and an init-container block following the llm-pricing.json precedent, no-clobber so operator tuning survives image upgrades. Bumps to 0.14.9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was Debug, so at the default level an operator tuning the file had no way to confirm their edit was picked up -- for a file that decides what a merge is allowed to drop. LoadDirectives already keeps its per-cycle reload line at Information for exactly this reason; this now matches, and names the reclaimed words so a storytelling deployment can see its character names are protected. Once per cycle, so the cost is nil. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ships the Information-level merge-coverage vocabulary logging (c82d478). At Debug there was no way to confirm from a running agent that the vocabulary file had been read at all -- a live cycle produced only circumstantial evidence about whether an override was in effect, which is not good enough for config that decides what a merge may discard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four more live cycles (0.14.8 → 0.14.10), plus a design fixContinued running against the production agent. Six cycles total now; the previous comment covered the first two. Cycle table
† the bug fixed in Gating converged. Withholding settled in the low-to-mid 50s. The corpus stopped shrinking: live entries went 275 → 240 → 223 → 229 → 242 → 236, i.e. it consumed the duplicate backlog that accumulated while consolidation was off, then stabilised with mining outpacing consolidation. The early drop was transient, not erosion. Zero hard deletions across all six cycles. Every removal is recoverable for 90 days. The safeguards caught real lossesCycle 3 — a merge that would have collapsed six entries into one narrow technical note: Carl Franklin, Matt and Lacey — three more people, about to go the way of Trish Roberts. Cycle 6 — the high-value floor fired for the first time: That is one of the exact entries from the original forensic table ( Design fix: the coverage stoplist was hardcoded (
|
The problem
Dream consolidation handed the entire memory corpus to the LLM every cycle with an open licence to delete, then hard-deleted whatever came back —
File.Delete, no tombstone, and the only trace was a Debug log line containing the entry ID, not its content. So a loss was both unrecoverable and uninvestigable.Exposure compounds. At the default twice-daily cadence each entry faces 730 deletion decisions a year:
A one-in-a-thousand misjudgement loses half the corpus in a year. No directive is good enough to beat a 730×-repeated gamble.
Measured, not theorised
Restored a Longhorn backup of a live agent's PVC and diffed it against the running store. Over 3.5 days: 148 → 109 entries, −26%. Named things that vanished corpus-wide:
Duane,Trish,Roberts,PWOP Productions,Las Vegas,Tripit,BlazorBook,BlazorHol.Importance and reinforcement provided zero protection. Among entries whose content survived nowhere:
dream.mdalready told the model reinforcement signals importance. It deleted them anyway. That is why every safeguard here is deterministic rather than prompt text.The merge failure mode is subtle:
user-preferences/identity's successor kept the machine-readable account map and silently dropped "Rocky Lhotka also appears in travel and calendar data as Rockford Duane Lhotka." The result reads fine. Nothing flagged it.Changes
Archive instead of delete.
MemoryEntrygainsArchivedAt/ArchiveReason;ILongTermMemory.ArchiveAsynchides an entry from search while keeping it on disk and retrievable by ID. Default interface method delegating toDeleteAsync, so existing fakes compile untouched. Recovery/retention sit on an optionalIArchivedMemoryMaintenance; a purge pass hard-deletes afterDream:MemoryArchiveRetention(90d). Archives log at Information with content inline.Candidate gating. An entry is eligible only if new/changed since its last review, or in a near-duplicate cluster. Review state is a content fingerprint, not a timestamp — importance decay rewrites score and
UpdatedAtbut not content, so decayed entries don't leak back through the gate. Clustering sits behindIMemoryDuplicateCandidates: cosine where embeddings exist, Jaccard otherwise, so BM25-only deployments still dedupe. Enforced in code — merge arithmetic is keyed on the eligible set, so an ID outside it resolves to nothing. A clustering failure degrades to unreviewed-only, never back to the whole corpus.Merge coverage check. Proper nouns, acronyms and multi-digit numbers in a merge's sources must appear in the merged text, or the merge is rejected and the sources are left alone. Deliberately biased toward rejection: a false rejection leaves a duplicate alive one more cycle; a false acceptance destroys the only record of how a fact was worded.
Validated against the real 148-entry corpus:
The 17% missed are pairs where one source's specifics are a strict subset of the other's — genuinely redundant. That empirical pass caught two false-rejection bugs the unit tests missed: possessives (
Rocky'svsRocky, 27 occurrences) and bare single digits (top 3→top three).High-value pruning floor. Entries at or above
Dream:PruningProtectionImportance(0.80) orDream:PruningProtectionReinforcementCount(5) can be merged — content survives, coverage-checked — but are never archived as standalone ephemeral.Provenance.
mergedFrom/mergedAton merged entries. Source text is not duplicated: sources are archived, so IDs resolve for the retention window. Metadata is outside the search surface, so ranking is unaffected.Latent data-loss bug fixed. The pass deleted all sources up front, then saved. A
toSavewith blank content hit acontinue— after its sources were already gone. Any throw between the loops did the same at scale. Merged entries are now saved first, and only sources whose replacement persisted are retired.Honest scope
Gating alone would not have prevented most of the observed losses — high-value entries are frequently reinforced and have near-duplicate siblings, which is exactly what keeps them eligible. For those, the coverage check and the pruning floor are the prevention, and archiving is the safety net.
Still open (follow-up, all pre-existing and now much less dangerous):
dream.mdstill licenses deletion at near-zero importanceLastSeenAt, so a memory used weekly still decaysuser-preferences/**MaxResults: 1000Testing
dotnet build RockBot.slnxclean; all 19 test projects pass. 35 new tests acrossMemoryArchiveTests,ConsolidationCandidateGatingTestsandMergeCoverageTests— including a regression test built from the actualRockford Duanemerge that lost the name.Deployment note
Consolidation is currently disabled on the live agent (
Dream__MemoryConsolidationEnabled=false) to stop the bleeding. After deploying, re-enable and watch one cycle for the new Information lines:reviewing N of M entries,consolidation safeguards fired,refused to prune. If merge rejections run high,ConsolidationSimilarityThresholdis the dial.🤖 Generated with Claude Code