superlocalmemory 4.0.7 → 4.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +219 -1
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/scripts/ensure-venv.sh +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +119 -9
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +5 -5
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/cli/summary_cmd.py +23 -3
  37. package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
  38. package/src/superlocalmemory/core/config.py +41 -7
  39. package/src/superlocalmemory/core/consolidation_engine.py +14 -15
  40. package/src/superlocalmemory/core/context_cache.py +0 -2
  41. package/src/superlocalmemory/core/engine.py +371 -63
  42. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  43. package/src/superlocalmemory/core/install_detector.py +131 -0
  44. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  45. package/src/superlocalmemory/core/recall_worker.py +4 -0
  46. package/src/superlocalmemory/core/security_primitives.py +3 -6
  47. package/src/superlocalmemory/core/store_pipeline.py +94 -26
  48. package/src/superlocalmemory/core/topic_signature.py +0 -2
  49. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  50. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  51. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  52. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  53. package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
  54. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  55. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  56. package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
  57. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  58. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  59. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  60. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  61. package/src/superlocalmemory/infra/backup.py +44 -8
  62. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  63. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  64. package/src/superlocalmemory/learning/bandit.py +0 -2
  65. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  66. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  67. package/src/superlocalmemory/learning/ensemble.py +0 -2
  68. package/src/superlocalmemory/learning/labeler.py +0 -2
  69. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  70. package/src/superlocalmemory/learning/model_cache.py +0 -2
  71. package/src/superlocalmemory/learning/pattern_miner.py +12 -7
  72. package/src/superlocalmemory/learning/ranker.py +0 -2
  73. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  74. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  75. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  76. package/src/superlocalmemory/math/fisher.py +1 -1
  77. package/src/superlocalmemory/math/hopfield.py +4 -1
  78. package/src/superlocalmemory/math/langevin.py +1 -1
  79. package/src/superlocalmemory/math/sheaf.py +7 -3
  80. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  81. package/src/superlocalmemory/mcp/profiles.py +11 -4
  82. package/src/superlocalmemory/mcp/server.py +8 -1
  83. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  84. package/src/superlocalmemory/mcp/tools_core.py +1 -1
  85. package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
  86. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  87. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  88. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  89. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  90. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  92. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  93. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  94. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
  96. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  97. package/src/superlocalmemory/retrieval/engine.py +272 -43
  98. package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
  99. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  100. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  101. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  102. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  103. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  105. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  106. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  107. package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
  108. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  109. package/src/superlocalmemory/retrieval/vector_store.py +88 -10
  110. package/src/superlocalmemory/server/consolidation_runner.py +140 -0
  111. package/src/superlocalmemory/server/recall_serializer.py +44 -2
  112. package/src/superlocalmemory/server/routes/agents.py +52 -8
  113. package/src/superlocalmemory/server/routes/brain.py +110 -2
  114. package/src/superlocalmemory/server/routes/memories.py +153 -0
  115. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  116. package/src/superlocalmemory/server/routes/v3_api.py +24 -46
  117. package/src/superlocalmemory/server/unified_daemon.py +566 -7
  118. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  119. package/src/superlocalmemory/storage/backup.py +531 -0
  120. package/src/superlocalmemory/storage/database.py +11 -4
  121. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  122. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +142 -2
  124. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  125. package/src/superlocalmemory/storage/migrations.py +15 -1
  126. package/src/superlocalmemory/storage/models.py +7 -0
  127. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  128. package/src/superlocalmemory/summaries/base.py +159 -0
  129. package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
  130. package/src/superlocalmemory/summaries/project_work_log.py +23 -7
  131. package/src/superlocalmemory/summaries/session_summary.py +10 -6
  132. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  133. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  134. package/src/superlocalmemory/ui/index.html +9 -3
  135. package/src/superlocalmemory/ui/js/core.js +1 -1
  136. package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
  137. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  138. package/src/superlocalmemory/ui/js/od-memories.js +337 -12
  139. package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
  140. package/src/superlocalmemory/ui/js/od-operations.js +1 -150
  141. package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
  142. package/src/superlocalmemory/ui/js/od-shell.js +10 -0
@@ -411,7 +411,7 @@ class CompressRouter:
411
411
  logger.debug("CCR update_compressed failed (non-fatal): %s", exc)
412
412
 
413
413
  def _ccr_delete(self, ccr_id: str) -> None:
414
- """WP-10 D6: Defensive delete — idempotent, never raises.
414
+ """Defensive delete — idempotent, never raises.
415
415
 
416
416
  Used to clean up a CCR row if post-store processing fails. In the
417
417
  store-after-success D6 path this should never be needed (no orphans
@@ -123,10 +123,10 @@ _OPENAI_FORWARD_HEADERS = frozenset([
123
123
  _GEMINI_NATIVE_FORWARD_HEADERS = frozenset([
124
124
  "x-goog-api-key",
125
125
  "content-type",
126
- "authorization", # WP-11a: Antigravity ADC/OAuth bearer was dropped; add it back
126
+ "authorization", # Antigravity ADC/OAuth bearer was previously dropped; explicitly add it back
127
127
  ])
128
128
 
129
- # WP-11: Vertex AI forward headers — Authorization passed untouched (AC-2).
129
+ # Vertex AI forward headers — Authorization is passed through untouched.
130
130
  # x-goog-user-project required for quota attribution on Vertex calls.
131
131
  _VERTEX_FORWARD_HEADERS = frozenset([
132
132
  "authorization",
@@ -150,7 +150,7 @@ def build_proxy_router(proxy: ProxyApp) -> APIRouter:
150
150
  async def gemini_openai_models_route(request: Request) -> Response:
151
151
  return await handle_gemini_openai_compat(proxy, request)
152
152
 
153
- # WP-11: Vertex AI passthrough — must be registered AFTER exact /v1/* routes
153
+ # Vertex AI passthrough — must be registered AFTER exact /v1/* routes
154
154
  # to avoid shadowing /v1/messages, /v1/chat/completions, /v1/embeddings, etc.
155
155
  # FastAPI resolves routes in registration order; the exact routes above are
156
156
  # declared before this catch-path, so there is no shadowing.
@@ -1,4 +1,4 @@
1
- """vertex_surface.py — Vertex AI passthrough proxy surface (WP-11).
1
+ """vertex_surface.py — Vertex AI passthrough proxy surface.
2
2
 
3
3
  Transparent passthrough: forward Authorization bearer untouched (AC-2),
4
4
  cache by body content never by token (SEC), no SSE — always single JSON.
@@ -14,7 +14,7 @@ Key design decisions (per LLD §5 STAGE-5 RESOLUTIONS):
14
14
  SECURITY (AC-3): bearer token structurally excluded from cache key, value,
15
15
  logs, and stored ProxyRequest.headers (redacted via _redact_headers).
16
16
 
17
- WP-11a (gemini-native fix) lives in _helpers.py:_GEMINI_NATIVE_FORWARD_HEADERS.
17
+ The gemini-native Authorization-header fix lives in _helpers.py:_GEMINI_NATIVE_FORWARD_HEADERS.
18
18
  """
19
19
 
20
20
  from __future__ import annotations
@@ -959,7 +959,7 @@ class CacheDB:
959
959
  def ccr_delete(self, ccr_id: str, *, tenant_id: str = "default") -> None:
960
960
  """Delete a CCR row by ccr_id scoped to tenant. Idempotent — warns on sqlite error, never raises.
961
961
 
962
- WP-10 D6: defensive infra + sweep parity. Deleting a non-existent row is a no-op.
962
+ Defensive delete idempotent: deleting a non-existent row is a no-op.
963
963
  H-02: tenant_id guard prevents a tenant from deleting another tenant's CCR.
964
964
  """
965
965
  try:
@@ -973,7 +973,7 @@ class CacheDB:
973
973
  def ccr_count(self) -> int:
974
974
  """Return UNFILTERED count of rows in llmcache_ccr_originals.
975
975
 
976
- WP-10 CRIT-2: Do NOT reuse TTL-filtered count at :646. A fresh no-expiry row
976
+ Do NOT reuse TTL-filtered count from the query above. A fresh no-expiry row
977
977
  has ttl_expires=None, so the TTL filter returns 0 and D6 orphan tests would
978
978
  falsely pass. This unfiltered count is test infrastructure only.
979
979
  """
@@ -163,7 +163,7 @@ class AgenticRetriever:
163
163
  RetrievalRound(2, rq, len(rn), _avg(rn), True),
164
164
  )
165
165
 
166
- merged = sorted(pool.values(), key=lambda x: x[1], reverse=True)
166
+ merged = sorted(pool.values(), key=lambda x: (-x[1], x[0].fact_id))
167
167
  return [f for f, _ in merged[:top_k]]
168
168
 
169
169
  # -- Sufficiency check ---------------------------------------------------
@@ -178,8 +178,15 @@ class ANNIndex:
178
178
  if k <= 0:
179
179
  return []
180
180
 
181
- top_indices = np.argpartition(scores, -k)[-k:]
182
- top_indices = top_indices[np.argsort(scores[top_indices])[::-1]]
181
+ # Full sort with a secondary key on fact_id so that equal cosine
182
+ # scores are broken deterministically. argpartition cannot
183
+ # guarantee which facts reach the top-k when scores tie at the
184
+ # boundary — the selection follows array layout (insertion order)
185
+ # and changes between restarts. lexsort picks the same k facts
186
+ # in the same order regardless of how the index was loaded.
187
+ id_arr = np.array(self._ids, dtype=object)
188
+ order = np.lexsort((id_arr, -scores)) # primary: -score, secondary: fact_id
189
+ top_indices = order[:k]
183
190
 
184
191
  return [
185
192
  (self._ids[i], float(scores[i]))
@@ -267,7 +267,7 @@ class BM25Channel:
267
267
  except Exception as exc: # pragma: no cover — legacy/missing expansion FTS
268
268
  logger.debug("Expansion FTS search skipped: %s", exc)
269
269
 
270
- out.sort(key=lambda x: x[1], reverse=True)
270
+ out.sort(key=lambda x: (-x[1], x[0]))
271
271
  return out[:top_k]
272
272
 
273
273
  def search(
@@ -341,7 +341,7 @@ class BM25Channel:
341
341
  bonus *= 1.5 # 50% boost for exact phrase match
342
342
  scored.append((self._fact_ids[i], bonus))
343
343
 
344
- scored.sort(key=lambda x: x[1], reverse=True)
344
+ scored.sort(key=lambda x: (-x[1], x[0]))
345
345
  return scored[:top_k]
346
346
 
347
347
  def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
@@ -160,7 +160,7 @@ class BridgeDiscovery:
160
160
  if len(bridges) >= max_bridges:
161
161
  break
162
162
 
163
- bridges.sort(key=lambda x: x[1], reverse=True)
163
+ bridges.sort(key=lambda x: (-x[1], x[0]))
164
164
  return filter_authorized_results(
165
165
  self._db,
166
166
  bridges,
@@ -243,7 +243,7 @@ class BridgeDiscovery:
243
243
  for fid, score in activations.items()
244
244
  if fid not in set(seed_ids) and score > 0.01
245
245
  ]
246
- results.sort(key=lambda x: x[1], reverse=True)
246
+ results.sort(key=lambda x: (-x[1], x[0]))
247
247
  return filter_authorized_results(
248
248
  self._db,
249
249
  results,
@@ -20,6 +20,7 @@ import concurrent.futures
20
20
  import functools
21
21
  import logging
22
22
  import math
23
+ import os
23
24
  import re
24
25
  import threading
25
26
  import time
@@ -57,6 +58,46 @@ if TYPE_CHECKING:
57
58
  logger = logging.getLogger(__name__)
58
59
 
59
60
 
61
+ # How long the parallel channel phase may run before a channel is abandoned.
62
+ #
63
+ # This is a guard against a genuinely wedged channel, NOT a speed cutoff, and
64
+ # the distinction is the whole point. A channel that misses this limit is
65
+ # cancelled and contributes NOTHING to fusion: its candidates are not reordered,
66
+ # they are absent. So whenever this limit binds, the answer is decided partly by
67
+ # what else the machine happened to be doing — the same question returns a
68
+ # different answer under load, which is a correctness failure, not a slow one.
69
+ #
70
+ # It replaced a 1.4 s cutoff that was chosen to keep the recall p95 low. Six
71
+ # runs of identical code against the same 0.95 GB store logged 0, 0, 25, 2, 0
72
+ # and 0 abandoned channels; in the third run `hopfield` was cut off on 13 of
73
+ # 140 queries and `temporal` on 9, while the first run lost nothing on those
74
+ # same queries. That spread was the last remaining source of unrepeatable
75
+ # recall, and it is why fixing tie-breaks everywhere else moved top-10 churn
76
+ # from 40.7% to 22.9% and left rank-1 disagreement sitting at ~15%: a
77
+ # tie-break cannot repair a missing input.
78
+ #
79
+ # The value comes from the measured cost of the channels themselves, on that
80
+ # same store, 140 queries, with the limit raised out of the way so nothing was
81
+ # truncated (p95 / max, ms):
82
+ #
83
+ # temporal 580 / 1983 hopfield 492 / 945 bm25 230 / 1093
84
+ # semantic 264 / 662 spreading_activation 238 / 571
85
+ #
86
+ # The slowest channel's p95 is 580 ms and its worst single run was 1,983 ms, so
87
+ # 8 s is roughly four times the worst observed cost — it should never bind on a
88
+ # machine that is merely busy. It also stays well inside the daemon's own
89
+ # last-resort recall budget (25 s, `_recall_budget_s`), which is the layer that
90
+ # exists to catch a true hang and which already tells the caller when it fires
91
+ # (`retrieval_mode=degraded_lexical`). Before this change the inner 1.4 s cutoff
92
+ # silently overrode that outer promise of "quality recall under load".
93
+ #
94
+ # Lowering this to improve a latency percentile means buying that percentile
95
+ # with missing answers. Per HARD-RULES RULE 6 the ordering is Correct, then
96
+ # Complete, then Repeatable, and only then Fast — so if this needs to move,
97
+ # measure what it costs in answer quality first and record the number.
98
+ CHANNEL_HANG_GUARD_SECONDS = 8.0
99
+
100
+
60
101
  class CrossEncoderProtocol(Protocol):
61
102
  """Duck-typed cross-encoder interface."""
62
103
  def rerank(self, query: str, candidates: list[tuple[str, str]]) -> list[tuple[str, float]]: ...
@@ -103,7 +144,7 @@ class RetrievalEngine:
103
144
  self._spreading_activation = channels.get("spreading_activation")
104
145
  self._embedder = embedder
105
146
  self._reranker = reranker
106
- self._strategy = strategy or QueryStrategyClassifier()
147
+ self._strategy = strategy or QueryStrategyClassifier(config=config)
107
148
  self._base_weights = (base_weights or ChannelWeights()).as_dict()
108
149
  self._profile_channel = profile_channel
109
150
  self._bridge = bridge_discovery
@@ -225,12 +266,16 @@ class RetrievalEngine:
225
266
  # 3. Run channels. Both scope flags AND extra_disabled_channels travel as
226
267
  # explicit call parameters so concurrent recalls with different flags
227
268
  # cannot corrupt each other. No lock needed — no shared mutable state.
269
+ # Owned by this call, so concurrent recalls cannot report each other's
270
+ # losses. Non-empty means this answer is incomplete, not just slow.
271
+ dropped_channels: set[str] = set()
228
272
  ch_results = self._run_channels(
229
273
  query, profile_id, strat,
230
274
  extra_disabled_channels=extra_disabled_channels,
231
275
  include_global=include_global, include_shared=include_shared,
232
276
  as_of=as_of, known_as_of=known_as_of, valid_at=valid_at,
233
277
  include_unknown=include_unknown,
278
+ dropped_channels=dropped_channels,
234
279
  )
235
280
  _em("run_channels")
236
281
  # One request may need admission before fusion and again after optional
@@ -251,6 +296,7 @@ class RetrievalEngine:
251
296
  total = sum(len(v) for v in ch_results.values())
252
297
 
253
298
  # 3. Single-pass RRF fusion
299
+ ch_results = self._semantic_rank_for_unenriched(ch_results)
254
300
  fused = weighted_rrf(ch_results, strat.weights, k=self._config.rrf_k)
255
301
  _em("rrf_fusion")
256
302
 
@@ -296,10 +342,29 @@ class RetrievalEngine:
296
342
  except Exception as exc:
297
343
  logger.warning("Bridge discovery: %s", exc)
298
344
 
299
- # Scene expansion (v3.5.0: batch + time-budgeted).
300
- # Skip if channels already exceeded the per-recall time budget;
301
- # the scene signal is nice-to-have, never worth delaying response.
302
- if fused and (_time_e.monotonic() - _e0) < 0.8:
345
+ # Scene expansion (v3.5.0: batch).
346
+ #
347
+ # This used to be skipped when more than 0.8 s of the recall had already
348
+ # elapsed, on the reasoning that the scene signal is nice-to-have and
349
+ # never worth delaying a response. The reasoning was wrong, because the
350
+ # stage does not merely decorate the answer — it appends candidates that
351
+ # can outrank what fusion produced. Gating it on a stopwatch therefore
352
+ # made the ANSWER depend on how busy the machine was, and 0.8 s sits on
353
+ # top of recall's own median (~1,044 ms on the 0.95 GB archive), so it
354
+ # was not a rare safety valve: measured over two runs of 60 queries, the
355
+ # two clock gates flipped their decision on 22 of 60, and of the 19
356
+ # queries whose answer changed, every one had a flipped gate.
357
+ #
358
+ # Removing both gates moved rank-1 disagreement between two runs from
359
+ # 20.0% to 3.3% and top-10 from 31.7% to 10.0%, for about 100-180 ms of
360
+ # p95 (1,191-1,230 ms -> 1,256-1,375 ms, ceiling 2,000 ms). Per
361
+ # HARD-RULES RULE 6 that is the correct direction: Correct, Complete,
362
+ # Repeatable, and only then Fast.
363
+ #
364
+ # So do not reintroduce a time condition here. If this stage ever needs
365
+ # bounding, bound it by DATA — a candidate count, a scene cap — so the
366
+ # same input always takes the same path.
367
+ if fused:
303
368
  try:
304
369
  top_ids = [fr.fact_id for fr in fused[:20]]
305
370
  scenes_map = self._db.get_scenes_for_facts_batch(top_ids, profile_id)
@@ -323,10 +388,14 @@ class RetrievalEngine:
323
388
  # Instead of competing as independent channel, entity_graph SCORES
324
389
  # the candidates from other channels by graph proximity to query entities.
325
390
  # Research: Microsoft GraphRAG DRIFT, Pistis-RAG cascaded architecture.
391
+ # The 0.9 s clock gate that used to guard this stage is gone for the
392
+ # reason given above the scene expansion, and it mattered more here:
393
+ # this stage re-scores every fused candidate and then re-sorts them, so
394
+ # whether it ran decided the top answer outright rather than adding to
395
+ # it. Bound by data if it ever needs bounding, never by elapsed time.
326
396
  if (self._entity is not None
327
397
  and "entity_graph" not in set(self._config.disabled_channels)
328
- and fused
329
- and (_time_e.monotonic() - _e0) < 0.9):
398
+ and fused):
330
399
  try:
331
400
  candidate_ids = [fr.fact_id for fr in fused[:100]]
332
401
  eg_scores = self._entity.score_candidates(
@@ -351,7 +420,7 @@ class RetrievalEngine:
351
420
  ))
352
421
  else:
353
422
  boosted.append(fr)
354
- fused = sorted(boosted, key=lambda r: r.fused_score, reverse=True)
423
+ fused = sorted(boosted, key=lambda r: (-r.fused_score, r.fact_id))
355
424
  except Exception as exc:
356
425
  logger.warning("Entity graph signal enhancement: %s", exc)
357
426
 
@@ -408,29 +477,6 @@ class RetrievalEngine:
408
477
  if strat.query_type == "aggregation" and facts:
409
478
  top = self._enforce_session_diversity(top, facts, min_sessions=3, top_k=20)
410
479
 
411
- # 5. Cross-encoder rerank (optional)
412
- # Bug 4 fix: reduced alpha for multi-hop/temporal to preserve diversity
413
- # V3.3.21: Skip reranker if worker isn't ready yet (cold start).
414
- # Returns results without CE reranking (~5-10pp lower quality) but instant
415
- # instead of blocking 15-19s on first recall. Worker warms up in background.
416
- reranker_ready = (
417
- self._reranker is not None
418
- and getattr(self._reranker, '_worker_ready', False)
419
- )
420
- reranker_applied = False
421
- reranker_status = (
422
- "fallback_not_ready" if self._reranker is not None
423
- else "not_configured"
424
- )
425
- if reranker_ready and facts:
426
- ce_alpha = 0.5 if strat.query_type in ("multi_hop", "temporal") else 0.75
427
- top, reranker_applied, reranker_status = self._apply_reranker(
428
- query, top, facts, alpha=ce_alpha,
429
- )
430
- elif reranker_ready:
431
- reranker_status = "no_candidates"
432
- _em(f"rerank(ready={reranker_ready})")
433
-
434
480
  # v3.6.6: Evidence floor — gate on per-channel scores (NOT fused/RRF score).
435
481
  # Nonsense queries fuse at 0.75-0.78 because RRF is rank-derived and
436
482
  # uncalibrated. The discriminator is EARNED CHANNEL EVIDENCE:
@@ -439,6 +485,12 @@ class RetrievalEngine:
439
485
  # spreading_activation and hopfield do NOT count — they are associative
440
486
  # amplifiers that fabricated the nonsense results in calibration tests.
441
487
  # Kill-switch: SLM_RECALL_NO_FLOOR=1 bypasses the floor.
488
+ # Runs BEFORE the cross-encoder so the CE batch contains only
489
+ # evidence-qualified candidates. The floor gates on channel_scores
490
+ # (semantic, bm25, entity_graph, temporal) which are assigned during
491
+ # channel execution and are not affected by CE reranking. Moving the
492
+ # floor here does not change which queries abstain; it reduces the CE
493
+ # batch from ~180 candidates to the qualified subset (~30–60).
442
494
  import os as _os_floor
443
495
  floor_enabled = (
444
496
  getattr(self._config, "evidence_floor_enabled", True)
@@ -453,6 +505,29 @@ class RetrievalEngine:
453
505
  # qualified candidate was immediately below the slice.
454
506
  top = self._apply_evidence_floor(top, facts, min_sem)
455
507
 
508
+ # 5. Cross-encoder rerank (optional, on the evidence-qualified pool)
509
+ # Bug 4 fix: reduced alpha for multi-hop/temporal to preserve diversity
510
+ # V3.3.21: Skip reranker if worker isn't ready yet (cold start).
511
+ # Returns results without CE reranking (~5-10pp lower quality) but instant
512
+ # instead of blocking 15-19s on first recall. Worker warms up in background.
513
+ reranker_ready = (
514
+ self._reranker is not None
515
+ and getattr(self._reranker, '_worker_ready', False)
516
+ )
517
+ reranker_applied = False
518
+ reranker_status = (
519
+ "fallback_not_ready" if self._reranker is not None
520
+ else "not_configured"
521
+ )
522
+ if reranker_ready and facts:
523
+ ce_alpha = 0.5 if strat.query_type in ("multi_hop", "temporal") else 0.75
524
+ top, reranker_applied, reranker_status = self._apply_reranker(
525
+ query, top, facts, alpha=ce_alpha,
526
+ )
527
+ elif reranker_ready:
528
+ reranker_status = "no_candidates"
529
+ _em(f"rerank(ready={reranker_ready})")
530
+
456
531
  # V3.4.11: Channel diversity — guarantee entity_graph results appear in
457
532
  # the final output. Applied AFTER reranking and evidence qualification
458
533
  # so an associative-only candidate cannot be reintroduced after the gate.
@@ -489,6 +564,7 @@ class RetrievalEngine:
489
564
  # Q2b: thematic context when the top results cluster in one
490
565
  # community. Precomputed summary lookup only — no per-query LLM.
491
566
  community_context=self._community_context(results, profile_id),
567
+ incomplete_channels=tuple(sorted(dropped_channels)),
492
568
  )
493
569
 
494
570
  # -- Community context (Wave Q2b) --------------------------------------
@@ -662,7 +738,7 @@ class RetrievalEngine:
662
738
  channel_ranks=fr.channel_ranks,
663
739
  channel_scores=fr.channel_scores,
664
740
  ))
665
- boosted.sort(key=lambda r: r.fused_score, reverse=True)
741
+ boosted.sort(key=lambda r: (-r.fused_score, r.fact_id))
666
742
  return boosted
667
743
 
668
744
  # -- Session diversity enforcement ----------------------------------------
@@ -799,6 +875,94 @@ class RetrievalEngine:
799
875
  self._query_embedding_cache[query] = emb
800
876
  return emb
801
877
 
878
+ def _semantic_rank_for_unenriched(
879
+ self, ch_results: dict[str, list[tuple[str, float]]],
880
+ ) -> dict[str, list[tuple[str, float]]]:
881
+ """Give a candidate whose vector does not exist yet a fair semantic rank.
882
+
883
+ Fusion here is rank-based, so a fact the semantic channel did not return
884
+ forfeits that channel's entire contribution — the most heavily weighted
885
+ one. When the reason for that absence is simply that the vector has not
886
+ been computed yet, the absence describes the ingest pipeline and says
887
+ nothing about the fact. Left alone, a memory written seconds ago is the
888
+ hardest thing in the store to find, which is the worst possible failure
889
+ for this product.
890
+
891
+ Such candidates are placed at the MEDIAN of the semantic ranking, never
892
+ near the top: enough to compete on their other evidence, not enough to
893
+ win on freshness alone. A candidate that HAS a vector and still was not
894
+ returned is left exactly as it is — that absence is real evidence of
895
+ irrelevance, and the two must not be confused.
896
+
897
+ Returns a new mapping; the input is not modified.
898
+ """
899
+ sem = ch_results.get("semantic") or []
900
+ if not sem:
901
+ return ch_results
902
+ if not getattr(self._config, "write_recency_floor_enabled", True):
903
+ return ch_results
904
+ if os.environ.get("SLM_WRITE_RECENCY_NO_FLOOR", "0") == "1":
905
+ return ch_results
906
+
907
+ have = {fid for fid, _ in sem}
908
+ elsewhere = {
909
+ fid
910
+ for name, rows in ch_results.items()
911
+ if name != "semantic"
912
+ for fid, _ in rows
913
+ }
914
+ candidates = sorted(elsewhere - have)
915
+ if not candidates:
916
+ return ch_results
917
+
918
+ from datetime import UTC, datetime, timedelta
919
+
920
+ minutes = float(getattr(self._config, "write_recency_floor_minutes", 60.0))
921
+ cutoff = (datetime.now(UTC) - timedelta(minutes=minutes)).isoformat()
922
+ placeholders = ",".join("?" for _ in candidates)
923
+ try:
924
+ # A missing embedding_metadata row means no vector projection exists,
925
+ # which is what makes the semantic channel's silence uninformative.
926
+ unenriched = [
927
+ dict(r)["fact_id"]
928
+ for r in self._db.execute(
929
+ f"SELECT af.fact_id FROM atomic_facts AS af "
930
+ f"LEFT JOIN embedding_metadata AS em ON em.fact_id = af.fact_id "
931
+ f"WHERE af.fact_id IN ({placeholders}) "
932
+ f" AND em.fact_id IS NULL "
933
+ f" AND af.created_at >= ?",
934
+ (*candidates, cutoff),
935
+ )
936
+ ]
937
+ except (NameError, AttributeError, TypeError):
938
+ # These mean this code is wrong, not that the data is unusual. A bare
939
+ # `except Exception` here hid a missing import and left the whole
940
+ # feature silently inert while every test still passed.
941
+ raise
942
+ except Exception as exc:
943
+ # A store without this table, or a locked database: ranking must still
944
+ # return. Logged at warning, because "silently did nothing" is the
945
+ # failure mode this task exists to fix.
946
+ logger.warning("recent-unenriched admission skipped: %s: %s",
947
+ type(exc).__name__, exc)
948
+ return ch_results
949
+ if not unenriched:
950
+ return ch_results
951
+
952
+ scores = sorted(s for _, s in sem)
953
+ mid = len(scores) // 2
954
+ median = (
955
+ scores[mid] if len(scores) % 2 == 1
956
+ else (scores[mid - 1] + scores[mid]) / 2.0
957
+ )
958
+ insert_at = len(sem) // 2
959
+ merged = list(sem[:insert_at]) + [(fid, median) for fid in unenriched] + list(sem[insert_at:])
960
+ logger.debug(
961
+ "admitted %d recent un-enriched candidate(s) at semantic rank %d of %d",
962
+ len(unenriched), insert_at + 1, len(merged),
963
+ )
964
+ return {**ch_results, "semantic": merged}
965
+
802
966
  def _run_channels(
803
967
  self,
804
968
  query: str,
@@ -812,6 +976,7 @@ class RetrievalEngine:
812
976
  known_as_of: str | None = None,
813
977
  valid_at: str | None = None,
814
978
  include_unknown: bool = False,
979
+ dropped_channels: set[str] | None = None,
815
980
  ) -> dict[str, list[tuple[str, float]]]:
816
981
  """Run active retrieval channels.
817
982
 
@@ -822,6 +987,13 @@ class RetrievalEngine:
822
987
  enabled and healthy, parallel dispatch generally bounds the producer
823
988
  phase by the slowest submitted producer, plus serial embedding and
824
989
  result-collection overhead.
990
+
991
+ ``dropped_channels``, when given, receives the name of every channel
992
+ abandoned at ``CHANNEL_HANG_GUARD_SECONDS``. Those channels contributed
993
+ nothing, so the caller needs to know the answer is incomplete rather
994
+ than merely late. It is a caller-owned set passed down per recall and
995
+ deliberately not an attribute of self — two concurrent recalls sharing
996
+ one would report each other's losses (the v3.4.64 race).
825
997
  """
826
998
  import os as _os_e
827
999
  import time as _time_e
@@ -894,6 +1066,7 @@ class RetrievalEngine:
894
1066
  functools.partial(
895
1067
  self._temporal.search,
896
1068
  include_global=include_global, include_shared=include_shared,
1069
+ query_type=strat.query_type,
897
1070
  ),
898
1071
  query, profile_id, self._config.bm25_top_k,
899
1072
  )
@@ -920,22 +1093,25 @@ class RetrievalEngine:
920
1093
  q_emb, profile_id, self._config.bm25_top_k,
921
1094
  )
922
1095
 
923
- # Each local channel gets a strict latency budget. A slow graph walk
924
- # must not make an interactive recall wait 30 seconds; completed
925
- # channels still participate in fusion and the timeout is observable.
926
- channel_timeout_seconds = 1.0
927
- # One shared deadline keeps parallel dispatch genuinely bounded. A
1096
+ # One shared limit keeps parallel dispatch genuinely bounded. A
928
1097
  # per-future timeout here would serialise the wait and turn five slow
929
1098
  # channels into five seconds of UI latency.
930
1099
  done, pending = concurrent.futures.wait(
931
- futures.values(), timeout=channel_timeout_seconds,
1100
+ futures.values(), timeout=CHANNEL_HANG_GUARD_SECONDS,
932
1101
  )
933
1102
  for name, fut in futures.items():
934
1103
  if fut in pending:
935
- logger.warning(
936
- "Channel %s exceeded %.1fs latency budget",
937
- name, channel_timeout_seconds,
1104
+ # Not a latency notice: this answer is missing whatever this
1105
+ # channel alone could see, so it is logged at the level that
1106
+ # says so and recorded for the caller.
1107
+ logger.error(
1108
+ "Channel %s did not finish within %.1fs; this recall is "
1109
+ "answering without it",
1110
+ name, CHANNEL_HANG_GUARD_SECONDS,
938
1111
  )
1112
+ if dropped_channels is not None:
1113
+ dropped_channels.add(name)
1114
+ fut.cancel() # no-op if already running; prevents queued jobs from starting
939
1115
  continue
940
1116
  try:
941
1117
  ch_name, result = fut.result()
@@ -1113,7 +1289,7 @@ class RetrievalEngine:
1113
1289
  )
1114
1290
  for fr in fused
1115
1291
  ]
1116
- updated.sort(key=lambda r: r.fused_score, reverse=True)
1292
+ updated.sort(key=lambda r: (-r.fused_score, r.fact_id))
1117
1293
  return updated, True, "applied"
1118
1294
 
1119
1295
  # -- Agentic adapter -----------------------------------
@@ -1172,7 +1348,9 @@ class RetrievalEngine:
1172
1348
  continue
1173
1349
  evidence = [
1174
1350
  f"{ch}(rank={rk}, score={fr.channel_scores.get(ch, 0.0):.4f})"
1175
- for ch, rk in sorted(fr.channel_ranks.items(), key=lambda x: x[1])
1351
+ # Channel name breaks a tie, so the evidence string a caller
1352
+ # sees is the same on two runs when two channels agree on rank.
1353
+ for ch, rk in sorted(fr.channel_ranks.items(), key=lambda x: (x[1], x[0]))
1176
1354
  if rk < 1000
1177
1355
  ]
1178
1356
  # Recency decay: Ebbinghaus exponential + FSRS stability strengthening (v3.4.51).
@@ -1188,10 +1366,12 @@ class RetrievalEngine:
1188
1366
  # 0d, 0acc → 1.10× 45d, 0acc → 0.91× 90d, 0acc → 0.84×
1189
1367
  # 45d, 5acc → 0.95× 90d, 10acc → 0.90× (frequently used memories stay relevant)
1190
1368
  age_days = 0.0
1369
+ age_known = False
1191
1370
  if fact.created_at:
1192
1371
  try:
1193
1372
  created = datetime.fromisoformat(fact.created_at.replace("Z", "+00:00"))
1194
1373
  age_days = max(0.0, (now - created).total_seconds() / 86400.0)
1374
+ age_known = True
1195
1375
  except (ValueError, TypeError):
1196
1376
  pass
1197
1377
  _access = max(0, getattr(fact, "access_count", 0) or 0)
@@ -1213,6 +1393,48 @@ class RetrievalEngine:
1213
1393
  trust_weight, raw_trust = self._get_trust_weight(fact, profile_id)
1214
1394
 
1215
1395
  boosted_score = fr.fused_score * recency_boost * quality * trust_weight
1396
+
1397
+ # Query-type-conditioned recency amplifier.
1398
+ # Applied only to "recency" and "temporal" queries; factual, entity,
1399
+ # and all other types receive a factor of exactly 1.0 (no change).
1400
+ # The amplitude scalar is read from RetrievalConfig so it can be tuned
1401
+ # or zeroed at runtime. strength=0.0 is a strict no-op — the if-guard
1402
+ # ensures the previous ranking is reproduced byte-for-byte.
1403
+ #
1404
+ # recency — 7-day half-life, 1.5× maximum (present-activity queries)
1405
+ # temporal — 30-day half-life, 1.2× maximum (past-event queries)
1406
+ #
1407
+ # Hook for the follow-on embedding-lag adjustment (task 2.6): that
1408
+ # adjustment also multiplies boosted_score and belongs immediately after
1409
+ # this block, conditioned on channel_scores["semantic"] == 0.0 AND
1410
+ # age_days < 1.0. Add it as an independent if-block here so the two
1411
+ # factors compose cleanly without restructuring what is above or below.
1412
+ _prior_strength = getattr(self._config, "recency_prior_strength", 0.5)
1413
+ # age_known matters here: the fallback above leaves age_days at 0.0
1414
+ # when a fact carries no usable timestamp, which reads as "written
1415
+ # moments ago" and would hand an undated fact the largest possible
1416
+ # boost for being new. Not knowing when something was written is not
1417
+ # evidence that it is fresh.
1418
+ if (_prior_strength > 0.0 and age_known
1419
+ and strat.query_type in ("recency", "temporal")):
1420
+ _half_life = 7.0 if strat.query_type == "recency" else 30.0
1421
+ # Both query types use max_amp=1.5 so the decay is visible.
1422
+ # With max_amp=1.2 and half_life=30, the raw value at age 0d
1423
+ # is 1.5 and at age 30d is 1.25 — both clamp to 1.2. The prior
1424
+ # was inert over the first ~39 days, which is the range it was
1425
+ # built to discriminate. Raising the cap to 1.5 lets the
1426
+ # formula vary from 1.5 (fresh) through 1.25 (30d) toward 1.0
1427
+ # (old). This changes ranking: facts from 2 days ago and 30
1428
+ # days ago now receive different boosts. The change is a
1429
+ # correction to a clamp that made the prior inert, not a
1430
+ # measured gain.
1431
+ _max_amp = 1.5
1432
+ _cond_boost = 1.0 + _prior_strength * math.exp(
1433
+ -(math.log(2) / _half_life) * age_days
1434
+ )
1435
+ _cond_boost = min(_cond_boost, _max_amp)
1436
+ boosted_score = boosted_score * _cond_boost
1437
+
1216
1438
  # v3.5.0 (M2): soft-normalize to [0,1]. RRF weights + scene/entity
1217
1439
  # boosts push raw scores well above 1 (observed: 27.97). A sigmoid
1218
1440
  # preserves rank (monotonic) while giving users a readable 0-1 range.
@@ -1227,6 +1449,13 @@ class RetrievalEngine:
1227
1449
  evidence_chain=evidence,
1228
1450
  trust_score=raw_trust,
1229
1451
  ))
1452
+ # ranking_score incorporates every modifier computed in this loop
1453
+ # (Ebbinghaus decay, quality, trust, and the query-type-conditioned
1454
+ # recency amplifier). Sort here so RecallResponse.results[0] is
1455
+ # always the highest-ranked fact — callers that rely on the returned
1456
+ # order get the amplified ranking, not the pre-amplifier fused order.
1457
+ # Tie-break on fact_id keeps two runs over an unchanged store stable.
1458
+ results.sort(key=lambda r: (-(r.ranking_score or 0.0), r.fact.fact_id))
1230
1459
  return results
1231
1460
 
1232
1461
 
@@ -651,7 +651,7 @@ class EntityGraphChannel:
651
651
  max_score = max(sc for _, sc in results)
652
652
  if max_score > 0:
653
653
  results = [(fid, sc / max_score) for fid, sc in results]
654
- results.sort(key=lambda x: x[1], reverse=True)
654
+ results.sort(key=lambda x: (-x[1], x[0]))
655
655
  return filter_authorized_results(
656
656
  self._db,
657
657
  results,
@@ -261,8 +261,14 @@ class HopfieldChannel:
261
261
  # Step 4: Similarity to all patterns
262
262
  similarities = memory_matrix @ retrieved # shape (n,)
263
263
 
264
- # Step 5: Top-K selection
265
- top_indices = np.argsort(-similarities)[:top_k]
264
+ # Step 5: Top-K selection with stable tie-break on fact_id.
265
+ # argsort(-similarities) is score-only: equal similarities are ordered
266
+ # by row index, which tracks the order facts were loaded from the DB
267
+ # and varies between sessions. lexsort breaks ties on fact_id so the
268
+ # same query over the same store returns the same ranking every time.
269
+ id_arr = np.array(fact_ids, dtype=object)
270
+ order = np.lexsort((id_arr, -similarities)) # primary: -similarity, secondary: fact_id
271
+ top_indices = order[:top_k]
266
272
  results: list[tuple[str, float]] = [
267
273
  (fact_ids[int(i)], float(similarities[i]))
268
274
  for i in top_indices
@@ -91,7 +91,7 @@ class ProfileChannel:
91
91
  seen.add(fid)
92
92
  results.append((fid, 0.95))
93
93
 
94
- results.sort(key=lambda x: x[1], reverse=True)
94
+ results.sort(key=lambda x: (-x[1], x[0]))
95
95
  return results[:top_k]
96
96
 
97
97
  @staticmethod