Skip to content

Commit 10401b3

Browse files
authored
Merge pull request #22 from Marsu6996/fix/consolidation-recall-correctness
fix(memory): consolidation & recall correctness — live crisis graph, score clamp, reflection embedding, crash recovery
2 parents fb93ace + 6c92442 commit 10401b3

20 files changed

Lines changed: 850 additions & 108 deletions

src/iai_mcp/capture.py

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -317,32 +317,53 @@ def capture_turn(
317317
from iai_mcp.embed import embedder_for_store
318318
from iai_mcp.events import TELEMETRY_EMBED_NATIVE_FAILURE, write_event
319319

320-
try:
321-
# Embed the message content, never the cue. The cue is a provenance
322-
# label only (transcript drains and deferred-drain pass a positional
323-
# cue such as "session <id> turn <n>"); embedding it collapsed the
324-
# stored vector space and broke semantic recall. text is already
325-
# validated non-empty above (the MIN_CAPTURE_LEN guard), so embedding
326-
# text is safe for every caller.
327-
emb = embedder_for_store(store).embed(text)
328-
except Exception as exc:
329-
write_event(
330-
store,
331-
TELEMETRY_EMBED_NATIVE_FAILURE,
332-
{
333-
"op_type": "capture",
334-
"backend": "rust",
335-
"error_type": type(exc).__name__,
336-
"error": str(exc),
337-
},
338-
)
339-
raise NativeError(f"capture encode failed: {exc}") from exc
340-
embedding = list(emb)
320+
# Embedding is the expensive native (Rust) matmul; the exact-key idem dedup
321+
# below is a cheap SQLite lookup. Active sessions re-drain the *whole*
322+
# transcript every turn (write_deferred_captures / capture_transcript walk
323+
# from line 0 on each call), so embedding eagerly here re-embedded every
324+
# already-stored turn only to throw the vector away as a "reinforced"
325+
# exact-key re-drain -> chronic daemon CPU. Defer the embed so it runs at
326+
# most once and only when actually needed: an already-seen episodic turn
327+
# short-circuits on its idem tag and never embeds. Embed the message
328+
# content, never the cue (the cue is a positional provenance label;
329+
# embedding it collapsed the vector space and broke semantic recall). text
330+
# is validated non-empty above (the MIN_CAPTURE_LEN guard).
331+
_embed_cache: dict[str, list[float]] = {}
332+
333+
def _compute_embedding() -> list[float]:
334+
if "v" in _embed_cache:
335+
return _embed_cache["v"]
336+
try:
337+
emb = embedder_for_store(store).embed(text)
338+
except Exception as exc:
339+
write_event(
340+
store,
341+
TELEMETRY_EMBED_NATIVE_FAILURE,
342+
{
343+
"op_type": "capture",
344+
"backend": "rust",
345+
"error_type": type(exc).__name__,
346+
"error": str(exc),
347+
},
348+
)
349+
raise NativeError(f"capture encode failed: {exc}") from exc
350+
vec = list(emb)
351+
_embed_cache["v"] = vec
352+
return vec
341353

342354
with _CAPTURE_DEDUP_LOCK:
343355
if _is_episodic_conversational(tier, role):
344356
ts_iso = now.isoformat()
345357
idem_t = _idem_tag(session_id, role, ts_iso, text, source_uuid=source_uuid)
358+
# find_record_by_tag reads SQLite, but a just-inserted record may
359+
# still sit in the in-process _record_buffer (not yet flushed to the
360+
# records table). Under _CAPTURE_DEDUP_LOCK the insert path is
361+
# serialized with this find, so flushing the buffer here makes every
362+
# prior committed insert visible to the SQLite-backed find and closes
363+
# the check-then-insert race that produced live idem-tag duplicates.
364+
from iai_mcp.store import flush_record_buffer
365+
366+
flush_record_buffer(store)
346367
existing_id = store.find_record_by_tag(idem_t)
347368
if existing_id is not None:
348369
try:
@@ -362,7 +383,7 @@ def capture_turn(
362383
}
363384
else:
364385
try:
365-
neighbours = store.query_similar(embedding, k=3, tier=tier)
386+
neighbours = store.query_similar(_compute_embedding(), k=3, tier=tier)
366387
except (ValueError, IOError) as exc:
367388
log.warning(
368389
"capture_dedup_query_failed",
@@ -402,7 +423,7 @@ def capture_turn(
402423
tier=tier,
403424
literal_surface=text,
404425
aaak_index="",
405-
embedding=embedding,
426+
embedding=_compute_embedding(),
406427
community_id=None,
407428
centrality=0.0,
408429
detail_level=2,

src/iai_mcp/core/_serializers.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@
66
def _hit_to_json(h) -> dict:
77
_vf = getattr(h, "valid_from", None)
88
_vt = getattr(h, "valid_to", None)
9+
# Clamp the *displayed* score to [0,1]. Multiplicative boosts (trigram*2,
10+
# FTS*3, valence) can drive the internal score past 1.0; that raw value stays
11+
# in `sort_score` for ordering, but the client must never see a "confidence"
12+
# > 1 (or < 0). Ordering is unaffected: this only touches the serialized
13+
# number, never the rank.
14+
try:
15+
_display_score = max(0.0, min(1.0, float(h.score)))
16+
except (TypeError, ValueError):
17+
_display_score = 0.0
918
return {
1019
"record_id": str(h.record_id),
11-
"score": float(h.score),
20+
"score": _display_score,
1221
"reason": h.reason,
1322
"literal_surface": h.literal_surface,
1423
"adjacent_suggestions": [str(x) for x in h.adjacent_suggestions],

src/iai_mcp/daemon/__init__.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
DAEMON_SLEEP_CYCLE_STALE,
2929
DAEMON_WATCHDOG_NEEDS_OPERATOR,
3030
DAEMON_WEDGE_KILL,
31+
emit_best_effort,
3132
write_event,
3233
)
3334
from iai_mcp.identity_audit import continuous_audit
@@ -282,9 +283,47 @@ def _store_is_empty(store: MemoryStore) -> bool:
282283
# the unknown case as NOT empty so the tick proceeds; a truly empty store
283284
# just does a little harmless no-op work.
284285
log.debug("store empty check failed, assuming NOT empty: %s", exc)
286+
# e8f3deb fixed the *behavior* (don't park the tick) but left the
287+
# condition invisible. A recurring count failure (sqlite left in an error
288+
# state by a heavy reader) should surface to the operator, not just
289+
# log.debug. emit_best_effort is buffered and never raises, so it is safe
290+
# even when the store connection is the thing failing.
291+
try:
292+
emit_best_effort(
293+
store,
294+
"store_empty_check_failed",
295+
{"error": str(exc), "error_type": type(exc).__name__},
296+
severity="warning",
297+
)
298+
except Exception: # noqa: BLE001 -- telemetry must never break the tick
299+
pass
285300
return False
286301

287302

303+
def _normalize_boot_lifecycle_state(raw: dict) -> tuple[dict, bool]:
304+
"""Repair a crash-left incoherent lifecycle state at boot.
305+
306+
A daemon killed mid-SLEEP can leave lifecycle_state.json at
307+
current_state=SLEEP with sleep_cycle_progress=None -- incoherent, because a
308+
real in-flight sleep cycle always carries a progress dict. Resuming it wedges
309+
the daemon (it never advances the pipeline, never reaches the recluster that
310+
clears crisis, and recall stays degraded). Reset that one case to a clean
311+
WAKE and drop the stale crisis flag. A genuine degeneration re-arms crisis on
312+
the next complete sleep cycle. Returns (state, changed).
313+
"""
314+
if (
315+
isinstance(raw, dict)
316+
and raw.get("current_state") == "SLEEP"
317+
and raw.get("sleep_cycle_progress") is None
318+
):
319+
out = dict(raw)
320+
out["current_state"] = "WAKE"
321+
out["crisis_mode"] = False
322+
out["crisis_mode_since_ts"] = None
323+
return out, True
324+
return raw, False
325+
326+
288327
def _is_inside_window(
289328
window: tuple[int, int] | list | None,
290329
now: datetime,
@@ -1075,6 +1114,38 @@ async def _drain_and_report() -> None:
10751114
_sleep_pipeline = _SleepPipeline(store=store)
10761115

10771116
from pathlib import Path as _PathS2
1117+
# Boot normalization for a crash mid-SLEEP: lifecycle_state.json can be
1118+
# left at current_state=SLEEP with sleep_cycle_progress=None -- an
1119+
# incoherent state (a real in-flight cycle always carries a progress
1120+
# dict). Resuming it wedges the daemon: it never advances the sleep
1121+
# pipeline, never reaches the recluster that clears crisis, and recall
1122+
# stays degraded (SLEEP + crisis both degrade recall). Reset that one
1123+
# case to a clean WAKE (and drop the stale crisis flag set before the
1124+
# crash) so the daemon serves immediately; a genuine degeneration will
1125+
# simply re-arm crisis on the next complete sleep cycle.
1126+
try:
1127+
import json as _json_lc
1128+
_lc_path = _PathS2.home() / ".iai-mcp" / "lifecycle_state.json"
1129+
_lc_raw = _json_lc.loads(_lc_path.read_text())
1130+
_lc_norm, _lc_changed = _normalize_boot_lifecycle_state(_lc_raw)
1131+
if _lc_changed:
1132+
_lc_path.write_text(_json_lc.dumps(_lc_norm, indent=2))
1133+
log.warning(
1134+
"lifecycle_boot_normalized: stale SLEEP without "
1135+
"sleep_cycle_progress -> WAKE (crisis cleared)"
1136+
)
1137+
try:
1138+
emit_best_effort(
1139+
store,
1140+
"lifecycle_boot_normalized",
1141+
{"from_state": "SLEEP", "to_state": "WAKE",
1142+
"reason": "sleep_without_progress"},
1143+
severity="warning",
1144+
)
1145+
except Exception: # noqa: BLE001 -- telemetry must not block boot
1146+
pass
1147+
except (OSError, ValueError) as _lc_exc:
1148+
log.debug("lifecycle boot normalization skipped: %s", _lc_exc)
10781149
_s2_config = _load_s2_config()
10791150
_s2_coord = S2Coordinator(
10801151
store=store,

src/iai_mcp/daemon/_watchdog.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,11 @@ def _check_sleep_cycle_staleness(
545545
if not isinstance(progress, dict):
546546
return (False, {})
547547
attempt = progress.get("attempt")
548-
if not isinstance(attempt, int) or attempt != 1:
548+
# A retried-but-still-wedged cycle (attempt >= 2) is exactly the case the
549+
# watchdog must catch, not ignore. Gate on attempt < 1 so any genuine
550+
# running attempt is monitored; only attempt 0 / negative / non-int (and
551+
# bool, since isinstance(True, int) is True) short-circuits.
552+
if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 1:
549553
return (False, {})
550554
started_at_raw = progress.get("started_at")
551555
if not isinstance(started_at_raw, str) or not started_at_raw:

src/iai_mcp/dmn_reflection.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,30 @@ def synthesize(self, store, window_hours: int) -> MemoryRecord:
9494
"ts": now.isoformat(),
9595
}
9696

97+
# Embed the reflection's literal_surface so the record is retrievable by
98+
# vector/cosine recall. Hardcoding a zero vector (the old behaviour) made
99+
# every daily-reflection semantic record permanently invisible to recall
100+
# and fed zero-norm vectors into the scoring matmul (divide-by-zero
101+
# warnings). If the native embed fails, fall back to a zero vector flagged
102+
# embedding_pending=1 so the daemon's reembed-pending path fills it later
103+
# (never silently leave an unretrievable zero record).
97104
embed_dim = int(store.embed_dim)
98-
embedding = [0.0] * embed_dim
105+
embedding_pending = 0
106+
try:
107+
from iai_mcp.embed import embedder_for_store
108+
109+
embedding = list(embedder_for_store(store).embed(literal_surface))
110+
except Exception: # noqa: BLE001 -- degrade to deferred reembed, never zero-and-forget
111+
embedding = [0.0] * embed_dim
112+
embedding_pending = 1
99113

100114
return MemoryRecord(
101115
id=uuid4(),
102116
tier="semantic",
103117
literal_surface=literal_surface,
104118
aaak_index="",
105119
embedding=embedding,
120+
embedding_pending=embedding_pending,
106121
community_id=None,
107122
centrality=0.5,
108123
detail_level=1,

src/iai_mcp/hippo/_db.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,15 @@ def reembed_pending_rows(self, embedder: Any) -> int:
616616
for row in rows:
617617
rid = row["id"]
618618
surface = row["literal_surface"] or ""
619+
# On an encrypted store literal_surface is iai:enc:v1: ciphertext; embedding
620+
# the ciphertext would produce a garbage vector. Decrypt first (no-op on a
621+
# plaintext store or a value that isn't encrypted). A decrypt failure leaves
622+
# the row embedding_pending=1 so it is retried rather than poisoned.
623+
try:
624+
surface = self._decrypt_record_field(rid, "literal_surface", surface)
625+
except Exception as exc: # noqa: BLE001
626+
_log.warning("reembed_pending_rows: decrypt failed for id=%s: %s", rid, exc)
627+
continue
619628
try:
620629
vec = list(embedder.embed(surface))
621630
except Exception as exc: # noqa: BLE001

src/iai_mcp/lilli/cycle/sleep_pipeline/_crisis.py

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -66,49 +66,19 @@ def step_crisis_recluster(
6666

6767
if not dry_run:
6868
tbl = self._store.db.open_table(RECORDS_TABLE)
69-
try:
70-
df2 = tbl.search().to_pandas()
71-
except (OSError, ValueError, RuntimeError, StoreError):
72-
df2 = df
73-
7469
try:
7570
from iai_mcp.community import detect_communities
76-
from iai_mcp.graph import MemoryGraph
77-
from iai_mcp.store import EDGES_TABLE
71+
from iai_mcp.lilli.cycle.sleep_pipeline._live_graph import (
72+
build_live_graph,
73+
)
7874
import uuid as _uuid
7975

80-
g = MemoryGraph()
81-
for _, row in df2.iterrows():
82-
try:
83-
rid = _uuid.UUID(str(row["id"]))
84-
emb = row.get("embedding")
85-
emb_list = (
86-
list(emb) if emb is not None else []
87-
)
88-
g.add_node(rid, None, emb_list)
89-
except (ValueError, TypeError, AttributeError):
90-
continue
91-
92-
try:
93-
edges_df = (
94-
self._store.db.open_table(EDGES_TABLE)
95-
.search()
96-
.to_pandas()
97-
)
98-
for _, e in edges_df.iterrows():
99-
try:
100-
src_u = _uuid.UUID(str(e["src"]))
101-
dst_u = _uuid.UUID(str(e["dst"]))
102-
g.add_edge(
103-
src_u, dst_u,
104-
weight=float(
105-
e.get("weight", 1.0) or 1.0
106-
),
107-
)
108-
except (ValueError, TypeError, KeyError):
109-
continue
110-
except (OSError, ValueError, RuntimeError, StoreError) as exc:
111-
logger.debug("crisis_recluster edges query failed: %s", exc)
76+
# Recluster on the LIVE graph only (tombstone-filtered,
77+
# live-only edges) -- mirrors retrieve.py 53f04f9. Previously
78+
# this rebuilt communities over ALL records incl. 3000+
79+
# tombstoned, collapsing the partition (it reassigned ~9700
80+
# records into a single community on the real store).
81+
g = build_live_graph(self._store)
11282

11383
_assignment = detect_communities(
11484
g, prior=None, prior_mode="cold"

0 commit comments

Comments
 (0)