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.
- context_m.py +17 -0
- cortexm/__init__.py +45 -0
- cortexm/accel.py +403 -0
- cortexm/api/__init__.py +0 -0
- cortexm/api/chaos.py +118 -0
- cortexm/api/memory.py +635 -0
- cortexm/bench/__init__.py +0 -0
- cortexm/bench/abilities.py +311 -0
- cortexm/bench/baselines.py +89 -0
- cortexm/bench/beam_loader.py +317 -0
- cortexm/bench/generator.py +376 -0
- cortexm/bench/harness.py +211 -0
- cortexm/bench/messy.py +218 -0
- cortexm/bench/micro.py +251 -0
- cortexm/bench/ood.py +443 -0
- cortexm/bench/run.py +137 -0
- cortexm/bridge/__init__.py +0 -0
- cortexm/bridge/dates.py +178 -0
- cortexm/bridge/decoders.py +204 -0
- cortexm/bridge/enrich.py +255 -0
- cortexm/bridge/extractor.py +316 -0
- cortexm/bridge/fallback.py +332 -0
- cortexm/bridge/onnx_runtime.py +158 -0
- cortexm/bridge/patterns.py +760 -0
- cortexm/bridge/ppr.py +104 -0
- cortexm/bridge/prefilter.py +188 -0
- cortexm/bridge/query_extract.py +420 -0
- cortexm/bridge/reader.py +1174 -0
- cortexm/bridge/rerank.py +204 -0
- cortexm/bridge/writer.py +492 -0
- cortexm/cli.py +295 -0
- cortexm/cognition/__init__.py +53 -0
- cortexm/cognition/abstraction.py +192 -0
- cortexm/cognition/analogy.py +159 -0
- cortexm/cognition/engine.py +204 -0
- cortexm/cognition/gaps.py +365 -0
- cortexm/cognition/scanner.py +204 -0
- cortexm/config.py +375 -0
- cortexm/cortexm.py +8 -0
- cortexm/enterprise/__init__.py +0 -0
- cortexm/enterprise/audit.py +178 -0
- cortexm/enterprise/governance.py +239 -0
- cortexm/errors.py +35 -0
- cortexm/features/__init__.py +0 -0
- cortexm/features/git.py +204 -0
- cortexm/features/prefetch.py +88 -0
- cortexm/features/zk.py +105 -0
- cortexm/federation/__init__.py +39 -0
- cortexm/federation/crdt.py +275 -0
- cortexm/federation/fabric.py +109 -0
- cortexm/federation/hlc.py +80 -0
- cortexm/federation/node.py +145 -0
- cortexm/federation/schema_report.py +73 -0
- cortexm/federation/transport.py +164 -0
- cortexm/index/__init__.py +19 -0
- cortexm/index/nsg.py +386 -0
- cortexm/mcp/__init__.py +0 -0
- cortexm/mcp/server.py +985 -0
- cortexm/metrics.py +62 -0
- cortexm/migrate/__init__.py +0 -0
- cortexm/migrate/importers.py +192 -0
- cortexm/provenance/__init__.py +78 -0
- cortexm/provenance/agent.py +214 -0
- cortexm/provenance/cose.py +201 -0
- cortexm/provenance/scitt.py +258 -0
- cortexm/provenance/vc.py +250 -0
- cortexm/security/__init__.py +0 -0
- cortexm/security/crypto.py +162 -0
- cortexm/security/hashes.py +140 -0
- cortexm/security/injection.py +149 -0
- cortexm/security/mind.py +154 -0
- cortexm/security/pii.py +265 -0
- cortexm/security/rbac.py +169 -0
- cortexm/security/sandbox.py +131 -0
- cortexm/security/zk_hamming.py +142 -0
- cortexm/security/zk_sql.py +485 -0
- cortexm/server/__init__.py +0 -0
- cortexm/server/metrics.py +88 -0
- cortexm/server/rest.py +936 -0
- cortexm/server/sparql.py +984 -0
- cortexm/text/__init__.py +0 -0
- cortexm/text/dissim.py +252 -0
- cortexm/text/embedder.py +155 -0
- cortexm/text/fuzzy.py +218 -0
- cortexm/text/idiolect.py +253 -0
- cortexm/text/labse.py +374 -0
- cortexm/text/tokenizer.py +79 -0
- cortexm/trace/__init__.py +0 -0
- cortexm/trace/blob_arena.py +277 -0
- cortexm/trace/consolidate.py +337 -0
- cortexm/trace/contradictions.py +69 -0
- cortexm/trace/dedup.py +114 -0
- cortexm/trace/edges.py +214 -0
- cortexm/trace/fact.py +121 -0
- cortexm/trace/fade.py +245 -0
- cortexm/trace/lifecycle.py +112 -0
- cortexm/trace/rebuild.py +173 -0
- cortexm/trace/rules.py +171 -0
- cortexm/trace/store.py +680 -0
- cortexm/trace/structural.py +183 -0
- cortexm/trace/tmt.py +335 -0
- cortexm/util.py +148 -0
- cortexm/vsa/__init__.py +0 -0
- cortexm/vsa/attribution.py +149 -0
- cortexm/vsa/cleanup.py +161 -0
- cortexm/vsa/codecs.py +397 -0
- cortexm/vsa/hologram_overlay.py +139 -0
- cortexm/vsa/index.py +163 -0
- cortexm/vsa/ops.py +149 -0
- cortexm/vsa/palace.py +446 -0
- cortexm/vsa/role_vectors.py +236 -0
- cortexm/vsa/slb.py +78 -0
- cortexm/vsa/tlsh_trie.py +137 -0
- cortexm/vsa/working_memory.py +249 -0
- cortexm-0.3.0.dist-info/METADATA +482 -0
- cortexm-0.3.0.dist-info/RECORD +120 -0
- cortexm-0.3.0.dist-info/WHEEL +5 -0
- cortexm-0.3.0.dist-info/entry_points.txt +2 -0
- cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
- cortexm-0.3.0.dist-info/top_level.txt +2 -0
cortexm/bridge/writer.py
ADDED
|
@@ -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
|
+
}
|