Long-Term Memory Self-Reinforcing Loop — 2026-05-03
2026-05-03
nous/src/...) point into Airene's proprietary repository. The source is not publicly available — to examine the code under NDA, please contact Apotentia.
Symptom
After purging 22 anomalous episodes from Hippocampus, the trauma fragment
self:taught: My husband says I'm continued to surface in UCDS as
[ltm] self:taught: My husband says I'm. Eric flagged: "weird."
Root cause
Direct feedback loop in limbic/long_term_memory.rs::on_broadcast:
Signal::MemoryRecall { content, emotional_charge, age_seconds } => {
if *age_seconds > 300 && emotional_charge.abs() > 0.1 {
self.store(...); // ← stored ANY MemoryRecall, including own emissions
}
}
Same module emits Signal::MemoryRecall { content: format!("[ltm] {}", short), ... } from its TextInput handler. So:
- Some user input triggers an LTM recall.
- LTM emits
MemoryRecall("[ltm] X", charge, age). - LTM's own
on_broadcastlistener receives that event. - Both gate conditions pass (age > 300, charge nonzero).
- LTM stores
"[ltm] X"as a NEWLongTermEpisodewithformed_at = now. - Future TextInput recalls the new episode → emits
[ltm] [ltm] X. - That gets re-stored. And so on.
Each cycle adds a new [ltm] prefix and a new entry. Over minutes, LTM
grows unboundedly with stacking-prefix duplicates of every emotionally-
charged thought she ever had.
Evidence
- DB size jump: nous.db went from 176 MB to 529 MB in ~30 minutes of normal operation post-restart. Hippocampus blob was ~2 MB; LTM blob was 583 bytes. Remaining ~525 MB was redb append-write overhead from the loop hammering writes.
- LTM contents at audit time: 2 entries — both the same trauma
fragment, second with stacking
[ltm]prefix proving the loop. - UCDS persistence:
[ltm]content kept appearing inlast_thoughteven after hippocampus purge.
Fix
limbic/long_term_memory.rs::on_broadcast:
Signal::MemoryRecall { content, emotional_charge, age_seconds } => {
if event.source_module == "long_term_memory" || content.starts_with("[ltm]") {
return; // skip self-emissions to break the loop
}
// ... existing logic
}
Two-layer guard: source_module check (canonical) AND content prefix check (defensive, in case a relay layer doesn't preserve source_module).
The general bug class — for future module/subsystem development
A self-reinforcing loop requires FOUR criteria to be simultaneously true:
- Module emits
Signal::X. - Module listens for
Signal::Xin itson_broadcastwithout a source-module guard. - Listener handler persists the signal data into save_state-relevant fields (i.e. mutates fields included in the bincode round-trip).
- Module's
tick()(or other path) re-emits based on persisted data.
If all four are true, the module amplifies its own emissions and grows its persistent state unboundedly. Database bloat + content corruption.
Single criteria don't matter. Many modules self-listen for context-tracking purposes (read-only). Many emit and listen but don't persist. The danger is specifically emit + listen + persist + re-emit-from-persistent.
Audit method
For each module file:
# 1. List signals it EMITS (in Event::new(...) calls)
grep -oE "Signal::[A-Z][a-zA-Z]+" "$module" | sort -u
# 2. List signals it LISTENS for (in on_broadcast match arms)
awk '/fn on_broadcast/,/^ }/' "$module" | grep -oE "Signal::[A-Z][a-zA-Z]+\s*[{=]"
# 3. For overlapping signals, manually inspect the handler:
# Does it persist? (assigns to self.X where X is in save_state's serialized fields)
# Does it re-emit? (the tick path uses self.X to emit Signal::X)
# 4. If both yes, add a source_module guard like LTM's fix.
Audit results, 2026-05-03
11 modules self-listen on signals they also emit. Manual triage:
| Module | Self-emit signal | Risk verdict |
|---|---|---|
| consciousness | InternalThought | LOW — read-only context tracking |
| creativity | InternalThought | LOW — read-only |
| default_mode | InternalThought | LOW — read-only, generates own thoughts on idle but doesn't persist InternalThought content |
| language | InternalThought | LOW — uses for context, doesn't persist |
| precuneus | InternalThought | ALREADY GUARDED ✓ |
| prefrontal | InternalThought | LOW — context window only |
| self_reflection | InternalThought | LOW — uses for reflection, persists own reflections separately |
| topic_tracker | TopicBoundary, TopicCohering | FALSE POSITIVE — those signals are emitted, not received (grep matched emit-side) |
| tpj | InternalThought | LOW — perspective tracking, no persistence |
| hippocampus | MemoryRecall | FALSE POSITIVE — no MemoryRecall match arm in on_broadcast |
| hypothalamus | ArousalShift | LOW — only triggers satisfy_drive(REST, ...), no re-emit |
| long_term_memory | MemoryRecall | WAS THE BUG, FIXED |
| arousal | ArousalShift | ALREADY GUARDED ✓ |
No other module currently exhibits the four-criteria pattern. The audit method should be re-run when any new module that PERSISTS state is added.
Sentinel for re-audit
Re-run this audit (audit_self_reinforcing_loops.sh if we ever build it
as a script) on these triggers:
- Any new module added to
cortical/,limbic/,subcortical/,autonomic/ - Any module whose
save_stateimpl changes - Any new
Signal::variant added tocore/event.rs
Eric's principle (2026-05-03)
we may have an recursion loop of ltm feeding back into ltm, or short-term memories reinforced unintentionally into long-term?
we should see if we have any other runaway self-reinforcing patterns like that, that's a problem/bug that has to be resolved everywhere and noted for future module or subsystem development
Standing rule: any module that emits AND listens for the same signal type must explicitly state in a code comment why the listener is safe (read-only, or has a source_module guard, or doesn't persist into save_state).