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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- 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)
|