Skip to content

fix(memory): loud signal when semantic recall degrades to non-semantic - #2015

Merged
joelteply merged 1 commit into
canaryfrom
fix/recall-loud-degrade
Jul 26, 2026
Merged

fix(memory): loud signal when semantic recall degrades to non-semantic#2015
joelteply merged 1 commit into
canaryfrom
fix/recall-loud-degrade

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

multi_layer_recall silently degrades to non-semantic order when there's no neural query embedding or no memory vectors — looks like working recall but ranking is meaningless (the trap the agent-memory bridge hit). Now logs loud (embedder id + vector count) so it can't hide. Diagnostic only; the neural-embedder wiring is the follow-up (model GGUF already on disk).

🤖 Generated with Claude Code

…c (stop it hiding)

multi_layer_recall silently falls back to non-semantic order when the query embedding
is absent/lexical or no memory carries a vector — the semantic layer just returns []
and the importance/recency/keyword layers survive, ignoring the query text. That LOOKS
like working recall (returns memories) but the ranking is meaningless — the exact trap
the agent-memory bridge hit tonight (off-topic results for every query).

Now: when a query was given but the semantic layer couldn't contribute, log LOUD —
embedder id, memories-with-vectors count, whether the query embedded — so this class of
degradation is visible, not silent ([[reliability-is-it-works-not-that-it-reports-failure-well]]).

Diagnostic only (no behavior change). The actual fix — wire the corpus manager to the
neural embedder (currently hardcoded lexical, main.rs:343) + populate memory vectors — is
the follow-up; the qwen3-embedding-0.6b GGUF is already on disk so it's tractable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply
joelteply enabled auto-merge (squash) July 26, 2026 02:40
@joelteply
joelteply merged commit 94269fa into canary Jul 26, 2026
@joelteply
joelteply deleted the fix/recall-loud-degrade branch July 26, 2026 02:40
joelteply added a commit that referenced this pull request Jul 26, 2026
…embedder + governed VRAM + persistent cache + coma fix) (#2016)

* fix(memory): global recall embedder resolves neural lazily + on-demand candidate-vector backfill — semantic recall for the agent-memory bridge

The agent-memory bridge (memory/remember, memory/recall-hook) recalls through the
GLOBAL PersonaMemoryManager, which main.rs constructed with the hardcoded LEXICAL
bootstrap embedder — deliberately, because NOTHING may gate the IPC socket bind on a
GPU/gateway probe (concurrency guide). The per-persona path resolved neural on spawn;
the global/bridge path never did. Result, dogfooded live: recall returned the SAME
off-topic memories for every query — non-semantic order that ignores the query text.

Two gaps, both closed here, reusing the existing resolver + storage (no new machinery):

1. LazyRecallEmbedder (cognition/embedding.rs) — resolves the dedicated in-process
   Qwen3-Embedding-0.6B on the FIRST recall via new resolve_recall_embedder_local()
   (paths: in-process GGUF → lexical floor; no chat adapter, which the global manager
   has none of), caches it process-stably, delegates. Zero boot-path cost — the probe +
   calibration is paid once on first real recall, never at socket-bind. This is the
   "separate addressing follow-up" main.rs's old comment promised.

2. ensure_memory_embeddings (memory/mod.rs) — agent memories are written with
   embedding: None, so corpus.memories_with_embeddings() was empty and SemanticRecallLayer
   no-op'd even with a neural query vector. multi_layer_recall now backfills missing
   candidate vectors on demand (async, cached, outside any lock) before the sync recall,
   so the semantic layer has something to rank. First recall per persona pays it; content
   is immutable so vectors never go stale; idempotent once populated.

Tests: ensure_memory_embeddings_backfills_missing_vectors (backfill + idempotence),
lazy_recall_embedder_is_boot_safe_before_resolution (no probe until first embed). Existing
memory module tests unperturbed (36/36). Complements the loud-degrade signal (b413aaa):
that made the degrade visible; this removes it on the bridge path when the embedder serves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* fix(memory): bound + fail-fast the candidate-embedding backfill so a down embedder can't hang the SessionStart recall

The on-demand backfill (prior commit) embedded EVERY missing candidate synchronously
inside multi_layer_recall. Two hazards that surfaced dogfooding live against a 375-memory
corpus with the in-process Qwen3 embedder returning degenerate (zero) vectors under GPU-OOM:

1. Down embedder → the backfill grinds all N candidates through a failing GPU forward
   pass, hanging the recall (the recall-hook command timed out). Now: FAIL_FAST — if the
   first few embeds all come back empty, the embedder is down; bail immediately. The
   caller's loud-degrade signal still reports the resulting non-semantic recall.
2. Working embedder but large cold corpus → one recall blocked on hundreds of embeds.
   Now: MAX_PER_CALL=64 caps per-recall work; a big corpus embeds incrementally across
   several recalls (amortized). Content is immutable, so partial progress never goes stale.

Test: ensure_memory_embeddings_fails_fast_when_embedder_down (counts embed calls, asserts
the backfill bails after ~3 empties instead of attempting all 20). Existing backfill +
idempotence test still green.

Note (separate, not this commit): the underlying degenerate embeddings are the in-process
Qwen3-Embedding-0.6B GGUF returning all-zero vectors on real content — the embed backend
(backends/llamacpp.rs) is correct (fresh embedding-mode context, KV cleared per text,
PoolingType::Last, 2048 clamp), so the zeros come from the Metal forward pass failing under
VRAM pressure (the boot-time GPU-OOM, #175 family). Recall goes fully semantic once the
embedder yields real vectors; the wiring + backfill are proven live via the
`recall.embedder.resolved source=in-process-llamacpp-global` probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* fix(inference): govern the embedding VRAM lane — lease before allocating, fail loud instead of decoding to degenerate zeros

The embedding forward pass allocated a ~1.5 GiB Metal context (2048-ctx compute +
KV) UNGOVERNED — grabbing VRAM behind the ResourceGovernor's back and competing with
the serving lane. Under pressure the Metal command buffer OOM'd and the decode returned
an ALL-ZERO vector (the "degenerate embedding" that silently broke semantic recall for
the agent-memory bridge). It's the last blocker on the recall-wiring fix.

Make the embed lane a governed GPU consumer, mirroring cognition/eval.rs::acquire_eval_lane_slot
exactly (no parallel allocator — concurrency guide #6): create_embedding now acquires a
Pinned VRAM LeaseRequest from ResourceDaemon::global() before the spawn_blocking forward
pass, holding the RAII LeaseGuard for the duration (freed on drop). Granted ⇒ the bytes
are reserved so the decode has room to succeed; InsufficientCapacity ⇒ fail LOUD with a
named error (probe embed.vram.refused) instead of allocating a doomed context that emits
garbage. Embeddings are GPU-only ([[gpu-is-non-negotiable...no-cpu-fallback]]) so there is
NO CPU spill like eval — the honest degrade is a refusal the caller surfaces as "no signal"
(and the recall backfill's fail-fast handles cleanly), never a zero vector. Ungoverned node
(no daemon) = behavior unchanged. Probes: embed.vram.leased / embed.vram.refused.

Const, in-code policy (not env). Scope = per-call CONTEXT admission control (the OOM fix);
registering the embed model's resident WEIGHTS as a ResourceConsumer (footprint/reclaim,
mirroring ServingConsumer) is the residency-accounting follow-up. This is step 1 of the
GPU-accelerated / grid-command / cache-layer embedding arc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* feat(memory): persistent embedding cache — embed once EVER, warmed on boot for every persona + agent

The process-global embedding cache (global_embedding_cache) was in-memory only, so it
died on every restart and all content re-embedded — which, with the governed embed lane,
means re-fighting the serving lane for VRAM (155 lease refusals observed on a warm 375-memory
corpus). Persist it.

EmbeddingCache gains snapshot_to/load_from: a compact, dep-free, atomic binary snapshot
([u64 count] then per entry [u64 key][u32 dim][dim × f32], all LE; write-temp-then-rename).
spawn_embedding_cache_persistence warms the cache from the snapshot at boot, then snapshots
every 60s on its OWN tokio task (RTOS shape: own task + tokio::time::interval, file write on
spawn_blocking, off every hot path — concurrency guide). Best-effort throughout: a lost
snapshot just re-embeds; probes embedding.cache.loaded / embedding.cache.flushed.

Wired once at boot (main.rs) against ~/.continuum/cache/embedding-cache.bin. Because this is
the ONE cache every persona AND agent shares — their recall embedders all wrap
global_embedding_cache via CachingEmbeddingProvider — a single warm-load brings back the
whole citizenry's vectors at once: each unique content embeds once, EVER. Steady state → the
VRAM refusals collapse to near-zero and cold-start recall is instant.

Step 2 of the GPU/grid/cache embedding arc (after the governed lane). Next: grid-shared
backing + ai/embedding/generate command. Test: embedding_cache_snapshot_round_trips
(byte-identical restore, missing file = Ok(0)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* fix(cognition): dream inference must acquire a serving lane + rest between review passes — stop it starving reactive responding (personas looked comatose)

SEVERE live failure (2026-07-26): 4 resident personas dreamed near-CONTINUOUSLY
(dream_consolidation ran 00:00→05:03) and did NOT respond to direct chat — alive but
comatose. Root cause (mapped, not guessed):

1. The dream's model call (distill_reviewing) called `self.adapter.generate_text` DIRECTLY,
   bypassing `acquire_serving_lane`. The turn path acquires a lane and the #139 reservation
   always holds one lane for a DIRECTED chat turn — but the dream slipped past it entirely,
   grabbing a physical llama decode lane uncounted. The semaphore then handed the chat turn a
   permit while the physical lane was busy dreaming, so the directed reply queued INSIDE llama
   behind the dream. Delivery was fine (Benchy DID receive the message + ran recall of 8
   memories); the turn's model call was starved. FIX: wrap the dream's generate_text in
   `acquire_serving_lane(false)` (non-directed) — caps all dream inference at MAX_LANES-1 and
   guarantees a waiting directed turn wins the lane. The load-bearing responsiveness fix.

2. The quiet-day review-only pass had NO cooldown: it drains REVIEW_ONLY_BATCH (6) beliefs,
   emits CadenceHint::Sleep, but the governor re-ticks every ~30s and immediately reviews the
   next 6 — forever, for a large belief store (Atlas: 4,336 engrams), compounded by the
   in-memory `reviewed` set resetting on each dev restart. FIX: REVIEW_ONLY_COOLDOWN_MS (5min)
   per-persona rest gate in try_review_only — belief hygiene trickles instead of grinding.
   Consolidation of FRESH experience stays ungated (real learning is never throttled).

Both preserve the organism (the fade/consolidation still runs) while freeing the lane so the
being can respond and act. 14 dream_consolidation tests stay green (quiet-day contract holds:
immediate re-tick still sleeps). Relates to #139, #221, the acting-organism arc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant