Hebbian Co-Retrieval — Agent Simulation & Improvement Analysis¶
Date: 2026-03-09 Scope: C1 implementation stress-tested against 10 agent scenarios Result: 4 issues found (1 critical bug, 2 semantic flaws, 1 observability gap) → 4 targeted fixes
Framing: What Does an Agent Actually Experience?¶
An elf agent operates in a tight loop:
loop:
ctx = await system.frame("attention", query=current_task) ← C1 STAGES HERE
response = llm(ctx.text + user_message)
await system.learn(insights_from_response)
if should_signal: await system.outcome(block_ids, signal)
if system.should_dream: await system.dream()
C1 Hebbian staging fires on every frame() call. The agent never calls it directly — it just happens. This "invisible learning" is a strength (zero ceremony) but also where subtle failures hide.
Scenario 1: The Agentic Assistant (Steady-State)¶
Setup: Agent processes user queries all day. Each loop: frame("attention", query=user_msg).
Trace:
Query 1: "python list comprehensions" → [A, B, C] staging: {(A,B):1, (A,C):1, (B,C):1}
Query 2: "python performance tips" → [A, B, D] staging: {(A,B):2, ...}
Query 3: "optimising python code" → [A, B, E] staging: {(A,B):3} → PROMOTED to co_occurs edge
Query 4+: A and B now connected → graph expansion returns both together even when only A matches
Assessment: Works correctly. Blocks that consistently co-appear in semantically related queries form edges. The graph learns the agent's domain topology.
Minor concern: Queries 1-3 all asking variations of "python performance" may or may not represent genuinely independent signals. The agent called the same mental concept three ways in one session — is that 3 independent confirmations? See Issue 2.
Scenario 2: The Identity Loop — CRITICAL BUG FOUND¶
Setup: Agent wraps every LLM call with SELF frame for identity grounding:
for message in conversation:
self_ctx = await system.frame("self") # identity context
attn_ctx = await system.frame("attention", query=message)
response = llm(self_ctx.text + attn_ctx.text + message)
SELF frame has a 1-hour TTL cache (confirmed in context/frames.py: CachePolicy(ttl_seconds=3600)).
What actually happens:
Call 1 (t=0): frame("self") → DB query → [A, B, C, D, E] cached=False → STAGES all pairs
Call 2 (t=1s): frame("self") → CACHE HIT → [A, B, C, D, E] cached=True → STAGES AGAIN (bug!)
Call 3 (t=2s): frame("self") → CACHE HIT → [A, B, C, D, E] cached=True → (A,B) count=3 → PROMOTE!
Result: With threshold=3, the first 3 consecutive calls to frame("self") — happening in literal seconds — promote ALL C(N,2) constitutional block pairs to co_occurs edges via pure cache hits. No genuine independent retrieval occurred. This fires in 3 seconds rather than 3 distinct usage sessions.
This is a critical bug. Cached results carry no new signal — they are the same DB query result served from memory. Staging on a cache hit means a loop calling frame("self") every iteration creates the entire constitutional graph within seconds of the first real retrieval.
Fix: Guard staging with if not result.cached.
Scenario 3: The Burst Session vs. Cross-Session Signal¶
Setup: Two patterns produce identical staging counts:
Pattern A (burst): Same session, 3 calls in 3 seconds
frame("attention", query="risk") → [A, B] → staging (A,B)=1
frame("attention", query="risk") → [A, B] → staging (A,B)=2
frame("attention", query="risk") → [A, B] → PROMOTED
Pattern B (cross-session): 3 separate day-long sessions
Session 1: frame("attention", query="risk") → [A, B] → staging (A,B)=1
Session 2: frame("attention", query="risk") → [A, B] → staging (A,B)=2
Session 3: frame("attention", query="risk") → [A, B] → PROMOTED
Pattern B is strongly meaningful. A and B co-appear across 3 separate agent runs = genuine functional relationship.
Pattern A is noise. Three calls in one session = probably the same task iteration, possibly even the same query. The threshold=3 rationale ("once is coincidence, twice is pattern, three times is signal") assumes independence, which burst patterns violate.
The plan explicitly says: "Cross-session patterns are the most reliable Hebbian signal." But nothing in the current implementation enforces this. A threshold=3 burst session produces the same outcome as threshold=3 cross-session usage.
Fix: Per-session deduplication. Each pair can contribute at most 1 count per begin_session() cycle. This makes the threshold semantically mean "N distinct sessions" not "N calls."
Scenario 4: The Focused Domain Agent¶
Setup: A trading assistant always retrieves the same 8 core knowledge blocks for every query:
{risk_management, position_sizing, correlation, volatility, kelly_criterion,
drawdown, sharpe_ratio, mean_reversion}
Every frame("attention") call returns these 8 blocks (tight domain, specific vocabulary).
Without per-session dedup:
Call 1 (Session 1): C(8,2) = 28 pairs staged, all count=1
Call 2 (Session 1): same 28 pairs staged again, all count=2
Call 3 (Session 1): same 28 pairs → ALL 28 pairs promoted to edges
Result: 28 edges created in one session after 3 calls. The agent's graph becomes a complete subgraph for these 8 nodes. No degree cap enforcement in Phase 1, so each node gets 7 co_retrieval edges immediately.
With per-session dedup (Fix 2): Each pair counts once per session. Three separate sessions needed. The graph grows more slowly but more meaningfully.
Assessment: The fix is essential for domain-focused agents. Without it, highly correlated retrieval creates graph noise rather than signal.
Scenario 5: The Long-Running Agent with Concept Drift¶
Setup: Agent processes Topic A for 100 sessions, then shifts to Topic B. Topic A blocks are rarely retrieved again.
State after 100 Topic A sessions:
staging = {
(block_a1, block_a2): 2, # 2/3 toward promotion — one more session needed
(block_a3, block_a4): 1, # 1/3 toward promotion
... (dozens more)
}
After shift to Topic B:
- Topic A blocks decay → eventually archived by curate()
- Their pairs stay in staging dict forever: {(archived_id, archived_id): count}
- These pairs never promote (archived blocks can't be retrieved)
- Eviction only fires when len(staging) > 1000 — at Phase 1 scale, this may never happen
Zombie entry accumulation: With 500 blocks, 40 pairs per recall, 100 sessions → staging could hold 200+ zombie entries for archived blocks, consuming 20% of the cap while providing no value.
Fix: Curate-time cleanup. When curate() archives blocks, scan staging dict and remove pairs where either block is no longer active. The active block set is already fetched inside curate() — free O(n) intersection.
Scenario 6: The Multi-Frame Loop — Cross-Frame Blind Spot¶
Setup: Agent calls all three frames per loop:
identity = await system.frame("self") # returns constitutional blocks [C1..C10]
goals = await system.frame("task") # returns goal blocks [G1, G2, G3]
context = await system.frame("attention", query=task) # returns knowledge [K1..K5]
What stages:
- frame("self"): pairs among {C1..C10}
- frame("task"): pairs among {G1..G3}
- frame("attention"): pairs among {K1..K5}
What does NOT stage: C1 and K1 — even if they're always both relevant to the agent's reasoning in the same loop iteration, they appear in different frame() calls and are never co-staged.
Assessment: This is a design limitation, not a bug. Cross-frame co-use is a real signal (the agent uses SELF and ATTENTION context together for every prompt) but the current design can't capture it. The _session_block_ids breadcrumb already tracks all blocks seen in a session — a future "session-level Hebbian" enhancement could stage cross-frame pairs.
Current mitigation: Frame templates render separate contexts; the LLM combines them. Cross-frame semantic relationships are still discoverable via consolidation similarity or outcome signals. C1 enriches the graph, not replaces it.
Phase 2 opportunity: After session ends, run staging across session_block_ids pairs. Low priority — consolidation already handles semantic similarity; C1 targets usage-proved functional relationship.
Scenario 7: The Agent That Mostly Uses recall()¶
Setup: Agent uses recall() for quick lookups and only calls frame() for final context injection before LLM calls.
# Exploration phase (no staging)
candidates = await system.recall(query="position sizing concepts")
specific = await system.recall(query="Kelly criterion formula")
# Context injection (staging fires here)
ctx = await system.frame("attention", query="current trade decision")
Result: C1 correctly doesn't stage on recall() calls. The API contract is clean. Only frame() — the "I am about to use this context for real reasoning" call — triggers staging.
Assessment: Works as designed. The distinction between recall() (exploration, no side effects) and frame() (context injection, learning side effects) is meaningful and correctly implemented.
Scenario 8: The Fresh Start — Staging Lost on Restart¶
Setup: Agent runs for 2 sessions. Pair (A,B) reaches count=2 (threshold=3). Process restarts.
Session 1: (A,B) staged, count=1
Session 2: (A,B) staged, count=2
[RESTART]
Session 3: staging = {} — count=0 again
Session 4: count=1
Session 5: count=2
Session 6: count=3 → PROMOTED — but it took 6 sessions instead of 3
Assessment: Acceptable for Phase 1. The plan explicitly acknowledges this. The longer it takes, the more confident the eventual edge. "Patterns that survive restarts reform quickly in active usage" — true for genuinely important relationships.
When this matters: Short-lived agents (serverless, per-request spawning). For these agents, staging never accumulates and C1 never fires. The design implicitly assumes medium-to-long-lived processes. This should be documented.
Scenario 9: The SELF Frame — Constitutional Noise¶
Setup: Agent calls frame("self") once per conversation turn for identity grounding.
The SELF frame guarantees blocks tagged self/constitutional always appear (via _enforce_guarantees()). If 8 constitutional blocks exist, they appear in EVERY frame("self") result, regardless of query.
Combined with Scenario 2 fix (no staging on cache hit):
The fix makes this scenario safer. Without the fix, the 8 constitutional blocks would form a complete subgraph (28 edges) after 3 frame("self") calls in seconds. With the fix, they form it after 3 distinct sessions.
Is this desirable? The constitutional blocks ARE always co-used — they collectively define identity. So these edges are semantically correct. They just shouldn't form immediately.
Remaining concern: With per-session dedup, all 28 constitutional pairs reach threshold in exactly N_threshold sessions. Every session forms 28 new staging counts at once. This front-loads the constitutional graph creation.
Mitigation: The edges form correctly, just faster than average pairs (because constitutional blocks appear in 100% of SELF retrievals). They'll also be reinforced by reinforce_co_retrieved_edges() after creation, growing their reinforcement_count quickly — giving them C2 LTD protection (λ halved). Constitutional blocks forming a dense, durable subgraph is correct behavior.
Scenario 10: Observability — The Silent Promotion¶
Setup: Agent wants to know when its graph changes due to its own behavior.
Current state:
result = await system.frame("attention", query="risk management")
# Did this just promote an edge? Unknown. Agent can't tell.
status = await system.status()
# staging_count changed from 3 to 0? Agent might notice if polling.
# But no per-call signal.
The gap: Graph promotions are silent. The agent can infer a promotion happened by watching status().co_retrieval_staging_count drop, but this requires polling and comparison, which is awkward in an event-driven loop.
Why it matters: An agent that knows edge promotion just happened can:
- Log it for introspection
- Trigger a recall() with expand=True to immediately benefit from the new edge
- Record the graph evolution as a learning event
Fix: Add edges_promoted: int = 0 to FrameResult. Zero most of the time; non-zero when staging crosses threshold. No polling needed.
Issues Summary¶
| # | Severity | Issue | Root Cause |
|---|---|---|---|
| 1 | Critical bug | Cached SELF frame triggers staging | result.cached not checked before staging |
| 2 | Semantic flaw | Burst session = cross-session (threshold doesn't enforce independence) | No per-session dedup in staging |
| 3 | Operational | Zombie staging entries from archived blocks | Staging dict never cleaned up without cap eviction |
| 4 | Observability | Agent can't see per-call promotion signal | FrameResult has no edges_promoted field |
Fixes Implemented¶
Fix 1: Cache-Aware Staging (Bug Fix)¶
In api.py.frame():
# Before (bug):
if recalled_ids:
await stage_and_promote_co_retrievals(...)
# After:
if recalled_ids and not result.cached: # cache hits carry no new signal
await stage_and_promote_co_retrievals(...)
Fix 2: Per-Session Deduplication¶
Add self._co_retrieval_session_seen: set[tuple[str, str]] = set() to MemorySystem.__init__().
Clear in begin_session().
Pass to stage_and_promote_co_retrievals() as session_seen parameter.
Each pair contributes at most 1 count per session. Threshold becomes "N distinct sessions" semantically.
Behavioral change: Tests using threshold=2 now require 2 begin_session() cycles. This is the correct semantics.
Fix 3: Zombie Cleanup in curate()¶
In api.py.curate(), after _curate() returns: scan staging dict, remove pairs where either block_id is not in the active set.
# After _curate():
if self._co_retrieval_staging:
async with self._engine.connect() as conn:
active = await get_active_blocks(conn)
active_ids = {b["id"] for b in active}
self._co_retrieval_staging = {
pair: count
for pair, count in self._co_retrieval_staging.items()
if pair[0] in active_ids and pair[1] in active_ids
}
Fix 4: FrameResult.edges_promoted¶
Add edges_promoted: int = 0 to FrameResult dataclass.
Capture promotion count from stage_and_promote_co_retrievals() return value.
Set on result before returning from frame().
promoted_count = 0
if recalled_ids and not result.cached:
promoted_count = await stage_and_promote_co_retrievals(...)
result.edges_promoted = promoted_count
Design Insights¶
What C1 does well¶
- Zero agent ceremony — Hebbian learning is invisible infrastructure
- API contract is clean:
frame()= learning side effects,recall()= pure - The
co_retrieval_staging_countinstatus()gives non-zero signal when learning is active co_occursedges in_EVICTION_ORDER— displaced by semantic agent edges, correct priority- C2 temporal decay self-corrects burst-promoted edges (they weaken without continued use)
What C1 cannot do (by design, not bugs)¶
- Cross-frame staging (SELF + ATTENTION co-used blocks)
- Persistent staging across process restarts (Phase 1 limitation)
- Weighted staging (pair co-retrieved 5 times in one session counts same as 5 separate sessions, post-fix)
Design tension: Immediate vs. Deferred Promotion¶
The plan chose immediate promotion (at recall time, not at curate time) for real-time graph availability. This is correct for agent-first design — the edge is available on the very next frame() call.
The cost: promotion is transactional overhead on every frame(). At top_k=20 and 190 pairs, this is bounded and acceptable at Phase 1 scale.