superlocalmemory 4.0.9 → 4.1.0

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +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 +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +308 -20
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,117 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """What happened to each retrieval channel on one recall.
5
+
6
+ WHY THIS EXISTS
7
+ ---------------
8
+ A channel that crashes on every query and a channel that correctly found
9
+ nothing produced the same observable result: absence. Both simply had no entry
10
+ in the fused candidate map, and the only trace was a log line on a machine
11
+ nobody is reading.
12
+
13
+ That matters because the channels are not interchangeable. Lexical search
14
+ finding nothing for a conceptual question is the system working. Lexical search
15
+ raising on every question is an outage that looks, from the outside, like a
16
+ store with nothing in it — the user sees weaker answers and has no way to tell
17
+ which of the two they are getting.
18
+
19
+ There is a third case the absence hid, and it is the worst of them: a channel
20
+ that never ran. When the query embedding is unavailable, three of the five
21
+ channels are never even dispatched. Nothing in the answer said so.
22
+
23
+ WHY MORE THAN "ok / empty / error"
24
+ ----------------------------------
25
+ Because the remedies differ, and a status whose remedy is ambiguous is a status
26
+ nobody acts on. ``error`` is a bug to fix. ``timeout`` is a capacity or data-size
27
+ problem. ``no_embedding`` means the embedding provider is down and several
28
+ channels are silently offline together. ``disabled`` and ``not_configured`` are
29
+ someone's deliberate choice and must not read as faults — which is the point:
30
+ without naming them, an operator reading a list of missing channels cannot tell
31
+ their own configuration apart from a failure.
32
+
33
+ STRINGS, NOT AN ENUM
34
+ --------------------
35
+ This crosses the MCP and HTTP surfaces, where it is JSON either way. A str-valued
36
+ constant serialises without a custom encoder and compares equal to what a client
37
+ sends back.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Literal
43
+
44
+ __all__ = [
45
+ "ALL_STATUSES",
46
+ "CHANNEL_NAMES",
47
+ "ChannelStatus",
48
+ "DISABLED",
49
+ "EMPTY",
50
+ "ERROR",
51
+ "NOT_CONFIGURED",
52
+ "NO_CANDIDATES",
53
+ "NO_EMBEDDING",
54
+ "OK",
55
+ "TIMEOUT",
56
+ "is_fault",
57
+ ]
58
+
59
+ ChannelStatus = Literal[
60
+ "ok",
61
+ "empty",
62
+ "error",
63
+ "timeout",
64
+ "disabled",
65
+ "not_configured",
66
+ "no_embedding",
67
+ "no_candidates",
68
+ ]
69
+
70
+ #: Ran and contributed candidates.
71
+ OK: ChannelStatus = "ok"
72
+ #: Ran, contributed nothing. A legitimate answer, not a fault.
73
+ EMPTY: ChannelStatus = "empty"
74
+ #: Raised. This answer is missing whatever this channel alone could see.
75
+ ERROR: ChannelStatus = "error"
76
+ #: Abandoned at the hang guard. The answer is incomplete, not merely late.
77
+ TIMEOUT: ChannelStatus = "timeout"
78
+ #: Excluded by configuration or by a per-recall ablation flag.
79
+ DISABLED: ChannelStatus = "disabled"
80
+ #: No such channel on this engine — not built, or its dependency is absent.
81
+ NOT_CONFIGURED: ChannelStatus = "not_configured"
82
+ #: Needed the query embedding, which was unavailable. Several channels fail
83
+ #: together this way, and none of them individually did anything wrong.
84
+ NO_EMBEDDING: ChannelStatus = "no_embedding"
85
+ #: Had nothing to work on. Distinct from ``empty``, which claims a search
86
+ #: happened: a channel that re-scores other channels' candidates never searched
87
+ #: at all when there were none, and if the reason there were none is that the
88
+ #: others failed, calling this "found nothing" hides the actual fault.
89
+ NO_CANDIDATES: ChannelStatus = "no_candidates"
90
+
91
+ ALL_STATUSES: frozenset[str] = frozenset({
92
+ OK, EMPTY, ERROR, TIMEOUT, DISABLED, NOT_CONFIGURED, NO_EMBEDDING,
93
+ NO_CANDIDATES,
94
+ })
95
+
96
+ #: Every channel a recall can report on, so a caller can tell "this channel had
97
+ #: no status recorded" (a gap in the reporting) from "this channel reported that
98
+ #: it did nothing" (an answer). A missing key is a bug; ``empty`` is not.
99
+ CHANNEL_NAMES: tuple[str, ...] = (
100
+ "semantic",
101
+ "bm25",
102
+ "temporal",
103
+ "hopfield",
104
+ "spreading_activation",
105
+ "entity_graph",
106
+ "profile",
107
+ )
108
+
109
+ #: Statuses that mean the answer is worse than it should have been. ``empty``,
110
+ #: ``disabled`` and ``not_configured`` are deliberately absent: the first is a
111
+ #: valid finding and the other two are somebody's decision.
112
+ _FAULTS: frozenset[str] = frozenset({ERROR, TIMEOUT, NO_EMBEDDING})
113
+
114
+
115
+ def is_fault(status: str | None) -> bool:
116
+ """Whether this status means the answer was degraded."""
117
+ return (status or "") in _FAULTS
@@ -26,7 +26,12 @@ import threading
26
26
  import time
27
27
  from typing import TYPE_CHECKING, Any, Protocol
28
28
 
29
- from superlocalmemory.core.config import ChannelWeights, RetrievalConfig
29
+ from superlocalmemory.core.config import (
30
+ CANONICAL_RECALL_LIMIT,
31
+ ChannelWeights,
32
+ RetrievalConfig,
33
+ )
34
+ from superlocalmemory.retrieval import channel_status as chstat
30
35
  from superlocalmemory.retrieval.fusion import FusionResult, weighted_rrf
31
36
  from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
32
37
  from superlocalmemory.retrieval.temporal_validity_filter import (
@@ -195,7 +200,7 @@ class RetrievalEngine:
195
200
 
196
201
  def recall(
197
202
  self, query: str, profile_id: str,
198
- mode: Mode = Mode.A, limit: int = 20,
203
+ mode: Mode = Mode.A, limit: int = CANONICAL_RECALL_LIMIT,
199
204
  *,
200
205
  extra_disabled_channels: set[str] | None = None,
201
206
  include_global: bool = False,
@@ -246,19 +251,41 @@ class RetrievalEngine:
246
251
  strat = self._strategy.classify(query, self._base_weights)
247
252
  _em("classify")
248
253
 
249
- # Profile shortcut (runs before channel search)
250
- if self._profile_channel is not None:
254
+ # What each channel did, so the caller can tell an answer apart from
255
+ # an outage. Owned by this call for the same reason the dropped set is:
256
+ # a dict on the engine would have two concurrent recalls overwriting
257
+ # each other's report.
258
+ channel_status: dict[str, str] = {}
259
+
260
+ # Profile shortcut (runs before channel search).
261
+ #
262
+ # The ablation flag is honoured here too. It was not, so an operator who
263
+ # switched this channel off still had it searched, still had its weight
264
+ # doubled on a hit, and read a status of "ok" or "empty" — which reports
265
+ # their own configuration as a live channel's finding.
266
+ _profile_disabled = "profile" in set(self._config.disabled_channels) | set(
267
+ extra_disabled_channels or (),
268
+ )
269
+ if _profile_disabled:
270
+ profile_hits = []
271
+ channel_status["profile"] = chstat.DISABLED
272
+ elif self._profile_channel is not None:
251
273
  try:
252
274
  profile_hits = self._profile_channel.search(
253
275
  query, profile_id, top_k=10,
254
276
  )
277
+ channel_status["profile"] = (
278
+ chstat.OK if profile_hits else chstat.EMPTY
279
+ )
255
280
  if profile_hits:
256
281
  strat.weights["profile"] = 2.0
257
282
  except Exception as exc:
258
283
  logger.warning("Profile channel: %s", exc)
259
284
  profile_hits = []
285
+ channel_status["profile"] = chstat.ERROR
260
286
  else:
261
287
  profile_hits = []
288
+ channel_status["profile"] = chstat.NOT_CONFIGURED
262
289
 
263
290
  # Dynamic top-k for aggregation queries
264
291
  effective_limit = 100 if strat.query_type == "aggregation" else limit
@@ -276,6 +303,7 @@ class RetrievalEngine:
276
303
  as_of=as_of, known_as_of=known_as_of, valid_at=valid_at,
277
304
  include_unknown=include_unknown,
278
305
  dropped_channels=dropped_channels,
306
+ channel_status=channel_status,
279
307
  )
280
308
  _em("run_channels")
281
309
  # One request may need admission before fusion and again after optional
@@ -393,9 +421,29 @@ class RetrievalEngine:
393
421
  # this stage re-scores every fused candidate and then re-sorts them, so
394
422
  # whether it ran decided the top answer outright rather than adding to
395
423
  # it. Bound by data if it ever needs bounding, never by elapsed time.
396
- if (self._entity is not None
397
- and "entity_graph" not in set(self._config.disabled_channels)
398
- and fused):
424
+ if self._entity is None:
425
+ channel_status["entity_graph"] = chstat.NOT_CONFIGURED
426
+ elif "entity_graph" in set(self._config.disabled_channels) | set(
427
+ extra_disabled_channels or (),
428
+ ):
429
+ # Both the configured set AND the per-call one, the same way the
430
+ # profile channel resolves it. Reading only the configured set meant
431
+ # a caller that switched this channel off for one recall still had
432
+ # it run, and still saw it reported as though it were the caller's
433
+ # own setting that was being honoured.
434
+ channel_status["entity_graph"] = chstat.DISABLED
435
+ elif not fused:
436
+ # It did not run, and saying "found nothing" would be a different
437
+ # claim. This channel re-scores other channels' candidates rather
438
+ # than producing its own, so with nothing fused there is nothing for
439
+ # it to do — and if the reason nothing fused is that the other five
440
+ # failed, reporting this one as having searched and come back empty
441
+ # hides that.
442
+ channel_status["entity_graph"] = chstat.NO_CANDIDATES
443
+ else:
444
+ # One chain, evaluated once. Repeating the three conditions to guard
445
+ # the work separately is how a status starts describing a decision
446
+ # the code no longer makes.
399
447
  try:
400
448
  candidate_ids = [fr.fact_id for fr in fused[:100]]
401
449
  eg_scores = self._entity.score_candidates(
@@ -405,6 +453,9 @@ class RetrievalEngine:
405
453
  include_global=include_global,
406
454
  include_shared=include_shared,
407
455
  )
456
+ channel_status["entity_graph"] = (
457
+ chstat.OK if eg_scores else chstat.EMPTY
458
+ )
408
459
  if eg_scores:
409
460
  boosted = []
410
461
  for fr in fused:
@@ -423,6 +474,7 @@ class RetrievalEngine:
423
474
  fused = sorted(boosted, key=lambda r: (-r.fused_score, r.fact_id))
424
475
  except Exception as exc:
425
476
  logger.warning("Entity graph signal enhancement: %s", exc)
477
+ channel_status["entity_graph"] = chstat.ERROR
426
478
 
427
479
  # Brain Core S402: bridge and scene expansion append candidates after
428
480
  # the channel boundary. Reapply the same hard correction-admission rule
@@ -565,6 +617,7 @@ class RetrievalEngine:
565
617
  # community. Precomputed summary lookup only — no per-query LLM.
566
618
  community_context=self._community_context(results, profile_id),
567
619
  incomplete_channels=tuple(sorted(dropped_channels)),
620
+ channel_status=dict(channel_status),
568
621
  )
569
622
 
570
623
  # -- Community context (Wave Q2b) --------------------------------------
@@ -977,6 +1030,7 @@ class RetrievalEngine:
977
1030
  valid_at: str | None = None,
978
1031
  include_unknown: bool = False,
979
1032
  dropped_channels: set[str] | None = None,
1033
+ channel_status: dict[str, str] | None = None,
980
1034
  ) -> dict[str, list[tuple[str, float]]]:
981
1035
  """Run active retrieval channels.
982
1036
 
@@ -994,6 +1048,12 @@ class RetrievalEngine:
994
1048
  than merely late. It is a caller-owned set passed down per recall and
995
1049
  deliberately not an attribute of self — two concurrent recalls sharing
996
1050
  one would report each other's losses (the v3.4.64 race).
1051
+
1052
+ ``channel_status``, likewise caller-owned, receives one entry per
1053
+ channel saying what became of it. Every channel gets exactly one:
1054
+ those that cannot run are recorded before dispatch with the reason, and
1055
+ every dispatched channel is recorded by the collection loop below —
1056
+ which iterates the futures, so it cannot skip one.
997
1057
  """
998
1058
  import os as _os_e
999
1059
  import time as _time_e
@@ -1023,23 +1083,50 @@ class RetrievalEngine:
1023
1083
  except Exception as exc:
1024
1084
  logger.warning("Query embedding failed: %s", exc)
1025
1085
 
1086
+ # Why a channel will not run, recorded BEFORE dispatch. An embedding
1087
+ # failure silently takes three of the five channels down together, and
1088
+ # the answer never said so: it looked exactly like a store with nothing
1089
+ # relevant in it. Configuration and ablation are recorded too, so an
1090
+ # operator reading a list of absent channels can tell their own choices
1091
+ # apart from a fault.
1092
+ if channel_status is not None:
1093
+ for _name, _obj, _needs_emb in (
1094
+ ("semantic", self._semantic, True),
1095
+ ("bm25", self._bm25, False),
1096
+ ("temporal", self._temporal, False),
1097
+ ("hopfield", self._hopfield, True),
1098
+ ("spreading_activation", self._spreading_activation, True),
1099
+ ):
1100
+ if _obj is None:
1101
+ channel_status[_name] = chstat.NOT_CONFIGURED
1102
+ elif _name in disabled:
1103
+ channel_status[_name] = chstat.DISABLED
1104
+ elif _needs_emb and q_emb is None:
1105
+ channel_status[_name] = chstat.NO_EMBEDDING
1106
+
1026
1107
  # v3.4.53: collect channel callables and run in parallel.
1027
1108
  # Each channel is a standalone search — no shared mutable state,
1028
1109
  # no ordering dependencies. SQLite WAL mode permits concurrent reads.
1029
1110
  futures: dict[str, concurrent.futures.Future] = {}
1030
1111
 
1031
1112
  def _safe_channel(name: str, fn, *args):
1032
- """Run a single channel, returning (name, result_or_None)."""
1113
+ """Run a single channel, returning (name, result_or_None, status).
1114
+
1115
+ Returning the status alongside the result is what separates "found
1116
+ nothing" from "raised": both used to come back as ``None``.
1117
+ """
1033
1118
  _cs = _time_e.monotonic() if _et else 0.0
1034
1119
  try:
1035
1120
  res = fn(*args)
1036
1121
  if _et:
1037
1122
  logger.warning("[RECALL-TIMING] channel.%-16s %.0f ms",
1038
1123
  name, (_time_e.monotonic() - _cs) * 1000.0)
1039
- return (name, res if res else None)
1124
+ if res:
1125
+ return (name, res, chstat.OK)
1126
+ return (name, None, chstat.EMPTY)
1040
1127
  except Exception as exc:
1041
1128
  logger.warning("%s channel: %s", name, exc)
1042
- return (name, None)
1129
+ return (name, None, chstat.ERROR)
1043
1130
 
1044
1131
  executor = self._channel_executor
1045
1132
  if self._semantic is not None and q_emb is not None and "semantic" not in disabled:
@@ -1111,14 +1198,22 @@ class RetrievalEngine:
1111
1198
  )
1112
1199
  if dropped_channels is not None:
1113
1200
  dropped_channels.add(name)
1201
+ # Same branch as the dropped set on purpose: two writes in one
1202
+ # place cannot disagree about which channels timed out.
1203
+ if channel_status is not None:
1204
+ channel_status[name] = chstat.TIMEOUT
1114
1205
  fut.cancel() # no-op if already running; prevents queued jobs from starting
1115
1206
  continue
1116
1207
  try:
1117
- ch_name, result = fut.result()
1208
+ ch_name, result, status = fut.result()
1209
+ if channel_status is not None:
1210
+ channel_status[ch_name] = status
1118
1211
  if result:
1119
1212
  out[ch_name] = result
1120
1213
  except Exception as exc:
1121
1214
  logger.warning("Channel %s failed: %s", name, exc)
1215
+ if channel_status is not None:
1216
+ channel_status[name] = chstat.ERROR
1122
1217
 
1123
1218
  # Apply registered post-retrieval filters (forgetting filter, etc.).
1124
1219
  # Pass as_of in context dict when set so the bi-temporal validity filter