cortexm 0.3.0__py3-none-any.whl

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 (120) hide show
  1. context_m.py +17 -0
  2. cortexm/__init__.py +45 -0
  3. cortexm/accel.py +403 -0
  4. cortexm/api/__init__.py +0 -0
  5. cortexm/api/chaos.py +118 -0
  6. cortexm/api/memory.py +635 -0
  7. cortexm/bench/__init__.py +0 -0
  8. cortexm/bench/abilities.py +311 -0
  9. cortexm/bench/baselines.py +89 -0
  10. cortexm/bench/beam_loader.py +317 -0
  11. cortexm/bench/generator.py +376 -0
  12. cortexm/bench/harness.py +211 -0
  13. cortexm/bench/messy.py +218 -0
  14. cortexm/bench/micro.py +251 -0
  15. cortexm/bench/ood.py +443 -0
  16. cortexm/bench/run.py +137 -0
  17. cortexm/bridge/__init__.py +0 -0
  18. cortexm/bridge/dates.py +178 -0
  19. cortexm/bridge/decoders.py +204 -0
  20. cortexm/bridge/enrich.py +255 -0
  21. cortexm/bridge/extractor.py +316 -0
  22. cortexm/bridge/fallback.py +332 -0
  23. cortexm/bridge/onnx_runtime.py +158 -0
  24. cortexm/bridge/patterns.py +760 -0
  25. cortexm/bridge/ppr.py +104 -0
  26. cortexm/bridge/prefilter.py +188 -0
  27. cortexm/bridge/query_extract.py +420 -0
  28. cortexm/bridge/reader.py +1174 -0
  29. cortexm/bridge/rerank.py +204 -0
  30. cortexm/bridge/writer.py +492 -0
  31. cortexm/cli.py +295 -0
  32. cortexm/cognition/__init__.py +53 -0
  33. cortexm/cognition/abstraction.py +192 -0
  34. cortexm/cognition/analogy.py +159 -0
  35. cortexm/cognition/engine.py +204 -0
  36. cortexm/cognition/gaps.py +365 -0
  37. cortexm/cognition/scanner.py +204 -0
  38. cortexm/config.py +375 -0
  39. cortexm/cortexm.py +8 -0
  40. cortexm/enterprise/__init__.py +0 -0
  41. cortexm/enterprise/audit.py +178 -0
  42. cortexm/enterprise/governance.py +239 -0
  43. cortexm/errors.py +35 -0
  44. cortexm/features/__init__.py +0 -0
  45. cortexm/features/git.py +204 -0
  46. cortexm/features/prefetch.py +88 -0
  47. cortexm/features/zk.py +105 -0
  48. cortexm/federation/__init__.py +39 -0
  49. cortexm/federation/crdt.py +275 -0
  50. cortexm/federation/fabric.py +109 -0
  51. cortexm/federation/hlc.py +80 -0
  52. cortexm/federation/node.py +145 -0
  53. cortexm/federation/schema_report.py +73 -0
  54. cortexm/federation/transport.py +164 -0
  55. cortexm/index/__init__.py +19 -0
  56. cortexm/index/nsg.py +386 -0
  57. cortexm/mcp/__init__.py +0 -0
  58. cortexm/mcp/server.py +985 -0
  59. cortexm/metrics.py +62 -0
  60. cortexm/migrate/__init__.py +0 -0
  61. cortexm/migrate/importers.py +192 -0
  62. cortexm/provenance/__init__.py +78 -0
  63. cortexm/provenance/agent.py +214 -0
  64. cortexm/provenance/cose.py +201 -0
  65. cortexm/provenance/scitt.py +258 -0
  66. cortexm/provenance/vc.py +250 -0
  67. cortexm/security/__init__.py +0 -0
  68. cortexm/security/crypto.py +162 -0
  69. cortexm/security/hashes.py +140 -0
  70. cortexm/security/injection.py +149 -0
  71. cortexm/security/mind.py +154 -0
  72. cortexm/security/pii.py +265 -0
  73. cortexm/security/rbac.py +169 -0
  74. cortexm/security/sandbox.py +131 -0
  75. cortexm/security/zk_hamming.py +142 -0
  76. cortexm/security/zk_sql.py +485 -0
  77. cortexm/server/__init__.py +0 -0
  78. cortexm/server/metrics.py +88 -0
  79. cortexm/server/rest.py +936 -0
  80. cortexm/server/sparql.py +984 -0
  81. cortexm/text/__init__.py +0 -0
  82. cortexm/text/dissim.py +252 -0
  83. cortexm/text/embedder.py +155 -0
  84. cortexm/text/fuzzy.py +218 -0
  85. cortexm/text/idiolect.py +253 -0
  86. cortexm/text/labse.py +374 -0
  87. cortexm/text/tokenizer.py +79 -0
  88. cortexm/trace/__init__.py +0 -0
  89. cortexm/trace/blob_arena.py +277 -0
  90. cortexm/trace/consolidate.py +337 -0
  91. cortexm/trace/contradictions.py +69 -0
  92. cortexm/trace/dedup.py +114 -0
  93. cortexm/trace/edges.py +214 -0
  94. cortexm/trace/fact.py +121 -0
  95. cortexm/trace/fade.py +245 -0
  96. cortexm/trace/lifecycle.py +112 -0
  97. cortexm/trace/rebuild.py +173 -0
  98. cortexm/trace/rules.py +171 -0
  99. cortexm/trace/store.py +680 -0
  100. cortexm/trace/structural.py +183 -0
  101. cortexm/trace/tmt.py +335 -0
  102. cortexm/util.py +148 -0
  103. cortexm/vsa/__init__.py +0 -0
  104. cortexm/vsa/attribution.py +149 -0
  105. cortexm/vsa/cleanup.py +161 -0
  106. cortexm/vsa/codecs.py +397 -0
  107. cortexm/vsa/hologram_overlay.py +139 -0
  108. cortexm/vsa/index.py +163 -0
  109. cortexm/vsa/ops.py +149 -0
  110. cortexm/vsa/palace.py +446 -0
  111. cortexm/vsa/role_vectors.py +236 -0
  112. cortexm/vsa/slb.py +78 -0
  113. cortexm/vsa/tlsh_trie.py +137 -0
  114. cortexm/vsa/working_memory.py +249 -0
  115. cortexm-0.3.0.dist-info/METADATA +482 -0
  116. cortexm-0.3.0.dist-info/RECORD +120 -0
  117. cortexm-0.3.0.dist-info/WHEEL +5 -0
  118. cortexm-0.3.0.dist-info/entry_points.txt +2 -0
  119. cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
  120. cortexm-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,492 @@
1
+ """Write path — μ=0 ingest orchestration.
2
+
3
+ messages → chunks (BLAKE3-hashed) → InjecMEM screening → deterministic
4
+ extraction → interference-aware lifecycle → bi-temporal Trace commit →
5
+ VSA palace encoding → Datalog materialization → temporal edge wiring.
6
+ Zero LLM calls at every step (the optional async enricher is a separate
7
+ API, never on this path).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as _dt
13
+ from datetime import datetime, timezone
14
+
15
+ from cortexm import metrics
16
+ from cortexm.bridge.extractor import Extractor
17
+ from cortexm.bridge.patterns import ExtractionContext
18
+ from cortexm.config import Config
19
+ from cortexm.security.injection import scan as injection_scan
20
+ from cortexm.security.injection import contagion_scan
21
+ from cortexm.trace import lifecycle
22
+ from cortexm.trace.contradictions import Action
23
+ from cortexm.trace.edges import (
24
+ wire_causal_edge, wire_refers_to, REFERS_TO,
25
+ )
26
+ from cortexm.trace.fact import Fact, make_fact
27
+ from cortexm.trace.rules import RuleEngine
28
+ from cortexm.trace.store import TraceStore
29
+ from cortexm.util import iso, new_id, similarity, token_estimate
30
+ from cortexm.vsa.palace import MemoryPalace
31
+
32
+
33
+ def _now() -> datetime:
34
+ return datetime.now(timezone.utc)
35
+
36
+
37
+ def _normalize_messages(messages) -> list[tuple[str, str, datetime | None]]:
38
+ """Accept mem0-style inputs: str | list[str] | list[dict] -> (role, text, ts)."""
39
+ out: list[tuple[str, str, datetime | None]] = []
40
+ if isinstance(messages, str):
41
+ out.append(("user", messages, None))
42
+ elif isinstance(messages, dict):
43
+ out.append((messages.get("role", "user"),
44
+ messages.get("content", ""), None))
45
+ elif isinstance(messages, list):
46
+ for m in messages:
47
+ if isinstance(m, str):
48
+ out.append(("user", m, None))
49
+ elif isinstance(m, dict):
50
+ role = m.get("role", "user")
51
+ content = m.get("content", "")
52
+ if isinstance(content, list): # multimodal content blocks
53
+ content = " ".join(
54
+ b.get("text", "") for b in content if isinstance(b, dict))
55
+ ts = m.get("timestamp") or m.get("ts")
56
+ out.append((role, content or "", ts))
57
+ return [(r, t, ts) for r, t, ts in out if t and t.strip()]
58
+
59
+
60
+ class MemoryWriter:
61
+ def __init__(self, config: Config, store: TraceStore, palace: MemoryPalace,
62
+ extractor: Extractor | None = None) -> None:
63
+ self.cfg = config
64
+ self.store = store
65
+ self.palace = palace
66
+ self.extractor = extractor or Extractor(config)
67
+ self.rules = RuleEngine(store)
68
+ # MINJA contagion guard: per-scope cache of quarantined source texts
69
+ # (loaded lazily, one query per scope, updated on quarantine).
70
+ self._taint_cache: dict[str, list[str]] = {}
71
+
72
+ # ------------------------------------------------------------------
73
+ def _name_of(self, user_id: str) -> str | None:
74
+ return self.store.kv_get(f"name:{user_id}")
75
+
76
+ def _set_name(self, user_id: str, name: str) -> None:
77
+ self.store.kv_set(f"name:{user_id}", name)
78
+
79
+ def _lexicon(self, user_id: str) -> set[str]:
80
+ import json
81
+ raw = self.store.kv_get(f"lexicon:{user_id}", "[]")
82
+ try:
83
+ return set(json.loads(raw))
84
+ except Exception:
85
+ return set()
86
+
87
+ def _grow_lexicon(self, user_id: str, names: set[str]) -> None:
88
+ lex = self._lexicon(user_id)
89
+ merged = lex | {n for n in names if n and 2 < len(n) < 60}
90
+ if len(merged) > 20000:
91
+ merged = set(sorted(merged)[:20000])
92
+ import json
93
+ self.store.kv_set(f"lexicon:{user_id}", json.dumps(sorted(merged)))
94
+
95
+ def _tainted_corpus(self, user_id: str) -> list[str]:
96
+ if user_id not in self._taint_cache:
97
+ try:
98
+ self._taint_cache[user_id] = \
99
+ self.store.quarantined_chunk_texts(user_id)
100
+ except Exception:
101
+ self._taint_cache[user_id] = []
102
+ return self._taint_cache[user_id]
103
+
104
+ def _taint(self, user_id: str, text: str) -> None:
105
+ corpus = self._tainted_corpus(user_id)
106
+ if text not in corpus:
107
+ corpus.append(text)
108
+
109
+ # ------------------------------------------------------------------
110
+ def _unmess_cache(self) -> dict:
111
+ """Lazy-init the unmess (idiolect + dissim) cache on this writer.
112
+ Reuses the chaos-mode pattern so production and chaos paths share
113
+ the same slang dictionary — observations accumulate across both."""
114
+ if not hasattr(self, "_unmess"):
115
+ from cortexm.text.dissim import DisSimSplitter
116
+ from cortexm.text.embedder import HashingEmbedder
117
+ from cortexm.text.idiolect import PerUserIdiolectNormalizer
118
+ embedder = HashingEmbedder(self.palace.dims, self.palace.cfg.seed)
119
+ self._unmess = {
120
+ "idiolect": PerUserIdiolectNormalizer(embedder),
121
+ "dissim": DisSimSplitter(max_depth=self.cfg.unmess_max_depth),
122
+ "embedder": embedder,
123
+ }
124
+ return self._unmess
125
+
126
+ def _unmess_text(self, text: str, user_id: str) -> list[str]:
127
+ """Run the Unmess pipeline on a single message, returning clauses.
128
+
129
+ 1. observe idiolect (slang dictionary grows)
130
+ 2. normalize text-speak + kNN slang replacement
131
+ 3. DisSim recursive syntactic split into simple clauses
132
+ Returns a list of clause strings (>=1). Falls back to the raw
133
+ text on any error so the path never blocks ingest.
134
+ """
135
+ if not self.cfg.unmess_enabled:
136
+ return [text]
137
+ try:
138
+ cache = self._unmess_cache()
139
+ idiolect = cache["idiolect"]
140
+ dissim = cache["dissim"]
141
+ idiolect.observe(user_id, text)
142
+ norm = idiolect.normalize(user_id, text)
143
+ clauses = [c.text for c in (dissim.simplify_text(norm) or [norm])]
144
+ return clauses if clauses else [norm]
145
+ except Exception:
146
+ return [text]
147
+
148
+ # ------------------------------------------------------------------
149
+ def add(self, messages, *, user_id: str = "default",
150
+ agent_id: str | None = None, run_id: str | None = None,
151
+ ts: datetime | None = None, source: str = "",
152
+ metadata: dict | None = None) -> dict:
153
+ ts = ts or _now()
154
+ norm = _normalize_messages(messages)
155
+ tokens = sum(token_estimate(t) for _, t, _ in norm)
156
+ self.store.begin_batch()
157
+ commit = self.store.create_commit(
158
+ f"ingest {len(norm)} message(s)", n_facts=0)
159
+ results: list[dict] = []
160
+ inserted_total = 0
161
+
162
+ for role, text, msg_ts in norm:
163
+ msg_time = msg_ts if isinstance(msg_ts, datetime) else ts
164
+ speaker = "assistant" if role in ("assistant", "ai", "bot") else "user"
165
+ chunk_id = self.store.add_chunk(
166
+ text, user_id=user_id, agent_id=agent_id, run_id=run_id,
167
+ ts=msg_time, source=source or role)
168
+ verdict = injection_scan(text, self.cfg.quarantine_injection)
169
+ if not verdict.quarantined and self.cfg.quarantine_contagion:
170
+ cv = contagion_scan(text, self._tainted_corpus(user_id),
171
+ threshold=self.cfg.contagion_threshold)
172
+ if cv is not None:
173
+ verdict = cv
174
+
175
+ ctx = ExtractionContext(
176
+ user_id=user_id, agent_id=agent_id, run_id=run_id,
177
+ ts=msg_time, speaker=speaker,
178
+ subject_name=self._name_of(user_id),
179
+ lexicon=self._lexicon(user_id))
180
+
181
+ # --- OOD ingestion: Unmess + DisSim + Bitap ----------------
182
+ # Pre-process the raw text through the unmess pipeline before
183
+ # running the deterministic extractor. This is the fix for the
184
+ # Tier-1 OOD catastrophe (paraphrase 9.4%, slang 5.1% recall):
185
+ # * idiolect normalizer: "u"→"you", "bruh"→"friend" if user
186
+ # co-occurred, "wrks"→"works" via kNN over vocab
187
+ # * DisSim splits compound sentences so each clause matches
188
+ # its own pattern instead of the regex missing all of them
189
+ # The extractor's internal Bitap trigger widening handles
190
+ # misspelled trigger words. When unmess is OFF (bench baseline
191
+ # config), we run the raw text through the extractor unchanged.
192
+ clauses = self._unmess_text(text, user_id) \
193
+ if self.cfg.unmess_enabled else [text]
194
+
195
+ candidates = []
196
+ for clause in clauses:
197
+ if not clause or not clause.strip():
198
+ continue
199
+ candidates.extend(self.extractor.extract(clause, ctx))
200
+
201
+ for cand in candidates:
202
+ if cand.confidence < self.cfg.min_confidence:
203
+ continue
204
+ valid_from = cand.valid_from or iso(msg_time)[:10]
205
+ fact = make_fact(
206
+ cand.subject, cand.relation, cand.value, now=msg_time,
207
+ valid_from=valid_from, valid_to=cand.valid_to,
208
+ user_id=user_id, agent_id=agent_id, run_id=run_id,
209
+ confidence=cand.confidence,
210
+ source_id=chunk_id,
211
+ source_hash=self.store.hasher.hash_text(text),
212
+ memory_type=("short_term" if cand.confidence < 0.6
213
+ else "short_term"),
214
+ provenance={"pattern": cand.pattern,
215
+ "speaker": speaker,
216
+ "span": list(cand.span),
217
+ "injection_risk": verdict.risk,
218
+ # Tier-4: surface whether this fact
219
+ # came from a strict trigger or a
220
+ # Bitap-widened one. Downstream audits
221
+ # can use this to estimate OOD recall
222
+ # vs FP rate; the min_confidence
223
+ # filter already applied a 0.10
224
+ # penalty on the bitap_widened path.
225
+ "trigger_source": getattr(
226
+ cand, "trigger_source", "strict"),
227
+ **({"context": cand.note} if cand.note else {})})
228
+ fact.birth_commit = commit
229
+
230
+ if verdict.quarantined:
231
+ fact.quarantined = True
232
+ fact.is_active = False
233
+ self.store.insert_fact(fact, commit)
234
+ self.store.add_edge(fact.id, chunk_id, "EXTRACTED_FROM")
235
+ self._taint(user_id, text)
236
+ results.append(self._result(fact, "QUARANTINED",
237
+ verdict.note))
238
+ continue
239
+
240
+ if cand.retraction:
241
+ inserted_total += self._apply_retraction(
242
+ fact, cand, commit, chunk_id, msg_time, results)
243
+ continue
244
+
245
+ if self.cfg.enable_lifecycle:
246
+ decision = lifecycle.assess(self.store, fact)
247
+ else:
248
+ from cortexm.trace.contradictions import Action as _A
249
+ decision = lifecycle.LifecycleDecision(_A.COMMIT, True)
250
+
251
+ inserted_total += self._apply_decision(
252
+ fact, decision, commit, chunk_id, results)
253
+
254
+ # learn name / lexicon
255
+ for cand in candidates:
256
+ if cand.relation == "name" and self._name_of(user_id) is None:
257
+ self._set_name(user_id, cand.value)
258
+ self._grow_lexicon(user_id, {c.subject for c in candidates} |
259
+ {c.value for c in candidates
260
+ if c.value and c.value[0:1].isupper()})
261
+
262
+ # Datalog materialization (inference bound into the palace)
263
+ if self.cfg.enable_rules and getattr(self.cfg, "apply_rules_each_add", True):
264
+ self.rules.invalidate()
265
+ derived = self.rules.apply(ts)
266
+ for f in derived:
267
+ f.user_id = user_id
268
+ self.palace.add(f.id, self.palace.encode_fact(f))
269
+ inserted_total += len(derived)
270
+
271
+ self._wire_temporal_edges(user_id)
272
+ self.store.end_batch()
273
+ self.palace.close()
274
+ metrics.bump_ingest(tokens=tokens, messages=len(norm),
275
+ facts=inserted_total)
276
+ return {"event": "ADD", "results": results,
277
+ "commit": commit,
278
+ "stats": {"messages": len(norm), "tokens": tokens,
279
+ "facts_inserted": inserted_total,
280
+ "llm_calls": 0}}
281
+
282
+ # ------------------------------------------------------------------
283
+ def ingest_candidates(self, candidates, *, user_id: str = "default",
284
+ agent_id: str | None = None,
285
+ chunk_id: str | None = None,
286
+ ts: datetime | None = None,
287
+ source: str = "", extractor_model: str | None = None,
288
+ ) -> int:
289
+ """Commit pre-built Candidates through the standard fact pipeline.
290
+
291
+ Used by the async LLM enrichment fallback (bridge/enrich.py): the
292
+ μ=0 path stays untouched, but enriched candidates still pass the
293
+ SAME quarantine, contradiction, lifecycle, edge-wiring and palace
294
+ indexing logic as pattern-extracted facts. Provenance records the
295
+ enrichment origin so audits can always distinguish the two paths.
296
+ """
297
+ ts = ts or _now()
298
+ text = ""
299
+ chunk = self.store.get_chunk(chunk_id) if chunk_id else None
300
+ if chunk:
301
+ text = chunk["text"]
302
+ self.store.begin_batch()
303
+ commit = self.store.create_commit(
304
+ f"enrich {len(candidates)} candidate(s) from {source or 'external'}",
305
+ n_facts=0)
306
+ inserted = 0
307
+ for cand in candidates:
308
+ if cand.confidence < self.cfg.min_confidence:
309
+ continue
310
+ verdict = injection_scan(text, self.cfg.quarantine_injection) \
311
+ if text else None
312
+ fact = make_fact(
313
+ cand.subject, cand.relation, cand.value, now=ts,
314
+ valid_from=cand.valid_from or iso(ts)[:10],
315
+ valid_to=cand.valid_to,
316
+ user_id=user_id, agent_id=agent_id, run_id=None,
317
+ confidence=cand.confidence,
318
+ source_id=chunk_id,
319
+ source_hash=(self.store.hasher.hash_text(text)
320
+ if text else self.store.hasher.hash_text(
321
+ cand.subject + cand.relation + cand.value)),
322
+ memory_type="short_term",
323
+ provenance={"pattern": cand.pattern,
324
+ "enriched_by": source or "external",
325
+ **({"extractor_model": extractor_model}
326
+ if extractor_model else {}),
327
+ **({"injection_risk": verdict.risk}
328
+ if verdict else {})})
329
+ fact.birth_commit = commit
330
+ if verdict is not None and verdict.quarantined:
331
+ fact.quarantined = True
332
+ fact.is_active = False
333
+ self.store.insert_fact(fact, commit)
334
+ if chunk_id:
335
+ self.store.add_edge(fact.id, chunk_id, "EXTRACTED_FROM")
336
+ continue
337
+ if self.cfg.enable_lifecycle:
338
+ decision = lifecycle.assess(self.store, fact)
339
+ else:
340
+ from cortexm.trace.contradictions import Action as _A
341
+ decision = lifecycle.LifecycleDecision(_A.COMMIT, True)
342
+ results: list = []
343
+ inserted += self._apply_decision(fact, decision, commit,
344
+ chunk_id, results)
345
+ if self.cfg.enable_rules:
346
+ self.rules.invalidate()
347
+ derived = self.rules.apply(ts)
348
+ for f in derived:
349
+ f.user_id = user_id
350
+ self.palace.add(f.id, self.palace.encode_fact(f))
351
+ inserted += len(derived)
352
+ self._wire_temporal_edges(user_id)
353
+ self.store.end_batch()
354
+ self.palace.close()
355
+ return inserted
356
+
357
+ # ------------------------------------------------------------------
358
+ def apply_rules(self, ts=None):
359
+ """Deferred Datalog materialization (bulk-ingest mode)."""
360
+ ts = ts or _now()
361
+ self.rules.invalidate()
362
+ derived = self.rules.apply(ts)
363
+ for f in derived:
364
+ self.palace.add(f.id, self.palace.encode_fact(f))
365
+ self.store.end_batch()
366
+ return derived
367
+
368
+ # ------------------------------------------------------------------
369
+ def _apply_decision(self, fact: Fact, decision, commit, chunk_id,
370
+ results) -> int:
371
+ action = decision.action
372
+ if action is Action.SKIP:
373
+ for f in self.store.get_facts(decision.target_ids or []):
374
+ self.store.update_fact(f.id, access_count=f.access_count + 1)
375
+ # exact restatement with an EXPLICIT date refines the
376
+ # interval: "I've been at Microsoft since March 2024"
377
+ # backdates an employment first learned without a date.
378
+ if (fact.valid_from and f.valid_from
379
+ and fact.valid_from < f.valid_from):
380
+ self.store.update_fact(f.id, valid_from=fact.valid_from)
381
+ return 0
382
+ if action is Action.MERGE:
383
+ for f in self.store.get_facts(decision.target_ids or []):
384
+ self.store.update_fact(
385
+ f.id, reinforcement=f.reinforcement + 1,
386
+ access_count=f.access_count + 1,
387
+ valid_from=(min(fact.valid_from, f.valid_from)
388
+ if fact.valid_from and f.valid_from
389
+ and fact.valid_from < f.valid_from
390
+ else f.valid_from),
391
+ provenance={**f.provenance,
392
+ "merged_with": fact.text(),
393
+ "last_pattern": fact.provenance.get("pattern")})
394
+ self.store.add_edge(f.id, chunk_id, "EXTRACTED_FROM")
395
+ return 0
396
+ if action is Action.SUPERSEDE:
397
+ for old in self.store.get_facts(decision.target_ids or []):
398
+ # interval sanity: valid_to must never precede valid_from —
399
+ # a stale retraction date can otherwise invert the interval
400
+ # and make the fact invisible to every temporal window.
401
+ vt = (min(old.valid_to, fact.valid_from)
402
+ if old.valid_to else fact.valid_from)
403
+ if old.valid_from and vt and vt < old.valid_from:
404
+ vt = old.valid_from
405
+ self.store.update_fact(
406
+ old.id, valid_to=vt,
407
+ is_active=0, tx_to=iso(_now()), retired_commit=commit,
408
+ provenance={**old.provenance,
409
+ "superseded_by": fact.id})
410
+ self.store.add_edge(fact.id, old.id, "CONTRADICTS",
411
+ {"reason": decision.note})
412
+ # Aeon-style CAUSAL edge: the new fact causally
413
+ # displaced the old one (the retraction / supersedence
414
+ # is the narrative cause; reader can walk CAUSAL to
415
+ # answer "why did X change?")
416
+ wire_causal_edge(
417
+ self.store, fact.id, old.id,
418
+ reason=f"superseded: {decision.note}")
419
+ self.store.insert_fact(fact, commit)
420
+ self.store.add_edge(fact.id, chunk_id, "EXTRACTED_FROM")
421
+ self.palace.add(fact.id, self.palace.encode_fact(fact))
422
+ results.append(self._result(fact, "SUPERSEDED", decision.note))
423
+ return 1
424
+ # COMMIT / COEXIST
425
+ self.store.insert_fact(fact, commit)
426
+ self.store.add_edge(fact.id, chunk_id, "EXTRACTED_FROM")
427
+ self.palace.add(fact.id, self.palace.encode_fact(fact))
428
+ results.append(self._result(fact, "ADD", decision.note))
429
+ return 1
430
+
431
+ # ------------------------------------------------------------------
432
+ def _apply_retraction(self, fact: Fact, cand, commit, chunk_id,
433
+ msg_time, results) -> int:
434
+ """left(X, org) — deactivate matching works_at facts (truth maintenance)."""
435
+ retired = 0
436
+ for f in self.store.query_facts(subject=fact.subject,
437
+ relation="works_at",
438
+ user_id=fact.user_id, active=True):
439
+ if similarity(f.value, fact.value) >= 0.6:
440
+ vt = fact.valid_from or iso(msg_time)[:10]
441
+ # clamp: a "left in Feb" retraction learned after a fact whose
442
+ # valid_from is later (re-stated employment) must not invert
443
+ # the interval — cap at the fact's own valid_from boundary.
444
+ if f.valid_from and vt < f.valid_from:
445
+ vt = f.valid_from
446
+ self.store.update_fact(
447
+ f.id, valid_to=vt,
448
+ is_active=0, tx_to=iso(_now()), retired_commit=commit,
449
+ provenance={**f.provenance, "retracted_by": fact.id})
450
+ self.store.add_edge(fact.id, f.id, "CONTRADICTS",
451
+ {"reason": "retraction (left org)"})
452
+ # Aeon CAUSAL: the retraction fact CAUSED the prior
453
+ # works_at fact to be retired — walking CAUSAL from the
454
+ # retired fact yields the retraction (and vice versa).
455
+ wire_causal_edge(
456
+ self.store, fact.id, f.id,
457
+ reason="retraction (left org)")
458
+ retired += 1
459
+ self.store.insert_fact(fact, commit)
460
+ self.store.add_edge(fact.id, chunk_id, "EXTRACTED_FROM")
461
+ self.palace.add(fact.id, self.palace.encode_fact(fact))
462
+ results.append(self._result(
463
+ fact, "RETRACTION", f"retired {retired} stale facts"))
464
+ return 1 + retired
465
+
466
+ # ------------------------------------------------------------------
467
+ def _wire_temporal_edges(self, user_id: str) -> None:
468
+ """TEMPORALLY_PRECEDED_BY chains between consecutive events."""
469
+ events = self.store.query_facts(relation="event", user_id=user_id,
470
+ active=True, order="valid_from",
471
+ limit=800)
472
+ by_subject: dict[str, list[Fact]] = {}
473
+ for f in events:
474
+ by_subject.setdefault(f.subject, []).append(f)
475
+ for subj, evs in by_subject.items():
476
+ evs.sort(key=lambda f: f.valid_from)
477
+ for a, b in zip(evs, evs[1:]):
478
+ self.store.add_edge(b.id, a.id, "TEMPORALLY_PRECEDED_BY")
479
+
480
+ # ------------------------------------------------------------------
481
+ @staticmethod
482
+ def _result(fact: Fact, event: str, note: str = "") -> dict:
483
+ return {
484
+ "id": fact.id,
485
+ "memory": f"{fact.subject} | {fact.relation} | {fact.value}",
486
+ "event": event,
487
+ "hash": fact.source_hash,
488
+ "confidence": fact.confidence,
489
+ "valid_from": fact.valid_from,
490
+ "valid_to": fact.valid_to,
491
+ "note": note,
492
+ }