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.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -11
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +5 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +60 -1
- package/src/superlocalmemory/cli/main.py +24 -1
- package/src/superlocalmemory/compliance/gdpr.py +104 -73
- package/src/superlocalmemory/contracts/__init__.py +1 -0
- package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
- package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
- package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
- package/src/superlocalmemory/contracts/v402.py +62 -0
- package/src/superlocalmemory/core/engine.py +10 -0
- package/src/superlocalmemory/core/recall_pipeline.py +6 -0
- package/src/superlocalmemory/core/recall_worker.py +12 -0
- package/src/superlocalmemory/core/worker_pool.py +12 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
- package/src/superlocalmemory/hooks/session_registry.py +136 -3
- package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
- package/src/superlocalmemory/integrations/__init__.py +1 -0
- package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
- package/src/superlocalmemory/learning/database.py +21 -14
- package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
- package/src/superlocalmemory/mcp/server.py +5 -0
- package/src/superlocalmemory/mcp/tools_brain.py +132 -0
- package/src/superlocalmemory/mcp/tools_core.py +25 -6
- package/src/superlocalmemory/mcp/tools_v3.py +16 -2
- package/src/superlocalmemory/retrieval/engine.py +43 -1
- package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
- package/src/superlocalmemory/server/routes/brain.py +206 -1
- package/src/superlocalmemory/server/routes/helpers.py +53 -35
- package/src/superlocalmemory/server/routes/v3_api.py +118 -11
- package/src/superlocalmemory/server/ui.py +7 -2
- package/src/superlocalmemory/server/unified_daemon.py +37 -4
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +490 -0
- package/src/superlocalmemory/storage/database.py +189 -34
- package/src/superlocalmemory/storage/migration_runner.py +8 -0
- package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
- package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/schema.py +4 -0
- package/src/superlocalmemory/ui/index.html +2 -2
- package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
- package/src/superlocalmemory/ui/js/brain.js +57 -1
- package/src/superlocalmemory/ui/js/od-brain.js +114 -40
- 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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|