superlocalmemory 4.0.8 → 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 (120) hide show
  1. package/CHANGELOG.md +146 -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/core/config.py +41 -7
  37. package/src/superlocalmemory/core/context_cache.py +0 -2
  38. package/src/superlocalmemory/core/engine.py +371 -63
  39. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  40. package/src/superlocalmemory/core/install_detector.py +131 -0
  41. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  42. package/src/superlocalmemory/core/security_primitives.py +3 -6
  43. package/src/superlocalmemory/core/store_pipeline.py +94 -26
  44. package/src/superlocalmemory/core/topic_signature.py +0 -2
  45. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  46. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  47. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  48. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  49. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  50. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  51. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  52. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  53. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  54. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  55. package/src/superlocalmemory/infra/backup.py +44 -8
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  57. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  58. package/src/superlocalmemory/learning/bandit.py +0 -2
  59. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  60. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  61. package/src/superlocalmemory/learning/ensemble.py +0 -2
  62. package/src/superlocalmemory/learning/labeler.py +0 -2
  63. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  64. package/src/superlocalmemory/learning/model_cache.py +0 -2
  65. package/src/superlocalmemory/learning/ranker.py +0 -2
  66. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  67. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  68. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  69. package/src/superlocalmemory/math/fisher.py +1 -1
  70. package/src/superlocalmemory/math/hopfield.py +4 -1
  71. package/src/superlocalmemory/math/langevin.py +1 -1
  72. package/src/superlocalmemory/math/sheaf.py +7 -3
  73. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  74. package/src/superlocalmemory/mcp/profiles.py +1 -1
  75. package/src/superlocalmemory/mcp/server.py +1 -1
  76. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  77. package/src/superlocalmemory/mcp/tools_core.py +1 -1
  78. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  79. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  80. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  81. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  82. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  83. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  84. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  85. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  86. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  87. package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
  88. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  89. package/src/superlocalmemory/retrieval/engine.py +272 -43
  90. package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
  91. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  92. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  93. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  94. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  95. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  96. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  97. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  98. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  99. package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
  100. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  101. package/src/superlocalmemory/retrieval/vector_store.py +88 -10
  102. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  103. package/src/superlocalmemory/server/routes/brain.py +2 -2
  104. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  105. package/src/superlocalmemory/server/unified_daemon.py +459 -7
  106. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  107. package/src/superlocalmemory/storage/backup.py +531 -0
  108. package/src/superlocalmemory/storage/database.py +11 -4
  109. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  110. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  111. package/src/superlocalmemory/storage/migration_runner.py +142 -2
  112. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  113. package/src/superlocalmemory/storage/migrations.py +15 -1
  114. package/src/superlocalmemory/storage/models.py +7 -0
  115. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  116. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  117. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  118. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  119. package/src/superlocalmemory/ui/js/core.js +1 -1
  120. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
@@ -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
@@ -88,7 +88,7 @@ class QuantizationAwareSearch:
88
88
  seen[fid] = score
89
89
 
90
90
  # Sort by score descending
91
- merged = sorted(seen.items(), key=lambda x: x[1], reverse=True)
91
+ merged = sorted(seen.items(), key=lambda x: (-x[1], x[0]))
92
92
  return merged[:top_k]
93
93
 
94
94
  # -- Tier helpers (encapsulate error handling) -------------------------
@@ -596,7 +596,7 @@ class RemoteReranker:
596
596
  (fact, float(score))
597
597
  for (fact, _), score in zip(ranked, scores)
598
598
  ]
599
- scored.sort(key=lambda pair: pair[1], reverse=True)
599
+ scored.sort(key=lambda pair: (-pair[1], pair[0].fact_id))
600
600
  return scored[:top_k], True, "applied"
601
601
 
602
602
  def score_pair(self, query: str, document: str) -> float:
@@ -735,7 +735,7 @@ class RemoteReranker:
735
735
  def _fusion_order(
736
736
  candidates: list[tuple[AtomicFact, float]],
737
737
  ) -> list[tuple[AtomicFact, float]]:
738
- return sorted(candidates, key=lambda pair: pair[1], reverse=True)
738
+ return sorted(candidates, key=lambda pair: (-pair[1], pair[0].fact_id))
739
739
 
740
740
 
741
741
  class _RetryableRemoteError(RemoteRerankerError):
@@ -599,7 +599,7 @@ class CrossEncoderReranker:
599
599
  if not self._model_loaded:
600
600
  if not self._shutdown_event.is_set() and not self._worker_loading:
601
601
  self._start_background_warmup()
602
- sorted_cands = sorted(candidates, key=lambda x: x[1], reverse=True)
602
+ sorted_cands = sorted(candidates, key=lambda x: (-x[1], x[0].fact_id))
603
603
  return sorted_cands[:top_k], False, "fallback_not_ready"
604
604
 
605
605
  documents = [fact.content for fact, _ in candidates]
@@ -617,7 +617,7 @@ class CrossEncoderReranker:
617
617
 
618
618
  if resp is None or not resp.get("ok"):
619
619
  # Fallback: return by existing score
620
- sorted_cands = sorted(candidates, key=lambda x: x[1], reverse=True)
620
+ sorted_cands = sorted(candidates, key=lambda x: (-x[1], x[0].fact_id))
621
621
  return sorted_cands[:top_k], False, "fallback_busy_or_unavailable"
622
622
 
623
623
  scores = resp["scores"]
@@ -625,7 +625,7 @@ class CrossEncoderReranker:
625
625
  (fact, float(score))
626
626
  for (fact, _), score in zip(candidates, scores)
627
627
  ]
628
- scored.sort(key=lambda x: x[1], reverse=True)
628
+ scored.sort(key=lambda x: (-x[1], x[0].fact_id))
629
629
  return scored[:top_k], True, "applied"
630
630
 
631
631
  def score_pair(self, query: str, document: str) -> float:
@@ -332,7 +332,7 @@ class SemanticChannel:
332
332
  for fact_id, score in external_scores:
333
333
  combined[fact_id] = max(combined.get(fact_id, 0.0), score)
334
334
  knn_results = sorted(
335
- combined.items(), key=lambda item: item[1], reverse=True,
335
+ combined.items(), key=lambda item: (-item[1], item[0]),
336
336
  )[:top_k * 2]
337
337
  if not knn_results:
338
338
  return [] # Caller falls through to full scan
@@ -401,7 +401,7 @@ class SemanticChannel:
401
401
  if sim > 0.05:
402
402
  scored.append((fact.fact_id, sim))
403
403
 
404
- scored.sort(key=lambda x: x[1], reverse=True)
404
+ scored.sort(key=lambda x: (-x[1], x[0]))
405
405
  return scored[:top_k]
406
406
 
407
407
  def _search_full_scan(
@@ -463,7 +463,7 @@ class SemanticChannel:
463
463
  if sim > 0.05:
464
464
  scored.append((fact.fact_id, sim))
465
465
 
466
- scored.sort(key=lambda x: x[1], reverse=True)
466
+ scored.sort(key=lambda x: (-x[1], x[0]))
467
467
  return scored[:top_k]
468
468
 
469
469
  @staticmethod