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.
Files changed (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +113 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. 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)