superlocalmemory 4.0.0 → 4.0.2

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 (83) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -11
  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/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  36. package/pyproject.toml +5 -4
  37. package/src/superlocalmemory/__init__.py +1 -1
  38. package/src/superlocalmemory/cli/commands.py +60 -1
  39. package/src/superlocalmemory/cli/main.py +24 -1
  40. package/src/superlocalmemory/compliance/gdpr.py +104 -73
  41. package/src/superlocalmemory/contracts/__init__.py +1 -0
  42. package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
  43. package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
  44. package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
  45. package/src/superlocalmemory/contracts/v402.py +62 -0
  46. package/src/superlocalmemory/core/engine.py +10 -0
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -0
  48. package/src/superlocalmemory/core/recall_worker.py +12 -0
  49. package/src/superlocalmemory/core/worker_pool.py +12 -0
  50. package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
  51. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
  52. package/src/superlocalmemory/hooks/session_registry.py +136 -3
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
  54. package/src/superlocalmemory/integrations/__init__.py +1 -0
  55. package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
  56. package/src/superlocalmemory/learning/database.py +21 -14
  57. package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
  58. package/src/superlocalmemory/mcp/server.py +5 -0
  59. package/src/superlocalmemory/mcp/tools_brain.py +132 -0
  60. package/src/superlocalmemory/mcp/tools_core.py +25 -6
  61. package/src/superlocalmemory/mcp/tools_v3.py +16 -2
  62. package/src/superlocalmemory/retrieval/engine.py +43 -1
  63. package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
  64. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
  65. package/src/superlocalmemory/server/routes/brain.py +206 -1
  66. package/src/superlocalmemory/server/routes/helpers.py +53 -35
  67. package/src/superlocalmemory/server/routes/v3_api.py +118 -11
  68. package/src/superlocalmemory/server/ui.py +7 -2
  69. package/src/superlocalmemory/server/unified_daemon.py +37 -4
  70. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  71. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  72. package/src/superlocalmemory/storage/agent_experience.py +490 -0
  73. package/src/superlocalmemory/storage/database.py +189 -34
  74. package/src/superlocalmemory/storage/migration_runner.py +8 -0
  75. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
  76. package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/schema.py +4 -0
  79. package/src/superlocalmemory/ui/index.html +2 -2
  80. package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
  81. package/src/superlocalmemory/ui/js/brain.js +57 -1
  82. package/src/superlocalmemory/ui/js/od-brain.js +114 -40
  83. package/src/superlocalmemory/ui/js/od-settings.js +8 -1
@@ -97,6 +97,9 @@ _ESSENTIAL_TOOLS: set[str] = {
97
97
  # Feedback / learning signals — reachable Dash-Core path for
98
98
  # thumbs-up / pin / drift signals.
99
99
  "report_feedback",
100
+ # v4.0.2 portable Brain evidence: profile-scoped receipt reads/writes.
101
+ "get_brain_evidence_status", "record_agent_experience",
102
+ "record_cognitive_turn", "finalize_cognitive_turn",
100
103
  # Memory management (2)
101
104
  "forget", "run_maintenance",
102
105
  # NOTE: prestage_context IS registered (see register_prestage_tool below)
@@ -272,6 +275,8 @@ from superlocalmemory.mcp.tools_loops import register_loop_tools
272
275
  register_loop_tools(_target, get_engine) # v3.8.0: bounded-loop tools (CLI+command+MCP)
273
276
  from superlocalmemory.mcp.tools_ops import register_ops_tools
274
277
  register_ops_tools(_target, get_engine) # Wave-3: operational recovery & admin remediation
278
+ from superlocalmemory.mcp.tools_brain import register_brain_tools
279
+ register_brain_tools(_target, get_engine) # v4.0.2 portable Brain receipts
275
280
  from superlocalmemory.mcp.tools_context import register_prestage_tool
276
281
 
277
282
 
@@ -0,0 +1,132 @@
1
+ """Portable Agent Experience receipt tools for the SLM Brain.
2
+
3
+ Receipts are scoped to the MCP engine's active profile. A host cannot write
4
+ to another profile by supplying a different ``profile_id`` in its payload.
5
+ The receipts are evidence/observability records only: recall and ranking do
6
+ not consume them synchronously, so an unavailable receipt store never slows
7
+ or changes a memory answer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ from mcp.types import ToolAnnotations
16
+
17
+ from superlocalmemory.core.admission import admits
18
+ from superlocalmemory.core.operation_request import OperationKind
19
+ from superlocalmemory.infra.data_root import state_path
20
+ from superlocalmemory.storage.agent_experience import (
21
+ AgentExperienceConflictError,
22
+ AgentExperienceStore,
23
+ CognitiveTurnTransitionError,
24
+ LearningWriteBusyError,
25
+ ProfileAdmissionError,
26
+ get_profile_receipt_summary,
27
+ )
28
+
29
+
30
+ def _store_for(engine: Any) -> AgentExperienceStore:
31
+ active_profile = engine.profile_id
32
+ return AgentExperienceStore(
33
+ Path(state_path("learning.db")),
34
+ is_profile_active=lambda profile_id: profile_id == active_profile,
35
+ )
36
+
37
+
38
+ def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
39
+ supplied = payload.get("profile_id")
40
+ if supplied != engine.profile_id:
41
+ return "profile_id must equal the active MCP profile"
42
+ return None
43
+
44
+
45
+ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
46
+ """Register transport-neutral receipt reads and writes.
47
+
48
+ The same tools work through Codex, Claude, Cursor, VS Code, and direct
49
+ HTTP MCP. Callers supply contract-shaped JSON; the active-profile check
50
+ keeps multi-profile data isolated at the public boundary.
51
+ """
52
+
53
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
54
+ async def get_brain_evidence_status() -> dict[str, Any]:
55
+ """Get profile-scoped Agent Experience and Cognitive Turn totals."""
56
+ engine = get_engine()
57
+ return {
58
+ "success": True,
59
+ "profile_id": engine.profile_id,
60
+ "agent_experience": get_profile_receipt_summary(
61
+ state_path("learning.db"), engine.profile_id
62
+ ),
63
+ "control_plane": "observation_only",
64
+ }
65
+
66
+ @server.tool()
67
+ @admits(OperationKind.REMEMBER)
68
+ async def record_agent_experience(payload: dict[str, Any]) -> dict[str, Any]:
69
+ """Record a contract-validated terminal experience receipt.
70
+
71
+ Supply only evidence you can substantiate. SLM records the declared
72
+ verification authority and does not use this receipt to alter recall,
73
+ ranking, or model routing automatically.
74
+ """
75
+ engine = get_engine()
76
+ error = _require_active_profile(engine, payload)
77
+ if error:
78
+ return {"success": False, "durable": False, "error": error}
79
+ try:
80
+ created = _store_for(engine).record_experience(payload)
81
+ except (AgentExperienceConflictError, ProfileAdmissionError) as exc:
82
+ return {"success": False, "durable": False, "error": str(exc)}
83
+ except LearningWriteBusyError as exc:
84
+ return {"success": False, "durable": False, "retryable": True, "error": str(exc)}
85
+ except (TypeError, ValueError) as exc:
86
+ return {"success": False, "durable": False, "error": str(exc)}
87
+ return {"success": True, "durable": True, "created": created}
88
+
89
+ @server.tool()
90
+ @admits(OperationKind.REMEMBER)
91
+ async def record_cognitive_turn(payload: dict[str, Any]) -> dict[str, Any]:
92
+ """Open one contract-validated cognitive-turn provenance receipt."""
93
+ engine = get_engine()
94
+ error = _require_active_profile(engine, payload)
95
+ if error:
96
+ return {"success": False, "durable": False, "error": error}
97
+ try:
98
+ created = _store_for(engine).create_cognitive_turn(payload)
99
+ except (
100
+ AgentExperienceConflictError,
101
+ CognitiveTurnTransitionError,
102
+ ProfileAdmissionError,
103
+ ) as exc:
104
+ return {"success": False, "durable": False, "error": str(exc)}
105
+ except LearningWriteBusyError as exc:
106
+ return {"success": False, "durable": False, "retryable": True, "error": str(exc)}
107
+ except (TypeError, ValueError) as exc:
108
+ return {"success": False, "durable": False, "error": str(exc)}
109
+ return {"success": True, "durable": True, "created": created}
110
+
111
+ @server.tool()
112
+ @admits(OperationKind.REMEMBER)
113
+ async def finalize_cognitive_turn(
114
+ receipt_id: str, outcome: dict[str, Any]
115
+ ) -> dict[str, Any]:
116
+ """Finalize an active-profile cognitive turn with outcome evidence."""
117
+ engine = get_engine()
118
+ try:
119
+ finalized = _store_for(engine).finalize_cognitive_turn(
120
+ engine.profile_id, receipt_id, outcome
121
+ )
122
+ except (
123
+ AgentExperienceConflictError,
124
+ CognitiveTurnTransitionError,
125
+ ProfileAdmissionError,
126
+ ) as exc:
127
+ return {"success": False, "durable": False, "error": str(exc)}
128
+ except LearningWriteBusyError as exc:
129
+ return {"success": False, "durable": False, "retryable": True, "error": str(exc)}
130
+ except (TypeError, ValueError) as exc:
131
+ return {"success": False, "durable": False, "error": str(exc)}
132
+ return {"success": True, "durable": True, "finalized": finalized}
@@ -272,6 +272,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
272
272
  include_shared: bool | None = None,
273
273
  window: str = "",
274
274
  as_of: str | None = None,
275
+ known_as_of: str | None = None,
276
+ valid_at: str | None = None,
277
+ include_unknown: bool = False,
275
278
  ) -> dict:
276
279
  """Search memories through hybrid retrieval, RRF fusion, and reranking.
277
280
 
@@ -369,13 +372,26 @@ def register_core_tools(server, get_engine: Callable) -> None:
369
372
  # Audit P2: treat empty/whitespace as_of as ABSENT (like HTTP does),
370
373
  # not as an invalid value — only a non-blank unparseable string is
371
374
  # rejected.
372
- if as_of is not None and str(as_of).strip():
375
+ def _normalize_temporal(value: str | None) -> str | None:
376
+ if value is None or not str(value).strip():
377
+ return None
373
378
  from superlocalmemory.retrieval.temporal_utils import normalize_as_of
374
- as_of = normalize_as_of(as_of)
375
- if as_of is None:
376
- return {"success": False, "error": "invalid_as_of"}
377
- else:
378
- as_of = None
379
+ return normalize_as_of(value)
380
+
381
+ raw_as_of, raw_known_as_of, raw_valid_at = as_of, known_as_of, valid_at
382
+ as_of = _normalize_temporal(raw_as_of)
383
+ known_as_of = _normalize_temporal(raw_known_as_of)
384
+ valid_at = _normalize_temporal(raw_valid_at)
385
+ # Preserve backwards-compatible as_of validation while exposing
386
+ # named two-clock boundaries. A supplied non-blank invalid value
387
+ # is rejected rather than silently becoming current recall.
388
+ for raw, normalized, code in (
389
+ (raw_as_of, as_of, "invalid_as_of"),
390
+ (raw_known_as_of, known_as_of, "invalid_known_as_of"),
391
+ (raw_valid_at, valid_at, "invalid_valid_at"),
392
+ ):
393
+ if raw is not None and str(raw).strip() and normalized is None:
394
+ return {"success": False, "error": code}
379
395
 
380
396
  from superlocalmemory.core.admission import enforce_read_scope
381
397
  _incl_global, _incl_shared = enforce_read_scope(include_global, include_shared)
@@ -387,6 +403,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
387
403
  fast=fast, include_global=_incl_global,
388
404
  include_shared=_incl_shared, window=window or None,
389
405
  as_of=as_of,
406
+ known_as_of=known_as_of,
407
+ valid_at=valid_at,
408
+ include_unknown=include_unknown,
390
409
  )
391
410
 
392
411
  result = await asyncio.to_thread(
@@ -286,6 +286,9 @@ def register_v3_tools(server, get_engine: Callable) -> None:
286
286
  query: str,
287
287
  limit: int = 10,
288
288
  as_of: str | None = None,
289
+ known_as_of: str | None = None,
290
+ valid_at: str | None = None,
291
+ include_unknown: bool = False,
289
292
  ) -> dict:
290
293
  """Recall with per-channel score breakdown.
291
294
 
@@ -301,7 +304,9 @@ def register_v3_tools(server, get_engine: Callable) -> None:
301
304
  try:
302
305
  import asyncio
303
306
  from superlocalmemory.mcp._daemon_proxy import choose_pool
304
- from superlocalmemory.retrieval.temporal_utils import normalize_as_of
307
+ from superlocalmemory.retrieval.temporal_utils import (
308
+ normalize_as_of, normalize_strict_boundary,
309
+ )
305
310
 
306
311
  # Normalize at MCP boundary before forwarding.
307
312
  _as_of: str | None = None
@@ -309,11 +314,20 @@ def register_v3_tools(server, get_engine: Callable) -> None:
309
314
  _as_of = normalize_as_of(as_of)
310
315
  if _as_of is None:
311
316
  return {"success": False, "error": "invalid_as_of"}
317
+ try:
318
+ _known_as_of = normalize_strict_boundary(known_as_of, "known_as_of")
319
+ _valid_at = normalize_strict_boundary(valid_at, "valid_at")
320
+ except ValueError as exc:
321
+ return {"success": False, "error": str(exc)}
312
322
 
313
323
  # choose_pool().recall uses blocking urllib; run off the event loop
314
324
  # so recall_trace doesn't stall the MCP server for other tools.
315
325
  raw = await asyncio.to_thread(
316
- lambda: choose_pool().recall(query=query, limit=limit, as_of=_as_of)
326
+ lambda: choose_pool().recall(
327
+ query=query, limit=limit, as_of=_as_of,
328
+ known_as_of=_known_as_of, valid_at=_valid_at,
329
+ include_unknown=include_unknown,
330
+ )
317
331
  )
318
332
  items = raw.get("results", []) if isinstance(raw, dict) else []
319
333
  results = []
@@ -28,6 +28,10 @@ from typing import TYPE_CHECKING, Any, Protocol
28
28
  from superlocalmemory.core.config import ChannelWeights, RetrievalConfig
29
29
  from superlocalmemory.retrieval.fusion import FusionResult, weighted_rrf
30
30
  from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
31
+ from superlocalmemory.retrieval.temporal_validity_filter import (
32
+ admit_correction_candidates,
33
+ admit_correction_fusion_results,
34
+ )
31
35
  from superlocalmemory.retrieval.time_window import (
32
36
  in_window,
33
37
  infer_window_from_query,
@@ -156,6 +160,9 @@ class RetrievalEngine:
156
160
  include_shared: bool = False,
157
161
  window: str | tuple[str, str] | None = None,
158
162
  as_of: str | None = None,
163
+ known_as_of: str | None = None,
164
+ valid_at: str | None = None,
165
+ include_unknown: bool = False,
159
166
  ) -> RecallResponse:
160
167
  """Full retrieval pipeline: strategy -> channels -> RRF -> rerank.
161
168
 
@@ -172,6 +179,9 @@ class RetrievalEngine:
172
179
  not-yet-valid and already-expired facts are demoted. Default ``None``
173
180
  leaves all existing behaviour unchanged.
174
181
  """
182
+ from superlocalmemory.retrieval.temporal_utils import normalize_strict_boundary
183
+ known_as_of = normalize_strict_boundary(known_as_of, "known_as_of")
184
+ valid_at = normalize_strict_boundary(valid_at, "valid_at")
175
185
  t0 = time.monotonic()
176
186
  # NOTE: extra_disabled_channels is passed as an explicit local argument
177
187
  # to _run_channels() — it is NOT stored on self. Storing it as a shared
@@ -218,11 +228,20 @@ class RetrievalEngine:
218
228
  query, profile_id, strat,
219
229
  extra_disabled_channels=extra_disabled_channels,
220
230
  include_global=include_global, include_shared=include_shared,
221
- as_of=as_of,
231
+ as_of=as_of, known_as_of=known_as_of, valid_at=valid_at,
232
+ include_unknown=include_unknown,
222
233
  )
223
234
  _em("run_channels")
224
235
  if profile_hits:
225
236
  ch_results["profile"] = profile_hits
237
+ # The profile shortcut bypasses _run_channels(), so it needs the same
238
+ # admission before it can influence fusion or seed graph expansion.
239
+ ch_results = admit_correction_candidates(
240
+ ch_results, profile_id, self._db, as_of=as_of,
241
+ known_as_of=known_as_of, valid_at=valid_at,
242
+ include_unknown=include_unknown,
243
+ include_global=include_global, include_shared=include_shared,
244
+ )
226
245
  total = sum(len(v) for v in ch_results.values())
227
246
 
228
247
  # 3. Single-pass RRF fusion
@@ -330,6 +349,16 @@ class RetrievalEngine:
330
349
  except Exception as exc:
331
350
  logger.warning("Entity graph signal enhancement: %s", exc)
332
351
 
352
+ # Brain Core S402: bridge and scene expansion append candidates after
353
+ # the channel boundary. Reapply the same hard correction-admission rule
354
+ # immediately before any candidate can be materialized or reranked.
355
+ fused = admit_correction_fusion_results(
356
+ fused, profile_id, self._db, as_of=as_of,
357
+ known_as_of=known_as_of, valid_at=valid_at,
358
+ include_unknown=include_unknown,
359
+ include_global=include_global, include_shared=include_shared,
360
+ )
361
+
333
362
  _em("expand+entity_enh")
334
363
 
335
364
  # T-window: prune candidates to the requested event-time range.
@@ -773,6 +802,9 @@ class RetrievalEngine:
773
802
  include_global: bool = False,
774
803
  include_shared: bool = False,
775
804
  as_of: str | None = None,
805
+ known_as_of: str | None = None,
806
+ valid_at: str | None = None,
807
+ include_unknown: bool = False,
776
808
  ) -> dict[str, list[tuple[str, float]]]:
777
809
  """Run active retrieval channels.
778
810
 
@@ -917,6 +949,16 @@ class RetrievalEngine:
917
949
  except Exception as exc:
918
950
  logger.warning("Post-retrieval filter failed: %s", exc)
919
951
 
952
+ # The legacy temporal filter preserves its score-demotion semantics for
953
+ # compatibility. Admission is separate and hard: no current
954
+ # system-superseded fact may seed fusion, bridge discovery, or rerank.
955
+ out = admit_correction_candidates(
956
+ out, profile_id, self._db, as_of=as_of,
957
+ known_as_of=known_as_of, valid_at=valid_at,
958
+ include_unknown=include_unknown,
959
+ include_global=include_global, include_shared=include_shared,
960
+ )
961
+
920
962
  return out
921
963
 
922
964
  def close(self, *, wait: bool = False) -> None:
@@ -104,4 +104,19 @@ def normalize_as_of(s: object) -> Optional[str]:
104
104
  return None
105
105
 
106
106
 
107
- __all__ = ("normalize_as_of",)
107
+ def normalize_strict_boundary(value: object, name: str) -> Optional[str]:
108
+ """Normalize an optional strict boundary or reject an invalid supplied value.
109
+
110
+ Unlike legacy ``as_of`` direct calls, explicit two-clock queries cannot
111
+ quietly degrade to current recall: doing so would make a malformed history
112
+ request appear authoritative.
113
+ """
114
+ if value is None or (isinstance(value, str) and not value.strip()):
115
+ return None
116
+ normalized = normalize_as_of(value)
117
+ if normalized is None:
118
+ raise ValueError(f"{name} must be an ISO-8601 timestamp")
119
+ return normalized
120
+
121
+
122
+ __all__ = ("normalize_as_of", "normalize_strict_boundary")
@@ -79,6 +79,157 @@ logger = logging.getLogger(__name__)
79
79
  _EVENT_TIME_DEMOTION_FACTOR: float = 0.5
80
80
 
81
81
 
82
+ def _normalized_as_of(as_of: str | None) -> str | None:
83
+ """Normalize an optional transaction-time boundary at one boundary.
84
+
85
+ The MCP/HTTP adapters already reject invalid non-blank values. Engine
86
+ callers are also public, however, so an invalid direct value must degrade
87
+ to current recall rather than turn a read into an exception.
88
+ """
89
+ if as_of is None:
90
+ return None
91
+ from superlocalmemory.retrieval.temporal_utils import normalize_as_of
92
+ return normalize_as_of(as_of)
93
+
94
+
95
+ def _invalidated_candidate_ids(
96
+ db: DatabaseManager,
97
+ fact_ids: set[str],
98
+ profile_id: str,
99
+ *,
100
+ as_of: str | None = None,
101
+ include_global: bool = False,
102
+ include_shared: bool = False,
103
+ ) -> set[str] | None:
104
+ """Return admissibility failures, or ``None`` when the lookup is unhealthy.
105
+
106
+ ``None`` deliberately preserves the established fail-open availability
107
+ contract. It is distinct from an empty set: a bad/mocked return value must
108
+ not accidentally exclude arbitrary candidates.
109
+ """
110
+ if not fact_ids:
111
+ return set()
112
+ try:
113
+ kwargs: dict[str, Any] = {"as_of": _normalized_as_of(as_of)}
114
+ if include_global:
115
+ kwargs["include_global"] = True
116
+ if include_shared:
117
+ kwargs["include_shared"] = True
118
+ invalid = db.get_invalidated_fact_ids(list(fact_ids), profile_id, **kwargs)
119
+ except Exception as exc:
120
+ logger.warning("Correction admission lookup failed: %s", exc)
121
+ return None
122
+ return invalid if isinstance(invalid, set) else None
123
+
124
+
125
+ def _strict_temporal_candidate_ids(
126
+ db: DatabaseManager,
127
+ fact_ids: set[str],
128
+ profile_id: str,
129
+ *,
130
+ known_as_of: str | None = None,
131
+ valid_at: str | None = None,
132
+ include_unknown: bool = False,
133
+ include_global: bool = False,
134
+ include_shared: bool = False,
135
+ ) -> set[str] | None:
136
+ """Return strict two-clock admission failures, fail-open on lookup error."""
137
+ if not fact_ids or (known_as_of is None and valid_at is None):
138
+ return set()
139
+ try:
140
+ invalid = db.get_strict_temporal_inadmissible_fact_ids(
141
+ list(fact_ids), profile_id,
142
+ known_as_of=_normalized_as_of(known_as_of),
143
+ valid_at=_normalized_as_of(valid_at),
144
+ include_unknown=include_unknown,
145
+ include_global=include_global,
146
+ include_shared=include_shared,
147
+ )
148
+ except Exception as exc:
149
+ logger.warning("Strict temporal admission lookup failed: %s", exc)
150
+ return None
151
+ return invalid if isinstance(invalid, set) else None
152
+
153
+
154
+ def admit_correction_candidates(
155
+ all_results: dict[str, list[tuple[str, float]]],
156
+ profile_id: str,
157
+ db: DatabaseManager,
158
+ *,
159
+ as_of: str | None = None,
160
+ known_as_of: str | None = None,
161
+ valid_at: str | None = None,
162
+ include_unknown: bool = False,
163
+ include_global: bool = False,
164
+ include_shared: bool = False,
165
+ ) -> dict[str, list[tuple[str, float]]]:
166
+ """Hard-exclude system-superseded facts before candidate fusion.
167
+
168
+ This is the SLM 4.0.2 Brain Core admission invariant. Ranking is unable to
169
+ enforce a correction because a high-scoring stale fact can still win. The
170
+ historical ``as_of`` boundary preserves facts that had not yet been
171
+ superseded at that point, so current corrections do not rewrite history.
172
+ """
173
+ fact_ids = {
174
+ fact_id
175
+ for channel_results in all_results.values()
176
+ for fact_id, _ in channel_results
177
+ }
178
+ invalid = _invalidated_candidate_ids(
179
+ db, fact_ids, profile_id, as_of=as_of,
180
+ include_global=include_global, include_shared=include_shared,
181
+ )
182
+ strict = _strict_temporal_candidate_ids(
183
+ db, fact_ids, profile_id,
184
+ known_as_of=known_as_of, valid_at=valid_at,
185
+ include_unknown=include_unknown,
186
+ include_global=include_global, include_shared=include_shared,
187
+ )
188
+ if strict:
189
+ invalid = (invalid or set()) | strict
190
+ if not invalid:
191
+ return all_results
192
+ return {
193
+ channel_name: [
194
+ (fact_id, score)
195
+ for fact_id, score in channel_results
196
+ if fact_id not in invalid
197
+ ]
198
+ for channel_name, channel_results in all_results.items()
199
+ }
200
+
201
+
202
+ def admit_correction_fusion_results(
203
+ fused_results: list[Any],
204
+ profile_id: str,
205
+ db: DatabaseManager,
206
+ *,
207
+ as_of: str | None = None,
208
+ known_as_of: str | None = None,
209
+ valid_at: str | None = None,
210
+ include_unknown: bool = False,
211
+ include_global: bool = False,
212
+ include_shared: bool = False,
213
+ ) -> list[Any]:
214
+ """Re-apply correction admission after graph/scene candidate expansion."""
215
+ fact_ids = {result.fact_id for result in fused_results}
216
+ invalid = _invalidated_candidate_ids(
217
+ db, fact_ids, profile_id, as_of=as_of,
218
+ include_global=include_global, include_shared=include_shared,
219
+ )
220
+ strict = _strict_temporal_candidate_ids(
221
+ db, fact_ids, profile_id,
222
+ known_as_of=known_as_of, valid_at=valid_at,
223
+ include_unknown=include_unknown,
224
+ include_global=include_global, include_shared=include_shared,
225
+ )
226
+ if strict:
227
+ invalid = (invalid or set()) | strict
228
+ if not invalid:
229
+ return fused_results
230
+ return [result for result in fused_results if result.fact_id not in invalid]
231
+
232
+
82
233
  class TemporalValidityFilter:
83
234
  """Demotes bi-temporally invalid facts in retrieval candidates.
84
235