superlocalmemory 3.2.1 → 3.2.3
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 +23 -1
- package/README.md +61 -1
- package/package.json +1 -1
- package/pyproject.toml +26 -1
- package/src/superlocalmemory/attribution/signer.py +6 -1
- package/src/superlocalmemory/core/config.py +113 -1
- package/src/superlocalmemory/core/consolidation_engine.py +595 -0
- package/src/superlocalmemory/core/embeddings.py +0 -1
- package/src/superlocalmemory/core/engine.py +164 -674
- package/src/superlocalmemory/core/engine_wiring.py +474 -0
- package/src/superlocalmemory/core/graph_analyzer.py +199 -0
- package/src/superlocalmemory/core/recall_pipeline.py +247 -0
- package/src/superlocalmemory/core/store_pipeline.py +483 -0
- package/src/superlocalmemory/core/worker_pool.py +35 -12
- package/src/superlocalmemory/encoding/auto_linker.py +308 -0
- package/src/superlocalmemory/encoding/context_generator.py +175 -0
- package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
- package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
- package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
- package/src/superlocalmemory/retrieval/engine.py +12 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
- package/src/superlocalmemory/retrieval/strategy.py +6 -6
- package/src/superlocalmemory/retrieval/vector_store.py +386 -0
- package/src/superlocalmemory/server/routes/v3_api.py +576 -0
- package/src/superlocalmemory/storage/access_log.py +169 -0
- package/src/superlocalmemory/storage/database.py +288 -0
- package/src/superlocalmemory/storage/schema.py +10 -0
- package/src/superlocalmemory/storage/schema_v32.py +252 -0
- package/src/superlocalmemory/storage/v2_migrator.py +24 -2
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3
|
|
4
|
+
|
|
5
|
+
"""Temporal Intelligence -- contradiction detection and fact invalidation.
|
|
6
|
+
|
|
7
|
+
Implements full bi-temporal validity tracking with 4 timestamps (L8 fix).
|
|
8
|
+
Contradiction detection via sheaf cohomology (Mode A: pure math) or
|
|
9
|
+
LLM verification (Mode B/C).
|
|
10
|
+
|
|
11
|
+
References:
|
|
12
|
+
- Zep/Graphiti: bi-temporal model (t_valid, t_invalid, t_created, t_expired)
|
|
13
|
+
- SLM sheaf.py: coboundary norm for contradiction severity
|
|
14
|
+
- Mem0 consolidator: SUPERSEDE action pattern
|
|
15
|
+
|
|
16
|
+
NEVER imports core/engine.py (Rule 06).
|
|
17
|
+
Receives components via __init__.
|
|
18
|
+
|
|
19
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from superlocalmemory.core.config import TemporalValidatorConfig
|
|
29
|
+
from superlocalmemory.math.sheaf import SheafConsistencyChecker
|
|
30
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
31
|
+
from superlocalmemory.trust.scorer import TrustScorer
|
|
32
|
+
|
|
33
|
+
from superlocalmemory.storage.models import AtomicFact
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TemporalValidator:
|
|
39
|
+
"""Validates temporal consistency and manages fact invalidation.
|
|
40
|
+
|
|
41
|
+
Components received via __init__ (NOT the engine -- Rule 06):
|
|
42
|
+
- db: DatabaseManager
|
|
43
|
+
- sheaf_checker: SheafConsistencyChecker (existing, for Mode A)
|
|
44
|
+
- trust_scorer: TrustScorer (existing, for trust penalty)
|
|
45
|
+
- llm: LLMBackbone or None (for Mode B/C verification)
|
|
46
|
+
- config: TemporalValidatorConfig
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Trust penalty for expired facts
|
|
50
|
+
EXPIRATION_TRUST_PENALTY: float = -0.2
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
db: DatabaseManager,
|
|
55
|
+
sheaf_checker: SheafConsistencyChecker | None = None,
|
|
56
|
+
trust_scorer: TrustScorer | None = None,
|
|
57
|
+
llm: Any | None = None,
|
|
58
|
+
config: TemporalValidatorConfig | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._db = db
|
|
61
|
+
self._sheaf_checker = sheaf_checker
|
|
62
|
+
self._trust_scorer = trust_scorer
|
|
63
|
+
self._llm = llm
|
|
64
|
+
if config is None:
|
|
65
|
+
from superlocalmemory.core.config import TemporalValidatorConfig as _TVC
|
|
66
|
+
config = _TVC()
|
|
67
|
+
self._config = config
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Public API
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def validate_and_invalidate(
|
|
74
|
+
self,
|
|
75
|
+
new_fact: AtomicFact,
|
|
76
|
+
profile_id: str,
|
|
77
|
+
) -> list[dict]:
|
|
78
|
+
"""Check new fact for contradictions and invalidate old facts.
|
|
79
|
+
|
|
80
|
+
Algorithm:
|
|
81
|
+
1. Detect contradictions (sheaf or LLM).
|
|
82
|
+
2. For each contradiction: invalidate old fact (set valid_until + system_expired_at).
|
|
83
|
+
3. Apply trust penalty to invalidated facts.
|
|
84
|
+
4. Return list of invalidation actions.
|
|
85
|
+
|
|
86
|
+
Returns list of dicts: {old_fact_id, new_fact_id, reason, severity}
|
|
87
|
+
"""
|
|
88
|
+
contradictions = self.detect_contradiction(new_fact, profile_id)
|
|
89
|
+
|
|
90
|
+
if not contradictions:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
actions: list[dict] = []
|
|
94
|
+
for contradiction in contradictions:
|
|
95
|
+
old_fact_id = contradiction["fact_id_b"]
|
|
96
|
+
severity = contradiction["severity"]
|
|
97
|
+
reason = contradiction["description"]
|
|
98
|
+
|
|
99
|
+
# Step 1: Invalidate the old fact (bi-temporal)
|
|
100
|
+
self.invalidate_fact(
|
|
101
|
+
fact_id=old_fact_id,
|
|
102
|
+
invalidated_by=new_fact.fact_id,
|
|
103
|
+
reason=reason,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Step 2: Apply trust penalty
|
|
107
|
+
self._apply_trust_penalty(old_fact_id, profile_id)
|
|
108
|
+
|
|
109
|
+
actions.append({
|
|
110
|
+
"old_fact_id": old_fact_id,
|
|
111
|
+
"new_fact_id": new_fact.fact_id,
|
|
112
|
+
"reason": reason,
|
|
113
|
+
"severity": severity,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
logger.info(
|
|
117
|
+
"Temporal: invalidated %d facts due to new fact %s",
|
|
118
|
+
len(actions), new_fact.fact_id,
|
|
119
|
+
)
|
|
120
|
+
return actions
|
|
121
|
+
|
|
122
|
+
def detect_contradiction(
|
|
123
|
+
self,
|
|
124
|
+
new_fact: AtomicFact,
|
|
125
|
+
profile_id: str,
|
|
126
|
+
) -> list[dict]:
|
|
127
|
+
"""Detect contradictions between new fact and existing facts.
|
|
128
|
+
|
|
129
|
+
Mode A: Sheaf consistency (pure math, no LLM).
|
|
130
|
+
Mode B/C: LLM verification with sheaf as pre-filter.
|
|
131
|
+
|
|
132
|
+
Returns list of dicts: {fact_id_a, fact_id_b, severity, edge_type, description}
|
|
133
|
+
"""
|
|
134
|
+
mode = self._config.mode
|
|
135
|
+
|
|
136
|
+
if mode == "a" or self._llm is None or not self._llm.is_available():
|
|
137
|
+
return self._sheaf_contradiction(new_fact, profile_id)
|
|
138
|
+
else:
|
|
139
|
+
return self._llm_contradiction(new_fact, profile_id)
|
|
140
|
+
|
|
141
|
+
def invalidate_fact(
|
|
142
|
+
self,
|
|
143
|
+
fact_id: str,
|
|
144
|
+
invalidated_by: str,
|
|
145
|
+
reason: str,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Set valid_until and system_expired_at for a fact.
|
|
148
|
+
|
|
149
|
+
BI-TEMPORAL INTEGRITY: BOTH timestamps set in same operation.
|
|
150
|
+
NEVER deletes the fact (Rule 17: immutability).
|
|
151
|
+
Double invalidation is idempotent (TI-17).
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
# Check if temporal record exists
|
|
155
|
+
existing = self._db.get_temporal_validity(fact_id)
|
|
156
|
+
if existing and existing.get("valid_until") is not None:
|
|
157
|
+
# Already invalidated -- skip (idempotent)
|
|
158
|
+
logger.debug("Fact %s already invalidated, skipping", fact_id)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if existing:
|
|
162
|
+
# Update existing record
|
|
163
|
+
self._db.invalidate_fact_temporal(
|
|
164
|
+
fact_id=fact_id,
|
|
165
|
+
invalidated_by=invalidated_by,
|
|
166
|
+
invalidation_reason=reason,
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
# Create record then invalidate
|
|
170
|
+
profile_rows = self._db.execute(
|
|
171
|
+
"SELECT profile_id FROM atomic_facts WHERE fact_id = ?",
|
|
172
|
+
(fact_id,),
|
|
173
|
+
)
|
|
174
|
+
if not profile_rows:
|
|
175
|
+
logger.debug("Fact %s not found, cannot invalidate", fact_id)
|
|
176
|
+
return
|
|
177
|
+
pid = dict(profile_rows[0])["profile_id"]
|
|
178
|
+
self._db.store_temporal_validity(fact_id, pid)
|
|
179
|
+
self._db.invalidate_fact_temporal(
|
|
180
|
+
fact_id=fact_id,
|
|
181
|
+
invalidated_by=invalidated_by,
|
|
182
|
+
invalidation_reason=reason,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
logger.debug(
|
|
186
|
+
"Invalidated fact %s by %s: %s", fact_id, invalidated_by, reason,
|
|
187
|
+
)
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
logger.debug(
|
|
190
|
+
"Fact invalidation failed for %s: %s", fact_id, exc,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def is_temporally_valid(self, fact_id: str, profile_id: str = "") -> bool:
|
|
194
|
+
"""Check if a fact is currently temporally valid.
|
|
195
|
+
|
|
196
|
+
A fact is valid if:
|
|
197
|
+
- No temporal record exists (assumed valid), OR
|
|
198
|
+
- valid_until IS NULL AND system_expired_at IS NULL
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
fact_id: The fact to check.
|
|
202
|
+
profile_id: Profile scope (accepted for API consistency with Rule 01,
|
|
203
|
+
but fact_id is PK so lookup is unambiguous).
|
|
204
|
+
|
|
205
|
+
NOTE: Phase 5 calls this as is_temporally_valid(fact_id, profile_id).
|
|
206
|
+
Both params are REQUIRED in the call site. Do NOT rename to is_valid().
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
tv = self._db.get_temporal_validity(fact_id)
|
|
210
|
+
if tv is None:
|
|
211
|
+
return True # No temporal record = assumed valid
|
|
212
|
+
return (
|
|
213
|
+
tv.get("valid_until") is None
|
|
214
|
+
and tv.get("system_expired_at") is None
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
return True # Fail open -- assume valid
|
|
218
|
+
|
|
219
|
+
def get_facts_valid_at(
|
|
220
|
+
self, profile_id: str, event_time: str,
|
|
221
|
+
) -> list[str]:
|
|
222
|
+
"""Get fact_ids that were valid at a specific event time.
|
|
223
|
+
|
|
224
|
+
Queries: valid_from <= event_time AND (valid_until IS NULL OR valid_until > event_time)
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
rows = self._db.execute(
|
|
228
|
+
"SELECT f.fact_id FROM atomic_facts f "
|
|
229
|
+
"JOIN fact_temporal_validity tv ON f.fact_id = tv.fact_id "
|
|
230
|
+
"WHERE f.profile_id = ? "
|
|
231
|
+
" AND (tv.valid_from IS NULL OR tv.valid_from <= ?) "
|
|
232
|
+
" AND (tv.valid_until IS NULL OR tv.valid_until > ?)",
|
|
233
|
+
(profile_id, event_time, event_time),
|
|
234
|
+
)
|
|
235
|
+
return [dict(r)["fact_id"] for r in rows]
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
logger.debug(
|
|
238
|
+
"Temporal query failed for profile %s at %s: %s",
|
|
239
|
+
profile_id, event_time, exc,
|
|
240
|
+
)
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
def get_system_knowledge_at(
|
|
244
|
+
self, profile_id: str, transaction_time: str,
|
|
245
|
+
) -> list[str]:
|
|
246
|
+
"""Get fact_ids that the system knew about at a specific transaction time.
|
|
247
|
+
|
|
248
|
+
Queries: system_created_at <= T AND (system_expired_at IS NULL OR system_expired_at > T)
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
rows = self._db.execute(
|
|
252
|
+
"SELECT f.fact_id FROM atomic_facts f "
|
|
253
|
+
"JOIN fact_temporal_validity tv ON f.fact_id = tv.fact_id "
|
|
254
|
+
"WHERE f.profile_id = ? "
|
|
255
|
+
" AND tv.system_created_at <= ? "
|
|
256
|
+
" AND (tv.system_expired_at IS NULL OR tv.system_expired_at > ?)",
|
|
257
|
+
(profile_id, transaction_time, transaction_time),
|
|
258
|
+
)
|
|
259
|
+
return [dict(r)["fact_id"] for r in rows]
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
logger.debug(
|
|
262
|
+
"System knowledge query failed for profile %s at %s: %s",
|
|
263
|
+
profile_id, transaction_time, exc,
|
|
264
|
+
)
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# Contradiction detection: Mode A (sheaf, pure math)
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _sheaf_contradiction(
|
|
272
|
+
self,
|
|
273
|
+
new_fact: AtomicFact,
|
|
274
|
+
profile_id: str,
|
|
275
|
+
) -> list[dict]:
|
|
276
|
+
"""Detect contradictions via sheaf coboundary norm.
|
|
277
|
+
|
|
278
|
+
Uses existing SheafConsistencyChecker.check_consistency().
|
|
279
|
+
No LLM needed -- pure linear algebra.
|
|
280
|
+
"""
|
|
281
|
+
if self._sheaf_checker is None:
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
results = self._sheaf_checker.check_consistency(new_fact, profile_id)
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
"fact_id_a": r.fact_id_a,
|
|
289
|
+
"fact_id_b": r.fact_id_b,
|
|
290
|
+
"severity": r.severity,
|
|
291
|
+
"edge_type": r.edge_type,
|
|
292
|
+
"description": r.description,
|
|
293
|
+
}
|
|
294
|
+
for r in results
|
|
295
|
+
if r.severity > self._config.contradiction_threshold
|
|
296
|
+
]
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
logger.debug(
|
|
299
|
+
"Sheaf contradiction check failed for fact %s: %s",
|
|
300
|
+
new_fact.fact_id, exc,
|
|
301
|
+
)
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
# Contradiction detection: Mode B/C (LLM with sheaf pre-filter)
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def _llm_contradiction(
|
|
309
|
+
self,
|
|
310
|
+
new_fact: AtomicFact,
|
|
311
|
+
profile_id: str,
|
|
312
|
+
) -> list[dict]:
|
|
313
|
+
"""Detect contradictions via LLM verification.
|
|
314
|
+
|
|
315
|
+
Two-stage pipeline:
|
|
316
|
+
1. Sheaf pre-filter: find candidates with coboundary > 0.3
|
|
317
|
+
2. LLM verification: ask LLM to confirm each candidate.
|
|
318
|
+
"""
|
|
319
|
+
contradictions: list[dict] = []
|
|
320
|
+
|
|
321
|
+
# Stage 1: Sheaf pre-filter (get candidates)
|
|
322
|
+
candidates: list[Any] = []
|
|
323
|
+
if self._sheaf_checker is not None:
|
|
324
|
+
try:
|
|
325
|
+
results = self._sheaf_checker.check_consistency(
|
|
326
|
+
new_fact, profile_id,
|
|
327
|
+
)
|
|
328
|
+
candidates = [
|
|
329
|
+
r for r in results
|
|
330
|
+
if r.severity > self._config.llm_prefilter_threshold
|
|
331
|
+
]
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
logger.debug(
|
|
334
|
+
"Sheaf pre-filter failed for fact %s: %s",
|
|
335
|
+
new_fact.fact_id, exc,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# If no sheaf results, find candidates by entity overlap
|
|
339
|
+
if not candidates:
|
|
340
|
+
candidates = self._entity_based_candidates(new_fact, profile_id)
|
|
341
|
+
|
|
342
|
+
# Stage 2: LLM verification
|
|
343
|
+
for candidate in candidates[: self._config.max_llm_checks]:
|
|
344
|
+
other_fact_id = (
|
|
345
|
+
candidate.fact_id_b
|
|
346
|
+
if hasattr(candidate, "fact_id_b")
|
|
347
|
+
else candidate
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
other_content = self._get_fact_content(other_fact_id)
|
|
351
|
+
if not other_content:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
is_contradiction = self._llm_verify_contradiction(
|
|
355
|
+
new_fact.content, other_content,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if is_contradiction:
|
|
359
|
+
severity = getattr(candidate, "severity", 0.8)
|
|
360
|
+
fact_b = (
|
|
361
|
+
other_fact_id
|
|
362
|
+
if isinstance(other_fact_id, str)
|
|
363
|
+
else candidate.fact_id_b
|
|
364
|
+
)
|
|
365
|
+
contradictions.append({
|
|
366
|
+
"fact_id_a": new_fact.fact_id,
|
|
367
|
+
"fact_id_b": fact_b,
|
|
368
|
+
"severity": severity,
|
|
369
|
+
"edge_type": getattr(
|
|
370
|
+
candidate, "edge_type", "llm_detected",
|
|
371
|
+
),
|
|
372
|
+
"description": (
|
|
373
|
+
f"LLM-verified contradiction "
|
|
374
|
+
f"(sheaf pre-filter severity: {severity:.3f})"
|
|
375
|
+
),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
return contradictions
|
|
379
|
+
|
|
380
|
+
def _llm_verify_contradiction(
|
|
381
|
+
self, content_a: str, content_b: str,
|
|
382
|
+
) -> bool:
|
|
383
|
+
"""Ask LLM whether two statements contradict each other."""
|
|
384
|
+
if self._llm is None or not self._llm.is_available():
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
prompt = (
|
|
389
|
+
"Do these two statements contradict each other? "
|
|
390
|
+
"A contradiction means they cannot both be true "
|
|
391
|
+
"at the same time.\n\n"
|
|
392
|
+
f"Statement A: {content_a}\n"
|
|
393
|
+
f"Statement B: {content_b}\n\n"
|
|
394
|
+
"Answer ONLY 'yes' or 'no'."
|
|
395
|
+
)
|
|
396
|
+
response = self._llm.generate(
|
|
397
|
+
prompt, system="You are a precise fact-checker.",
|
|
398
|
+
)
|
|
399
|
+
return response.strip().lower().startswith("yes")
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
logger.debug("LLM contradiction check failed: %s", exc)
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
def _entity_based_candidates(
|
|
405
|
+
self, new_fact: AtomicFact, profile_id: str,
|
|
406
|
+
) -> list[str]:
|
|
407
|
+
"""Find contradiction candidates by entity overlap when sheaf unavailable."""
|
|
408
|
+
candidates: list[str] = []
|
|
409
|
+
try:
|
|
410
|
+
for entity in new_fact.canonical_entities[:5]:
|
|
411
|
+
rows = self._db.execute(
|
|
412
|
+
"SELECT DISTINCT fact_id FROM atomic_facts "
|
|
413
|
+
"WHERE profile_id = ? AND fact_id != ? "
|
|
414
|
+
"AND canonical_entities_json LIKE ?",
|
|
415
|
+
(profile_id, new_fact.fact_id, f"%{entity}%"),
|
|
416
|
+
)
|
|
417
|
+
for row in rows:
|
|
418
|
+
candidates.append(dict(row)["fact_id"])
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
logger.debug(
|
|
421
|
+
"Entity candidate search failed for fact %s: %s",
|
|
422
|
+
new_fact.fact_id, exc,
|
|
423
|
+
)
|
|
424
|
+
return list(set(candidates))[:10]
|
|
425
|
+
|
|
426
|
+
def _get_fact_content(self, fact_id: str) -> str | None:
|
|
427
|
+
"""Get fact content by ID."""
|
|
428
|
+
try:
|
|
429
|
+
rows = self._db.execute(
|
|
430
|
+
"SELECT content FROM atomic_facts WHERE fact_id = ?",
|
|
431
|
+
(fact_id,),
|
|
432
|
+
)
|
|
433
|
+
return dict(rows[0])["content"] if rows else None
|
|
434
|
+
except Exception:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
# ------------------------------------------------------------------
|
|
438
|
+
# Trust penalty
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
def _apply_trust_penalty(self, fact_id: str, profile_id: str) -> None:
|
|
442
|
+
"""Apply trust penalty to an expired/invalidated fact.
|
|
443
|
+
|
|
444
|
+
Uses TrustScorer.update_on_contradiction() which adds +3.0
|
|
445
|
+
to beta parameter, reducing trust score.
|
|
446
|
+
"""
|
|
447
|
+
if self._trust_scorer is None:
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
self._trust_scorer.update_on_contradiction(
|
|
452
|
+
target_type="fact",
|
|
453
|
+
target_id=fact_id,
|
|
454
|
+
profile_id=profile_id,
|
|
455
|
+
)
|
|
456
|
+
logger.debug("Trust penalty applied to expired fact %s", fact_id)
|
|
457
|
+
except Exception as exc:
|
|
458
|
+
logger.debug(
|
|
459
|
+
"Trust penalty failed for fact %s: %s", fact_id, exc,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# Temporal validity filter (registered via ChannelRegistry)
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def temporal_validity_filter_impl(
|
|
468
|
+
channel_results: dict[str, list],
|
|
469
|
+
profile_id: str,
|
|
470
|
+
db: DatabaseManager,
|
|
471
|
+
include_expired: bool = False,
|
|
472
|
+
) -> dict[str, list]:
|
|
473
|
+
"""Filter expired facts from all channel results.
|
|
474
|
+
|
|
475
|
+
Handles both tuple format (fact_id, score) and dict format from channels.
|
|
476
|
+
Called via closure wrapper registered in engine_wiring.py.
|
|
477
|
+
"""
|
|
478
|
+
if include_expired:
|
|
479
|
+
return channel_results
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
# Get all expired fact_ids for this profile (single query)
|
|
483
|
+
expired_rows = db.execute(
|
|
484
|
+
"SELECT fact_id FROM fact_temporal_validity "
|
|
485
|
+
"WHERE profile_id = ? "
|
|
486
|
+
" AND (valid_until IS NOT NULL OR system_expired_at IS NOT NULL)",
|
|
487
|
+
(profile_id,),
|
|
488
|
+
)
|
|
489
|
+
expired_ids = {dict(r)["fact_id"] for r in expired_rows}
|
|
490
|
+
except Exception:
|
|
491
|
+
return channel_results
|
|
492
|
+
|
|
493
|
+
if not expired_ids:
|
|
494
|
+
return channel_results
|
|
495
|
+
|
|
496
|
+
# Filter each channel's results
|
|
497
|
+
filtered: dict[str, list] = {}
|
|
498
|
+
for channel_name, results in channel_results.items():
|
|
499
|
+
filtered[channel_name] = [
|
|
500
|
+
item for item in results
|
|
501
|
+
if _extract_fact_id(item) not in expired_ids
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
return filtered
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _extract_fact_id(item: Any) -> str:
|
|
508
|
+
"""Extract fact_id from channel result item (tuple or dict)."""
|
|
509
|
+
if isinstance(item, tuple):
|
|
510
|
+
return item[0] # (fact_id, score)
|
|
511
|
+
if isinstance(item, dict):
|
|
512
|
+
return item.get("fact_id", "")
|
|
513
|
+
return str(item)
|