superlocalmemory 4.0.4 → 4.0.6
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 +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- 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 +3 -2
- 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 +2 -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 +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -110,12 +110,30 @@ def is_remote_cross_encoder_backend(backend: str) -> bool:
|
|
|
110
110
|
return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
def validate_remote_reranker_config(
|
|
113
|
+
def validate_remote_reranker_config(
|
|
114
|
+
backend: str,
|
|
115
|
+
endpoint: str,
|
|
116
|
+
trust_plain_http_lan: bool = True,
|
|
117
|
+
) -> str | None:
|
|
114
118
|
"""Return an actionable error string, or None when the pair is coherent.
|
|
115
119
|
|
|
116
120
|
Covers the issue-#103 leftover directly: an endpoint configured against a
|
|
117
121
|
LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
|
|
118
122
|
produces a named error naming both keys and the exact edit to make.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
backend: Value of ``retrieval.cross_encoder_backend``.
|
|
126
|
+
endpoint: Value of ``retrieval.cross_encoder_endpoint``.
|
|
127
|
+
trust_plain_http_lan: When True (the default), numeric RFC1918/ULA/
|
|
128
|
+
link-local addresses may use plain HTTP — the same security posture
|
|
129
|
+
as the local reranker, where memory text only crosses loopback.
|
|
130
|
+
Set to False in hardened deployments (zero-trust networks, shared
|
|
131
|
+
colocation) to require HTTPS for all non-loopback hosts.
|
|
132
|
+
|
|
133
|
+
Threat model note: trusting a private-LAN address does NOT prevent a
|
|
134
|
+
MITM attack by an adversary on the same physical LAN (e.g. via ARP
|
|
135
|
+
spoofing). This flag means "the LAN is under my control and I accept that
|
|
136
|
+
risk." It is not a claim that RFC1918 traffic is cryptographically secure.
|
|
119
137
|
"""
|
|
120
138
|
backend = (backend or "").strip()
|
|
121
139
|
endpoint = (endpoint or "").strip()
|
|
@@ -139,11 +157,23 @@ def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
|
|
|
139
157
|
)
|
|
140
158
|
if not remote:
|
|
141
159
|
return None
|
|
142
|
-
return _validate_endpoint_url(endpoint)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _validate_endpoint_url(
|
|
146
|
-
|
|
160
|
+
return _validate_endpoint_url(endpoint, trust_plain_http_lan=trust_plain_http_lan)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_endpoint_url(
|
|
164
|
+
endpoint: str,
|
|
165
|
+
trust_plain_http_lan: bool = True,
|
|
166
|
+
) -> str | None:
|
|
167
|
+
"""Scheme/host allow-listing for the operator-supplied rerank URL.
|
|
168
|
+
|
|
169
|
+
Plain-HTTP allowances (most-to-least trusted):
|
|
170
|
+
1. Loopback (127.x, ::1, localhost) — always allowed.
|
|
171
|
+
2. Numeric RFC1918/ULA/link-local addresses — allowed when
|
|
172
|
+
``trust_plain_http_lan`` is True (the default). Only numeric
|
|
173
|
+
addresses qualify; bare hostnames are never trusted because DNS is
|
|
174
|
+
mutable and not a trust boundary.
|
|
175
|
+
3. Everything else (public IPs, bare hostnames) — always requires HTTPS.
|
|
176
|
+
"""
|
|
147
177
|
try:
|
|
148
178
|
parsed = urlparse(endpoint)
|
|
149
179
|
except ValueError as exc:
|
|
@@ -177,11 +207,39 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
|
|
|
177
207
|
"retrieval.cross_encoder_api_key; it is sent as a Bearer header "
|
|
178
208
|
"and never logged."
|
|
179
209
|
)
|
|
180
|
-
if parsed.scheme == "http"
|
|
210
|
+
if parsed.scheme == "http":
|
|
211
|
+
hostname = parsed.hostname
|
|
212
|
+
if _is_loopback_host(hostname):
|
|
213
|
+
return None # loopback always allowed regardless of trust flag
|
|
214
|
+
if trust_plain_http_lan and _is_private_lan_host(hostname):
|
|
215
|
+
# Numeric private address on an operator-trusted LAN. Threat model:
|
|
216
|
+
# an attacker on the same physical LAN can still MITM plain HTTP
|
|
217
|
+
# (ARP spoofing). This is allowed because the LAN is assumed to be
|
|
218
|
+
# under the operator's control. Set trust_plain_http_lan=False in
|
|
219
|
+
# hardened/zero-trust environments.
|
|
220
|
+
return None
|
|
221
|
+
if not _is_private_lan_host(hostname):
|
|
222
|
+
# Public IP, CGNAT, or a bare hostname (DNS not trusted as a
|
|
223
|
+
# proof of locality). Bare hostnames that happen to resolve to
|
|
224
|
+
# private IPs are NOT trusted: DNS can be poisoned or changed,
|
|
225
|
+
# so only provably-private numeric addresses are accepted.
|
|
226
|
+
return (
|
|
227
|
+
"retrieval.cross_encoder_endpoint must use HTTPS for this "
|
|
228
|
+
"host. Plain HTTP is allowed only for loopback "
|
|
229
|
+
"(127.x/::1/localhost) and numeric private-LAN addresses "
|
|
230
|
+
"(RFC1918: 10.x, 172.16-31.x, 192.168.x; IPv6 ULA fc00::/7; "
|
|
231
|
+
"link-local 169.254.x/fe80::). "
|
|
232
|
+
"Bare hostnames are not trusted even if they resolve to a "
|
|
233
|
+
"private IP — use a numeric address or configure HTTPS."
|
|
234
|
+
)
|
|
235
|
+
# Private-LAN address but trust_plain_http_lan is False (hardened mode)
|
|
181
236
|
return (
|
|
182
|
-
"retrieval.cross_encoder_endpoint
|
|
183
|
-
"
|
|
184
|
-
"
|
|
237
|
+
"retrieval.cross_encoder_endpoint uses plain HTTP to a "
|
|
238
|
+
"private-LAN address. HTTPS is required because "
|
|
239
|
+
"retrieval.trust_plain_http_lan is set to false. "
|
|
240
|
+
"Either configure a TLS-terminating proxy on the reranker, or "
|
|
241
|
+
"set retrieval.trust_plain_http_lan=true to permit plain HTTP "
|
|
242
|
+
"within your private network (default for new installs)."
|
|
185
243
|
)
|
|
186
244
|
return None
|
|
187
245
|
|
|
@@ -197,6 +255,42 @@ def _is_loopback_host(hostname: str) -> bool:
|
|
|
197
255
|
return False
|
|
198
256
|
|
|
199
257
|
|
|
258
|
+
def _is_private_lan_host(hostname: str) -> bool:
|
|
259
|
+
"""True only for numeric private-range addresses (RFC1918, ULA, link-local).
|
|
260
|
+
|
|
261
|
+
Deliberate non-DNS: bare hostnames (e.g. ``my-reranker.lan``) return False
|
|
262
|
+
even if they currently resolve to a private IP. DNS is mutable and not a
|
|
263
|
+
trust boundary — an adversary who can influence DNS resolution can redirect
|
|
264
|
+
the endpoint to a public host, defeating the locality check. Only numeric
|
|
265
|
+
addresses are provably bound to a private range at configuration time.
|
|
266
|
+
|
|
267
|
+
Accepted ranges (Python 3.11+ ``ipaddress.is_private``):
|
|
268
|
+
IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
269
|
+
IPv4 link-local: 169.254.0.0/16
|
|
270
|
+
IPv6 ULA: fc00::/7 (includes fd00::/8)
|
|
271
|
+
IPv6 link-local: fe80::/10
|
|
272
|
+
|
|
273
|
+
Excluded ranges (not accepted for plain HTTP):
|
|
274
|
+
CGNAT 100.64.0.0/10 — ISP-shared address space, not operator-controlled
|
|
275
|
+
172.15.0.0/8 and 172.32.0.0/8 — outside the 172.16.0.0/12 boundary
|
|
276
|
+
Public unicast addresses
|
|
277
|
+
|
|
278
|
+
IPv4-mapped IPv6 addresses (``::ffff:192.168.1.1``) are unwrapped to their
|
|
279
|
+
IPv4 equivalent before the range check, so they are handled consistently.
|
|
280
|
+
"""
|
|
281
|
+
host = (hostname or "").rstrip(".").lower()
|
|
282
|
+
try:
|
|
283
|
+
addr = ipaddress.ip_address(host)
|
|
284
|
+
except ValueError:
|
|
285
|
+
# Not a numeric address — bare hostname, not provably private
|
|
286
|
+
return False
|
|
287
|
+
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.1 → 192.168.1.1) so the
|
|
288
|
+
# RFC1918 check applies to the IPv4 portion.
|
|
289
|
+
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
|
290
|
+
addr = addr.ipv4_mapped
|
|
291
|
+
return addr.is_private
|
|
292
|
+
|
|
293
|
+
|
|
200
294
|
def normalize_rerank_endpoint(endpoint: str) -> str:
|
|
201
295
|
"""Append ``/rerank`` when the URL stops at the API root.
|
|
202
296
|
|
|
@@ -370,8 +464,11 @@ class RemoteReranker:
|
|
|
370
464
|
api_key: str = "",
|
|
371
465
|
backend: str = "openai",
|
|
372
466
|
timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
|
|
467
|
+
trust_plain_http_lan: bool = True,
|
|
373
468
|
) -> None:
|
|
374
|
-
error = validate_remote_reranker_config(
|
|
469
|
+
error = validate_remote_reranker_config(
|
|
470
|
+
backend, endpoint, trust_plain_http_lan=trust_plain_http_lan,
|
|
471
|
+
)
|
|
375
472
|
if error:
|
|
376
473
|
raise RemoteRerankerConfigError(error)
|
|
377
474
|
|
|
@@ -49,8 +49,12 @@ All demotions are non-destructive (P5-INT-01): facts stay in the candidate
|
|
|
49
49
|
list but rank below valid facts. A factor of 0.0 restores the legacy hide
|
|
50
50
|
behaviour (a score of zero is gated out by the evidence floor).
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
fail-
|
|
52
|
+
All correction-admission lookups are bounded (candidate ids only), chunked,
|
|
53
|
+
and indexed. Admission is deliberately **fail-closed**: if SLM cannot prove
|
|
54
|
+
which candidates are invalidated, it returns no candidates rather than let an
|
|
55
|
+
approved stale fact re-enter recall. The legacy score-demotion filter follows
|
|
56
|
+
the same rule for its system-invalidated lookup. Event-time demotion remains a
|
|
57
|
+
best-effort ranking signal; it is not the correction authority.
|
|
54
58
|
|
|
55
59
|
Integrates with ChannelRegistry.register_filter() using the FilterFn signature:
|
|
56
60
|
(all_channel_results, profile_id, context) -> filtered_results
|
|
@@ -62,6 +66,7 @@ License: AGPL-3.0-or-later
|
|
|
62
66
|
from __future__ import annotations
|
|
63
67
|
|
|
64
68
|
import logging
|
|
69
|
+
from dataclasses import dataclass, field
|
|
65
70
|
from typing import TYPE_CHECKING, Any
|
|
66
71
|
|
|
67
72
|
if TYPE_CHECKING:
|
|
@@ -79,6 +84,21 @@ logger = logging.getLogger(__name__)
|
|
|
79
84
|
_EVENT_TIME_DEMOTION_FACTOR: float = 0.5
|
|
80
85
|
|
|
81
86
|
|
|
87
|
+
@dataclass
|
|
88
|
+
class CorrectionAdmissionCache:
|
|
89
|
+
"""Per-recall lifecycle admission cache.
|
|
90
|
+
|
|
91
|
+
The retrieval engine performs a mandatory second admission after bridge or
|
|
92
|
+
scene expansion. Facts already checked before fusion do not need a second
|
|
93
|
+
database read in the *same* recall, while any newly expanded id is checked
|
|
94
|
+
immediately. The cache never crosses requests, profiles, or DB writes.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
checked_fact_ids: set[str] = field(default_factory=set)
|
|
98
|
+
inadmissible_fact_ids: set[str] = field(default_factory=set)
|
|
99
|
+
unavailable: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
82
102
|
def _normalized_as_of(as_of: str | None) -> str | None:
|
|
83
103
|
"""Normalize an optional transaction-time boundary at one boundary.
|
|
84
104
|
|
|
@@ -100,26 +120,69 @@ def _invalidated_candidate_ids(
|
|
|
100
120
|
as_of: str | None = None,
|
|
101
121
|
include_global: bool = False,
|
|
102
122
|
include_shared: bool = False,
|
|
123
|
+
lifecycle_cache: CorrectionAdmissionCache | None = None,
|
|
103
124
|
) -> set[str] | None:
|
|
104
|
-
"""Return
|
|
125
|
+
"""Return correction failures, or ``None`` when admission is unprovable.
|
|
105
126
|
|
|
106
|
-
``None``
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
``None`` is intentionally distinct from an empty set. Callers MUST turn
|
|
128
|
+
it into an abstention (an empty candidate path), never treat it as "nothing
|
|
129
|
+
invalidated". Treating an unavailable lifecycle read as an empty set is a
|
|
130
|
+
fail-open path through which an approved stale fact can be re-admitted.
|
|
109
131
|
"""
|
|
110
132
|
if not fact_ids:
|
|
111
133
|
return set()
|
|
134
|
+
if lifecycle_cache is not None and lifecycle_cache.unavailable:
|
|
135
|
+
return None
|
|
136
|
+
unchecked = (
|
|
137
|
+
fact_ids - lifecycle_cache.checked_fact_ids
|
|
138
|
+
if lifecycle_cache is not None
|
|
139
|
+
else fact_ids
|
|
140
|
+
)
|
|
141
|
+
if not unchecked:
|
|
142
|
+
return (
|
|
143
|
+
lifecycle_cache.inadmissible_fact_ids & fact_ids
|
|
144
|
+
if lifecycle_cache is not None
|
|
145
|
+
else set()
|
|
146
|
+
)
|
|
112
147
|
try:
|
|
113
148
|
kwargs: dict[str, Any] = {"as_of": _normalized_as_of(as_of)}
|
|
114
149
|
if include_global:
|
|
115
150
|
kwargs["include_global"] = True
|
|
116
151
|
if include_shared:
|
|
117
152
|
kwargs["include_shared"] = True
|
|
118
|
-
|
|
153
|
+
# Do not infer support from a permissive mock's dynamic attributes.
|
|
154
|
+
# The concrete storage manager owns this optimized contract; older
|
|
155
|
+
# adapters continue through the two focused public queries below.
|
|
156
|
+
combined = getattr(type(db), "get_correction_inadmissible_fact_ids", None)
|
|
157
|
+
if callable(combined):
|
|
158
|
+
invalid = db.get_correction_inadmissible_fact_ids(
|
|
159
|
+
list(unchecked), profile_id, **kwargs,
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
invalid = db.get_invalidated_fact_ids(list(unchecked), profile_id, **kwargs)
|
|
163
|
+
pending_successors = db.get_nonapplied_correction_successor_ids(
|
|
164
|
+
list(unchecked),
|
|
165
|
+
profile_id,
|
|
166
|
+
include_global=include_global,
|
|
167
|
+
include_shared=include_shared,
|
|
168
|
+
)
|
|
169
|
+
if not isinstance(pending_successors, set):
|
|
170
|
+
return None
|
|
171
|
+
invalid |= pending_successors
|
|
119
172
|
except Exception as exc:
|
|
120
173
|
logger.warning("Correction admission lookup failed: %s", exc)
|
|
174
|
+
if lifecycle_cache is not None:
|
|
175
|
+
lifecycle_cache.unavailable = True
|
|
121
176
|
return None
|
|
122
|
-
|
|
177
|
+
if not isinstance(invalid, set):
|
|
178
|
+
if lifecycle_cache is not None:
|
|
179
|
+
lifecycle_cache.unavailable = True
|
|
180
|
+
return None
|
|
181
|
+
if lifecycle_cache is not None:
|
|
182
|
+
lifecycle_cache.checked_fact_ids.update(unchecked)
|
|
183
|
+
lifecycle_cache.inadmissible_fact_ids.update(invalid)
|
|
184
|
+
return lifecycle_cache.inadmissible_fact_ids & fact_ids
|
|
185
|
+
return invalid
|
|
123
186
|
|
|
124
187
|
|
|
125
188
|
def _strict_temporal_candidate_ids(
|
|
@@ -133,7 +196,7 @@ def _strict_temporal_candidate_ids(
|
|
|
133
196
|
include_global: bool = False,
|
|
134
197
|
include_shared: bool = False,
|
|
135
198
|
) -> set[str] | None:
|
|
136
|
-
"""Return strict two-clock
|
|
199
|
+
"""Return strict two-clock failures, or ``None`` when admission is unprovable."""
|
|
137
200
|
if not fact_ids or (known_as_of is None and valid_at is None):
|
|
138
201
|
return set()
|
|
139
202
|
try:
|
|
@@ -151,6 +214,26 @@ def _strict_temporal_candidate_ids(
|
|
|
151
214
|
return invalid if isinstance(invalid, set) else None
|
|
152
215
|
|
|
153
216
|
|
|
217
|
+
def _abstain_candidates(
|
|
218
|
+
all_results: dict[str, list[tuple[str, float]]],
|
|
219
|
+
*,
|
|
220
|
+
stage: str,
|
|
221
|
+
) -> dict[str, list[tuple[str, float]]]:
|
|
222
|
+
"""Fail closed without changing the retrieval filter public contract.
|
|
223
|
+
|
|
224
|
+
``ChannelRegistry`` and the retrieval engine both expect the original
|
|
225
|
+
channel-result shape. Returning the same channel names with empty lists
|
|
226
|
+
is an explicit candidate-level abstention: downstream fusion naturally
|
|
227
|
+
produces no materializable facts, while callers keep their stable response
|
|
228
|
+
schema and can report ``no_confident_match``.
|
|
229
|
+
"""
|
|
230
|
+
logger.error(
|
|
231
|
+
"Temporal correction admission unavailable at %s; abstaining from recall candidates",
|
|
232
|
+
stage,
|
|
233
|
+
)
|
|
234
|
+
return {channel_name: [] for channel_name in all_results}
|
|
235
|
+
|
|
236
|
+
|
|
154
237
|
def admit_correction_candidates(
|
|
155
238
|
all_results: dict[str, list[tuple[str, float]]],
|
|
156
239
|
profile_id: str,
|
|
@@ -162,6 +245,7 @@ def admit_correction_candidates(
|
|
|
162
245
|
include_unknown: bool = False,
|
|
163
246
|
include_global: bool = False,
|
|
164
247
|
include_shared: bool = False,
|
|
248
|
+
lifecycle_cache: CorrectionAdmissionCache | None = None,
|
|
165
249
|
) -> dict[str, list[tuple[str, float]]]:
|
|
166
250
|
"""Hard-exclude system-superseded facts before candidate fusion.
|
|
167
251
|
|
|
@@ -178,15 +262,20 @@ def admit_correction_candidates(
|
|
|
178
262
|
invalid = _invalidated_candidate_ids(
|
|
179
263
|
db, fact_ids, profile_id, as_of=as_of,
|
|
180
264
|
include_global=include_global, include_shared=include_shared,
|
|
265
|
+
lifecycle_cache=lifecycle_cache,
|
|
181
266
|
)
|
|
267
|
+
if invalid is None:
|
|
268
|
+
return _abstain_candidates(all_results, stage="pre_fusion.lifecycle")
|
|
182
269
|
strict = _strict_temporal_candidate_ids(
|
|
183
270
|
db, fact_ids, profile_id,
|
|
184
271
|
known_as_of=known_as_of, valid_at=valid_at,
|
|
185
272
|
include_unknown=include_unknown,
|
|
186
273
|
include_global=include_global, include_shared=include_shared,
|
|
187
274
|
)
|
|
275
|
+
if strict is None:
|
|
276
|
+
return _abstain_candidates(all_results, stage="pre_fusion.strict_temporal")
|
|
188
277
|
if strict:
|
|
189
|
-
invalid
|
|
278
|
+
invalid |= strict
|
|
190
279
|
if not invalid:
|
|
191
280
|
return all_results
|
|
192
281
|
return {
|
|
@@ -210,21 +299,35 @@ def admit_correction_fusion_results(
|
|
|
210
299
|
include_unknown: bool = False,
|
|
211
300
|
include_global: bool = False,
|
|
212
301
|
include_shared: bool = False,
|
|
302
|
+
lifecycle_cache: CorrectionAdmissionCache | None = None,
|
|
213
303
|
) -> list[Any]:
|
|
214
304
|
"""Re-apply correction admission after graph/scene candidate expansion."""
|
|
215
305
|
fact_ids = {result.fact_id for result in fused_results}
|
|
216
306
|
invalid = _invalidated_candidate_ids(
|
|
217
307
|
db, fact_ids, profile_id, as_of=as_of,
|
|
218
308
|
include_global=include_global, include_shared=include_shared,
|
|
309
|
+
lifecycle_cache=lifecycle_cache,
|
|
219
310
|
)
|
|
311
|
+
if invalid is None:
|
|
312
|
+
logger.error(
|
|
313
|
+
"Temporal correction admission unavailable at post_fusion.lifecycle; "
|
|
314
|
+
"abstaining from recall candidates",
|
|
315
|
+
)
|
|
316
|
+
return []
|
|
220
317
|
strict = _strict_temporal_candidate_ids(
|
|
221
318
|
db, fact_ids, profile_id,
|
|
222
319
|
known_as_of=known_as_of, valid_at=valid_at,
|
|
223
320
|
include_unknown=include_unknown,
|
|
224
321
|
include_global=include_global, include_shared=include_shared,
|
|
225
322
|
)
|
|
323
|
+
if strict is None:
|
|
324
|
+
logger.error(
|
|
325
|
+
"Temporal correction admission unavailable at post_fusion.strict_temporal; "
|
|
326
|
+
"abstaining from recall candidates",
|
|
327
|
+
)
|
|
328
|
+
return []
|
|
226
329
|
if strict:
|
|
227
|
-
invalid
|
|
330
|
+
invalid |= strict
|
|
228
331
|
if not invalid:
|
|
229
332
|
return fused_results
|
|
230
333
|
return [result for result in fused_results if result.fact_id not in invalid]
|
|
@@ -311,14 +414,11 @@ class TemporalValidityFilter:
|
|
|
311
414
|
# When as_of is set: only supersessions that occurred AT OR BEFORE
|
|
312
415
|
# as_of contribute (Phase 4b bi-temporal fix). Supersessions after
|
|
313
416
|
# as_of are invisible — the fact was still valid at the query point.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
# Fail-open: a validity-lookup error must never break retrieval.
|
|
320
|
-
logger.warning("Temporal validity lookup failed: %s", exc)
|
|
321
|
-
return all_results
|
|
417
|
+
invalid = _invalidated_candidate_ids(
|
|
418
|
+
self._db, all_fact_ids, profile_id, as_of=as_of,
|
|
419
|
+
)
|
|
420
|
+
if invalid is None:
|
|
421
|
+
return _abstain_candidates(all_results, stage="legacy_filter.lifecycle")
|
|
322
422
|
|
|
323
423
|
# --- Axis 2: Event-time expiry (Phase 4 T1b) ---
|
|
324
424
|
# Guard: skip event-time demotion when the caller signals it wants
|