← back to case studies

Long-Term Memory Self-Reinforcing Loop — 2026-05-03

2026-05-03

Source code references in this study (e.g. 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:

  1. Some user input triggers an LTM recall.
  2. LTM emits MemoryRecall("[ltm] X", charge, age).
  3. LTM's own on_broadcast listener receives that event.
  4. Both gate conditions pass (age > 300, charge nonzero).
  5. LTM stores "[ltm] X" as a NEW LongTermEpisode with formed_at = now.
  6. Future TextInput recalls the new episode → emits [ltm] [ltm] X.
  7. 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 in last_thought even 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:

  1. Module emits Signal::X.
  2. Module listens for Signal::X in its on_broadcast without a source-module guard.
  3. Listener handler persists the signal data into save_state-relevant fields (i.e. mutates fields included in the bincode round-trip).
  4. 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_state impl changes
  • Any new Signal:: variant added to core/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).