crprotocol 2.0.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 (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
crp/state/event_log.py ADDED
@@ -0,0 +1,313 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Event sourcing — append-only immutable fact lifecycle log (§3.2).
4
+
5
+ Every fact mutation (create, supersede, compact, archive, restore) is recorded
6
+ as a FactEvent. The log supports temporal queries: state_at_window,
7
+ facts_between, supersession_chain.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from collections.abc import Iterator
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from crp.extraction.types import Fact, FactEdge
19
+
20
+ from .fact import StateFact
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Event types
24
+ # ---------------------------------------------------------------------------
25
+
26
+ EVENT_CREATED = "created"
27
+ EVENT_SUPERSEDED = "superseded"
28
+ EVENT_COMPACTED = "compacted"
29
+ EVENT_ARCHIVED = "archived"
30
+ EVENT_RESTORED = "restored"
31
+ EVENT_EDGE_ADDED = "edge_added"
32
+
33
+ ALL_EVENT_TYPES = frozenset(
34
+ {EVENT_CREATED, EVENT_SUPERSEDED, EVENT_COMPACTED, EVENT_ARCHIVED, EVENT_RESTORED, EVENT_EDGE_ADDED}
35
+ )
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # FactEvent (from extraction/types.py, but we re-define here with monotonic ID)
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ @dataclass
43
+ class FactEvent:
44
+ """Immutable audit-log entry for a fact lifecycle event."""
45
+
46
+ event_id: int = 0 # monotonically increasing
47
+ timestamp: float = field(default_factory=time.time)
48
+ window_id: str = ""
49
+ event_type: str = ""
50
+ fact_id: str = ""
51
+ payload: dict[str, Any] = field(default_factory=dict)
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "event_id": self.event_id,
56
+ "timestamp": self.timestamp,
57
+ "window_id": self.window_id,
58
+ "event_type": self.event_type,
59
+ "fact_id": self.fact_id,
60
+ "payload": self.payload,
61
+ }
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> FactEvent:
65
+ return cls(
66
+ event_id=data.get("event_id", 0),
67
+ timestamp=data.get("timestamp", 0.0),
68
+ window_id=data.get("window_id", ""),
69
+ event_type=data.get("event_type", ""),
70
+ fact_id=data.get("fact_id", ""),
71
+ payload=data.get("payload", {}),
72
+ )
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # FactEventLog — append-only, thread-safe
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class FactEventLog:
81
+ """Append-only immutable event log for fact lifecycle (§3.2).
82
+
83
+ Supports:
84
+ - append(): add new event with monotonic ID
85
+ - state_at_window(): replay events to reconstruct state at a point
86
+ - facts_between(): range query
87
+ - supersession_chain(): full lifecycle trail for a fact
88
+ - Temporal queries: events_since, events_for_fact
89
+ """
90
+
91
+ def __init__(self) -> None:
92
+ self._events: list[FactEvent] = []
93
+ self._next_id: int = 1
94
+ self._lock = threading.Lock()
95
+
96
+ # Indexes for fast lookup
97
+ self._by_fact: dict[str, list[int]] = {} # fact_id → [event indices]
98
+ self._by_window: dict[str, list[int]] = {} # window_id → [event indices]
99
+
100
+ @property
101
+ def size(self) -> int:
102
+ return len(self._events)
103
+
104
+ # --- Append ---------------------------------------------------------------
105
+
106
+ def append(
107
+ self,
108
+ event_type: str,
109
+ fact_id: str,
110
+ window_id: str,
111
+ payload: dict[str, Any] | None = None,
112
+ ) -> FactEvent:
113
+ """Record an immutable event. Returns the created event."""
114
+ with self._lock:
115
+ event = FactEvent(
116
+ event_id=self._next_id,
117
+ timestamp=time.time(),
118
+ window_id=window_id,
119
+ event_type=event_type,
120
+ fact_id=fact_id,
121
+ payload=payload or {},
122
+ )
123
+ idx = len(self._events)
124
+ self._events.append(event)
125
+ self._next_id += 1
126
+
127
+ # Update indexes
128
+ self._by_fact.setdefault(fact_id, []).append(idx)
129
+ self._by_window.setdefault(window_id, []).append(idx)
130
+
131
+ return event
132
+
133
+ def record_fact_created(self, fact: Fact | StateFact, window_id: str) -> FactEvent:
134
+ """Convenience: record a fact creation event."""
135
+ fid = fact.id if isinstance(fact, StateFact) else fact.id
136
+ text = fact.text if isinstance(fact, StateFact) else fact.text
137
+ return self.append(
138
+ EVENT_CREATED,
139
+ fid,
140
+ window_id,
141
+ {"text": text, "confidence": fact.confidence},
142
+ )
143
+
144
+ def record_supersession(
145
+ self, old_fact_id: str, new_fact_id: str, window_id: str, confidence: float = 1.0
146
+ ) -> FactEvent:
147
+ return self.append(
148
+ EVENT_SUPERSEDED,
149
+ old_fact_id,
150
+ window_id,
151
+ {"superseded_by": new_fact_id, "confidence": confidence},
152
+ )
153
+
154
+ def record_compaction(self, fact_id: str, window_id: str, summary_id: str = "") -> FactEvent:
155
+ return self.append(EVENT_COMPACTED, fact_id, window_id, {"summary_id": summary_id})
156
+
157
+ def record_archived(self, fact_id: str, window_id: str) -> FactEvent:
158
+ return self.append(EVENT_ARCHIVED, fact_id, window_id)
159
+
160
+ def record_restored(self, fact_id: str, window_id: str) -> FactEvent:
161
+ return self.append(EVENT_RESTORED, fact_id, window_id)
162
+
163
+ def record_edge_added(self, edge: FactEdge, window_id: str) -> FactEvent:
164
+ return self.append(
165
+ EVENT_EDGE_ADDED,
166
+ edge.source_id,
167
+ window_id,
168
+ {
169
+ "edge_id": edge.id,
170
+ "target_id": edge.target_id,
171
+ "relation_type": edge.relation_type.value if hasattr(edge.relation_type, "value") else str(edge.relation_type),
172
+ "confidence": edge.confidence,
173
+ },
174
+ )
175
+
176
+ # --- Temporal queries (§3.2) ----------------------------------------------
177
+
178
+ def state_at_window(self, window_id: str) -> set[str]:
179
+ """Replay events up to *window_id* and return the set of active fact IDs.
180
+
181
+ An active fact is one that was created and not yet superseded/compacted/archived.
182
+ """
183
+ active: set[str] = set()
184
+ with self._lock:
185
+ target_indices = self._by_window.get(window_id)
186
+ if target_indices is None:
187
+ return active
188
+ max_idx = max(target_indices)
189
+
190
+ for event in self._events[: max_idx + 1]:
191
+ if event.event_type == EVENT_CREATED:
192
+ active.add(event.fact_id)
193
+ elif event.event_type in (EVENT_SUPERSEDED, EVENT_COMPACTED, EVENT_ARCHIVED):
194
+ active.discard(event.fact_id)
195
+ elif event.event_type == EVENT_RESTORED:
196
+ active.add(event.fact_id)
197
+ return active
198
+
199
+ def facts_between(self, start_window: str, end_window: str) -> list[FactEvent]:
200
+ """Return events between two windows (inclusive)."""
201
+ with self._lock:
202
+ start_indices = self._by_window.get(start_window)
203
+ end_indices = self._by_window.get(end_window)
204
+ if start_indices is None or end_indices is None:
205
+ return []
206
+ min_idx = min(start_indices)
207
+ max_idx = max(end_indices)
208
+ return list(self._events[min_idx: max_idx + 1])
209
+
210
+ def supersession_chain(self, fact_id: str) -> list[FactEvent]:
211
+ """Return the full supersession lifecycle trail for *fact_id*."""
212
+ chain: list[FactEvent] = []
213
+ current_id = fact_id
214
+
215
+ with self._lock:
216
+ visited: set[str] = set()
217
+ while current_id and current_id not in visited:
218
+ visited.add(current_id)
219
+ indices = self._by_fact.get(current_id, [])
220
+ for idx in indices:
221
+ event = self._events[idx]
222
+ chain.append(event)
223
+ if event.event_type == EVENT_SUPERSEDED:
224
+ next_id = event.payload.get("superseded_by", "")
225
+ if next_id:
226
+ current_id = next_id
227
+ break
228
+ else:
229
+ break # no supersession event found
230
+ return chain
231
+
232
+ def events_since(self, timestamp: float) -> list[FactEvent]:
233
+ """Return all events after *timestamp*."""
234
+ with self._lock:
235
+ return [e for e in self._events if e.timestamp >= timestamp]
236
+
237
+ def events_for_fact(self, fact_id: str) -> list[FactEvent]:
238
+ """Return all events for a specific fact."""
239
+ with self._lock:
240
+ indices = self._by_fact.get(fact_id, [])
241
+ return [self._events[i] for i in indices]
242
+
243
+ def events_by_type(self, event_type: str) -> list[FactEvent]:
244
+ """Filter events by type."""
245
+ with self._lock:
246
+ return [e for e in self._events if e.event_type == event_type]
247
+
248
+ def events_in_window(self, window_id: str) -> list[FactEvent]:
249
+ """Return all events for a specific window."""
250
+ with self._lock:
251
+ indices = self._by_window.get(window_id, [])
252
+ return [self._events[i] for i in indices]
253
+
254
+ # --- Iteration / bulk access ----------------------------------------------
255
+
256
+ def all_events(self) -> list[FactEvent]:
257
+ """Return a copy of all events."""
258
+ with self._lock:
259
+ return list(self._events)
260
+
261
+ def __iter__(self) -> Iterator[FactEvent]:
262
+ return iter(self._events)
263
+
264
+ def __len__(self) -> int:
265
+ return len(self._events)
266
+
267
+ # --- Serialization --------------------------------------------------------
268
+
269
+ def to_list(self) -> list[dict[str, Any]]:
270
+ """Serialize all events for persistence."""
271
+ with self._lock:
272
+ return [e.to_dict() for e in self._events]
273
+
274
+ def load_from_list(self, events: list[dict[str, Any]]) -> None:
275
+ """Restore from serialized event list."""
276
+ with self._lock:
277
+ self._events.clear()
278
+ self._by_fact.clear()
279
+ self._by_window.clear()
280
+ self._next_id = 1
281
+ for edata in events:
282
+ event = FactEvent.from_dict(edata)
283
+ idx = len(self._events)
284
+ self._events.append(event)
285
+ self._by_fact.setdefault(event.fact_id, []).append(idx)
286
+ self._by_window.setdefault(event.window_id, []).append(idx)
287
+ if event.event_id >= self._next_id:
288
+ self._next_id = event.event_id + 1
289
+
290
+ # --- Truncation (for snapshot support) ------------------------------------
291
+
292
+ def truncate_before(self, event_id: int) -> list[FactEvent]:
293
+ """Remove events before *event_id*. Returns removed events for archival."""
294
+ with self._lock:
295
+ cut_idx = 0
296
+ for i, e in enumerate(self._events):
297
+ if e.event_id >= event_id:
298
+ cut_idx = i
299
+ break
300
+ else:
301
+ cut_idx = len(self._events)
302
+
303
+ removed = self._events[:cut_idx]
304
+ self._events = self._events[cut_idx:]
305
+
306
+ # Rebuild indexes
307
+ self._by_fact.clear()
308
+ self._by_window.clear()
309
+ for i, e in enumerate(self._events):
310
+ self._by_fact.setdefault(e.fact_id, []).append(i)
311
+ self._by_window.setdefault(e.window_id, []).append(i)
312
+
313
+ return removed
crp/state/fact.py ADDED
@@ -0,0 +1,189 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """State-layer Fact model — extends extraction Fact with lazy embedding,
4
+ age tracking, and seen_count for the 4-tier memory hierarchy (§3.1).
5
+
6
+ StateFact wraps extraction.types.Fact with additional state-management fields
7
+ that the warm store, envelope builder, and compaction engine need.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from crp.extraction.types import Fact
18
+
19
+ logger = logging.getLogger("crp.state.fact")
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Lazy embedding support
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _EMBED_FN: Any = None # set by scoring / warm store init
26
+
27
+
28
+ def set_embedding_function(fn: Any) -> None:
29
+ """Register the global embedding function for lazy compute."""
30
+ global _EMBED_FN # noqa: PLW0603
31
+ _EMBED_FN = fn
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # StateFact
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @dataclass
40
+ class StateFact:
41
+ """Fact extended with state-management metadata (§3.1).
42
+
43
+ Wraps an extraction ``Fact`` and adds:
44
+ - Lazy embedding (computed on first access, cached)
45
+ - ``age_in_windows`` — updated each window by the warm store
46
+ - ``seen_count`` — how many envelopes this fact appeared in
47
+ - ``consumed_by_windows`` — which windows used this fact
48
+ - ``graph_edges`` — IDs of connected FactEdges
49
+ """
50
+
51
+ fact: Fact
52
+ _embedding: list[float] | None = field(default=None, repr=False)
53
+ age_in_windows: int = 0
54
+ seen_count: int = 0
55
+ consumed_by_windows: list[str] = field(default_factory=list)
56
+ graph_edges: list[str] = field(default_factory=list)
57
+ tier: int = 2 # 0=critical, 1=hot, 2=warm, 3=cold
58
+
59
+ # --- Delegated properties from inner Fact ---------------------------------
60
+
61
+ @property
62
+ def id(self) -> str:
63
+ return self.fact.id
64
+
65
+ @property
66
+ def text(self) -> str:
67
+ return self.fact.text
68
+
69
+ @property
70
+ def category(self) -> str:
71
+ return self.fact.category
72
+
73
+ @property
74
+ def confidence(self) -> float:
75
+ return self.fact.confidence
76
+
77
+ @property
78
+ def source_window_id(self) -> str:
79
+ return self.fact.source_window_id
80
+
81
+ @property
82
+ def created_at(self) -> float:
83
+ return self.fact.created_at
84
+
85
+ @property
86
+ def superseded_by(self) -> str | None:
87
+ return self.fact.superseded_by
88
+
89
+ @superseded_by.setter
90
+ def superseded_by(self, value: str | None) -> None:
91
+ self.fact.superseded_by = value
92
+
93
+ # --- Lazy embedding -------------------------------------------------------
94
+
95
+ @property
96
+ def embedding(self) -> list[float] | None:
97
+ """Lazy-compute embedding on first access."""
98
+ if self._embedding is None and _EMBED_FN is not None:
99
+ try:
100
+ self._embedding = _EMBED_FN(self.text)
101
+ except Exception: # noqa: BLE001
102
+ logger.warning("Embedding computation failed for fact %s", self.id)
103
+ return self._embedding
104
+
105
+ @embedding.setter
106
+ def embedding(self, value: list[float] | None) -> None:
107
+ self._embedding = value
108
+
109
+ def has_embedding(self) -> bool:
110
+ return self._embedding is not None
111
+
112
+ # --- State mutations ------------------------------------------------------
113
+
114
+ def mark_seen(self, window_id: str) -> None:
115
+ """Record that this fact was included in an envelope for *window_id*."""
116
+ self.seen_count += 1
117
+ if window_id not in self.consumed_by_windows:
118
+ self.consumed_by_windows.append(window_id)
119
+
120
+ def increment_age(self) -> None:
121
+ """Advance age by one window."""
122
+ self.age_in_windows += 1
123
+
124
+ def supersede(self, by_fact_id: str, confidence: float = 1.0) -> None:
125
+ """Mark this fact as superseded."""
126
+ self.fact.superseded_by = by_fact_id
127
+ self.fact.supersession_confidence = confidence
128
+
129
+ @property
130
+ def is_superseded(self) -> bool:
131
+ return self.fact.superseded_by is not None
132
+
133
+ # --- Serialization --------------------------------------------------------
134
+
135
+ def to_dict(self) -> dict[str, Any]:
136
+ """Serialize to dict for persistence (including embeddings §4D.1)."""
137
+ d: dict[str, Any] = {
138
+ "fact_id": self.id,
139
+ "text": self.text,
140
+ "category": self.category,
141
+ "confidence": self.confidence,
142
+ "source_window_id": self.source_window_id,
143
+ "created_at": self.created_at,
144
+ "extraction_stage": self.fact.extraction_stage,
145
+ "age_in_windows": self.age_in_windows,
146
+ "seen_count": self.seen_count,
147
+ "consumed_by_windows": self.consumed_by_windows,
148
+ "graph_edges": self.graph_edges,
149
+ "superseded_by": self.superseded_by,
150
+ "supersession_confidence": self.fact.supersession_confidence,
151
+ "tier": self.tier,
152
+ "has_embedding": self.has_embedding(),
153
+ }
154
+ # Persist actual embedding vectors when available (§4D.1)
155
+ if self._embedding is not None:
156
+ d["embedding"] = self._embedding
157
+ return d
158
+
159
+ @classmethod
160
+ def from_fact(cls, fact: Fact) -> StateFact:
161
+ """Wrap an extraction Fact into a StateFact."""
162
+ return cls(fact=fact)
163
+
164
+ @classmethod
165
+ def from_dict(cls, data: dict[str, Any]) -> StateFact:
166
+ """Deserialize from dict."""
167
+ fact = Fact(
168
+ id=data["fact_id"],
169
+ text=data["text"],
170
+ category=data.get("category", ""),
171
+ confidence=data.get("confidence", 0.0),
172
+ source_window_id=data.get("source_window_id", ""),
173
+ created_at=data.get("created_at", time.time()),
174
+ extraction_stage=data.get("extraction_stage", 0),
175
+ superseded_by=data.get("superseded_by"),
176
+ supersession_confidence=data.get("supersession_confidence", 0.0),
177
+ )
178
+ sf = cls(
179
+ fact=fact,
180
+ age_in_windows=data.get("age_in_windows", 0),
181
+ seen_count=data.get("seen_count", 0),
182
+ consumed_by_windows=data.get("consumed_by_windows", []),
183
+ graph_edges=data.get("graph_edges", []),
184
+ tier=data.get("tier", 2),
185
+ )
186
+ # Restore embedding vectors if persisted (§4D.1)
187
+ if "embedding" in data and data["embedding"] is not None:
188
+ sf._embedding = data["embedding"]
189
+ return sf
@@ -0,0 +1,189 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """FactGraph serialization — on-disk format with schema versioning (§22).
4
+
5
+ Provides forward/backward compatible serialization of the full fact graph
6
+ including nodes, edges, and metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from crp.extraction.types import Fact, FactEdge, FactGraph, RelationType
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Schema version
21
+ # ---------------------------------------------------------------------------
22
+
23
+ GRAPH_SCHEMA_VERSION = 1
24
+
25
+
26
+ @dataclass
27
+ class GraphSerializationHeader:
28
+ """Header for serialized FactGraph files."""
29
+
30
+ schema_version: int = GRAPH_SCHEMA_VERSION
31
+ node_count: int = 0
32
+ edge_count: int = 0
33
+ checksum: str = ""
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # FactGraphSerializer
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class FactGraphSerializer:
42
+ """Serialize/deserialize FactGraph to/from disk (§22).
43
+
44
+ Format: JSON with header + nodes + edges.
45
+ Schema-versioned for forward/backward compatibility.
46
+ """
47
+
48
+ @staticmethod
49
+ def serialize(graph: FactGraph) -> dict[str, Any]:
50
+ """Serialize a FactGraph to a dict."""
51
+ nodes = []
52
+ for _fid, fact in graph.nodes.items():
53
+ nodes.append({
54
+ "id": fact.id,
55
+ "text": fact.text,
56
+ "category": fact.category,
57
+ "source_window_id": fact.source_window_id,
58
+ "confidence": fact.confidence,
59
+ "extraction_stage": fact.extraction_stage,
60
+ "created_at": fact.created_at,
61
+ "superseded_by": fact.superseded_by,
62
+ "supersession_confidence": fact.supersession_confidence,
63
+ "metadata": fact.metadata,
64
+ })
65
+
66
+ edges = []
67
+ for edge in graph.edges:
68
+ rel_type = edge.relation_type
69
+ if isinstance(rel_type, RelationType):
70
+ rel_type = rel_type.value
71
+ edges.append({
72
+ "id": edge.id,
73
+ "source_id": edge.source_id,
74
+ "target_id": edge.target_id,
75
+ "relation_type": str(rel_type),
76
+ "confidence": edge.confidence,
77
+ "source_stage": edge.source_stage,
78
+ "metadata": edge.metadata,
79
+ })
80
+
81
+ payload = {"nodes": nodes, "edges": edges}
82
+ payload_str = json.dumps(payload, sort_keys=True, default=str)
83
+
84
+ try:
85
+ import blake3 # type: ignore[import-untyped]
86
+
87
+ checksum = blake3.blake3(payload_str.encode()).hexdigest()
88
+ except ImportError:
89
+ checksum = hashlib.sha256(payload_str.encode()).hexdigest()
90
+
91
+ header = GraphSerializationHeader(
92
+ node_count=len(nodes),
93
+ edge_count=len(edges),
94
+ checksum=checksum,
95
+ )
96
+
97
+ return {
98
+ "header": {
99
+ "schema_version": header.schema_version,
100
+ "node_count": header.node_count,
101
+ "edge_count": header.edge_count,
102
+ "checksum": header.checksum,
103
+ },
104
+ "nodes": nodes,
105
+ "edges": edges,
106
+ }
107
+
108
+ @staticmethod
109
+ def deserialize(data: dict[str, Any]) -> tuple[FactGraph, list[str]]:
110
+ """Deserialize a dict into a FactGraph. Returns (graph, warnings)."""
111
+ warnings: list[str] = []
112
+ header = data.get("header", {})
113
+
114
+ # Schema version check
115
+ version = header.get("schema_version", 0)
116
+ if version != GRAPH_SCHEMA_VERSION:
117
+ warnings.append(f"Schema version mismatch: {version} vs {GRAPH_SCHEMA_VERSION}")
118
+
119
+ graph = FactGraph()
120
+
121
+ # Deserialize nodes
122
+ for ndata in data.get("nodes", []):
123
+ fact = Fact(
124
+ id=ndata.get("id", ""),
125
+ text=ndata.get("text", ""),
126
+ category=ndata.get("category", ""),
127
+ source_window_id=ndata.get("source_window_id", ""),
128
+ confidence=ndata.get("confidence", 0.0),
129
+ extraction_stage=ndata.get("extraction_stage", 0),
130
+ created_at=ndata.get("created_at", 0.0),
131
+ superseded_by=ndata.get("superseded_by"),
132
+ supersession_confidence=ndata.get("supersession_confidence", 0.0),
133
+ metadata=ndata.get("metadata", {}),
134
+ )
135
+ graph.add_fact(fact)
136
+
137
+ # Deserialize edges (check orphans before add_edge, which silently drops them)
138
+ node_ids = set(graph.nodes.keys())
139
+ for edata in data.get("edges", []):
140
+ rel_str = edata.get("relation_type", "RELATED")
141
+ try:
142
+ rel_type: RelationType | str = RelationType(rel_str)
143
+ except ValueError:
144
+ rel_type = rel_str
145
+
146
+ edge = FactEdge(
147
+ id=edata.get("id", ""),
148
+ source_id=edata.get("source_id", ""),
149
+ target_id=edata.get("target_id", ""),
150
+ relation_type=rel_type,
151
+ confidence=edata.get("confidence", 0.0),
152
+ source_stage=edata.get("source_stage", 0),
153
+ metadata=edata.get("metadata", {}),
154
+ )
155
+ if edge.source_id not in node_ids:
156
+ warnings.append(f"Orphaned edge source: {edge.source_id}")
157
+ if edge.target_id not in node_ids:
158
+ warnings.append(f"Orphaned edge target: {edge.target_id}")
159
+ graph.add_edge(edge)
160
+
161
+ # Verify counts
162
+ expected_nodes = header.get("node_count", 0)
163
+ expected_edges = header.get("edge_count", 0)
164
+ if expected_nodes and len(graph.nodes) != expected_nodes:
165
+ warnings.append(f"Node count mismatch: header={expected_nodes}, loaded={len(graph.nodes)}")
166
+ if expected_edges and len(graph.edges) != expected_edges:
167
+ warnings.append(f"Edge count mismatch: header={expected_edges}, loaded={len(graph.edges)}")
168
+
169
+ return graph, warnings
170
+
171
+ @classmethod
172
+ def save_to_file(cls, graph: FactGraph, path: str | Path) -> None:
173
+ """Serialize and write to file."""
174
+ data = cls.serialize(graph)
175
+ p = Path(path)
176
+ p.parent.mkdir(parents=True, exist_ok=True)
177
+ p.write_text(json.dumps(data, default=str), encoding="utf-8")
178
+
179
+ @classmethod
180
+ def load_from_file(cls, path: str | Path) -> tuple[FactGraph, list[str]]:
181
+ """Load from file. Returns (graph, warnings)."""
182
+ p = Path(path)
183
+ if not p.exists():
184
+ return FactGraph(), [f"File not found: {path}"]
185
+ try:
186
+ data = json.loads(p.read_text(encoding="utf-8"))
187
+ except (json.JSONDecodeError, OSError) as exc:
188
+ return FactGraph(), [f"Failed to read file: {exc}"]
189
+ return cls.deserialize(data)