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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Session file TTL cleanup — automatic expiry of persisted sessions (§audit H11).
|
|
4
|
+
|
|
5
|
+
Production deployments accumulate session JSON files indefinitely.
|
|
6
|
+
This module provides automatic cleanup based on file age.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("crp.state.session_cleanup")
|
|
16
|
+
|
|
17
|
+
# Default TTL: 7 days
|
|
18
|
+
DEFAULT_TTL_SECONDS = 7 * 24 * 3600
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cleanup_expired_sessions(
|
|
22
|
+
sessions_dir: str | None = None,
|
|
23
|
+
ttl_seconds: float = DEFAULT_TTL_SECONDS,
|
|
24
|
+
dry_run: bool = False,
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
"""Remove session files older than *ttl_seconds*.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
sessions_dir: Path to the session storage directory.
|
|
30
|
+
Defaults to ``./crp_sessions``.
|
|
31
|
+
ttl_seconds: Maximum age in seconds (default 7 days).
|
|
32
|
+
dry_run: If True, list but do not delete files.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of removed (or would-be-removed) file paths.
|
|
36
|
+
"""
|
|
37
|
+
if sessions_dir is None:
|
|
38
|
+
data_dir = os.environ.get("CRP_DATA_DIR", ".")
|
|
39
|
+
sessions_dir = os.path.join(data_dir, "crp_sessions")
|
|
40
|
+
|
|
41
|
+
if not os.path.isdir(sessions_dir):
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
now = time.time()
|
|
45
|
+
cutoff = now - ttl_seconds
|
|
46
|
+
removed: list[str] = []
|
|
47
|
+
|
|
48
|
+
for entry in os.scandir(sessions_dir):
|
|
49
|
+
if not entry.is_file():
|
|
50
|
+
continue
|
|
51
|
+
# Only clean CRP-generated files (.json, .events.bin, .audit.jsonl)
|
|
52
|
+
if not (entry.name.endswith(".json") or entry.name.endswith(".events.bin")
|
|
53
|
+
or entry.name.endswith(".audit.jsonl")):
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
mtime = entry.stat().st_mtime
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
if mtime < cutoff:
|
|
60
|
+
path = entry.path
|
|
61
|
+
if dry_run:
|
|
62
|
+
logger.info("Would remove expired session file: %s (age %.0f h)",
|
|
63
|
+
path, (now - mtime) / 3600)
|
|
64
|
+
else:
|
|
65
|
+
try:
|
|
66
|
+
os.remove(path)
|
|
67
|
+
logger.info("Removed expired session file: %s (age %.0f h)",
|
|
68
|
+
path, (now - mtime) / 3600)
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
logger.warning("Failed to remove %s: %s", path, exc)
|
|
71
|
+
continue
|
|
72
|
+
removed.append(path)
|
|
73
|
+
|
|
74
|
+
if removed:
|
|
75
|
+
logger.info("Session cleanup: %d file(s) %s",
|
|
76
|
+
len(removed), "would be removed" if dry_run else "removed")
|
|
77
|
+
return removed
|
crp/state/snapshot.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Snapshots & session resume — periodic checkpoint + restore (§3.3, §22.4).
|
|
4
|
+
|
|
5
|
+
Snapshots capture warm store state at regular intervals so the event log can
|
|
6
|
+
be truncated. Session resume protocol: load snapshot → verify integrity →
|
|
7
|
+
replay events → rebuild ANN.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .event_log import FactEventLog
|
|
21
|
+
from .warm_store import WarmStateStore
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Snapshot data
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
SNAPSHOT_INTERVAL = 50 # windows between snapshots
|
|
28
|
+
SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EventLogSnapshot:
|
|
33
|
+
"""Captured state at a point in time (§3.3)."""
|
|
34
|
+
|
|
35
|
+
snapshot_id: str = ""
|
|
36
|
+
schema_version: int = SCHEMA_VERSION
|
|
37
|
+
window_id: str = ""
|
|
38
|
+
window_count: int = 0
|
|
39
|
+
timestamp: float = field(default_factory=time.time)
|
|
40
|
+
warm_store_data: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
last_event_id: int = 0
|
|
42
|
+
checksum: str = "" # BLAKE3 or SHA-256
|
|
43
|
+
|
|
44
|
+
def compute_checksum(self) -> str:
|
|
45
|
+
"""Compute integrity checksum over the snapshot payload."""
|
|
46
|
+
payload = json.dumps(self.warm_store_data, sort_keys=True, default=str)
|
|
47
|
+
try:
|
|
48
|
+
import blake3 # type: ignore[import-untyped]
|
|
49
|
+
|
|
50
|
+
return blake3.blake3(payload.encode()).hexdigest()
|
|
51
|
+
except ImportError:
|
|
52
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
53
|
+
|
|
54
|
+
def verify_checksum(self) -> bool:
|
|
55
|
+
"""Verify the stored checksum matches the payload."""
|
|
56
|
+
return self.checksum == self.compute_checksum()
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"snapshot_id": self.snapshot_id,
|
|
61
|
+
"schema_version": self.schema_version,
|
|
62
|
+
"window_id": self.window_id,
|
|
63
|
+
"window_count": self.window_count,
|
|
64
|
+
"timestamp": self.timestamp,
|
|
65
|
+
"warm_store_data": self.warm_store_data,
|
|
66
|
+
"last_event_id": self.last_event_id,
|
|
67
|
+
"checksum": self.checksum,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: dict[str, Any]) -> EventLogSnapshot:
|
|
72
|
+
return cls(
|
|
73
|
+
snapshot_id=data.get("snapshot_id", ""),
|
|
74
|
+
schema_version=data.get("schema_version", SCHEMA_VERSION),
|
|
75
|
+
window_id=data.get("window_id", ""),
|
|
76
|
+
window_count=data.get("window_count", 0),
|
|
77
|
+
timestamp=data.get("timestamp", 0.0),
|
|
78
|
+
warm_store_data=data.get("warm_store_data", {}),
|
|
79
|
+
last_event_id=data.get("last_event_id", 0),
|
|
80
|
+
checksum=data.get("checksum", ""),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Snapshot manager
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SnapshotManager:
|
|
90
|
+
"""Manages periodic snapshots of warm store state (§3.3).
|
|
91
|
+
|
|
92
|
+
Usage:
|
|
93
|
+
mgr = SnapshotManager(warm_store, event_log)
|
|
94
|
+
mgr.maybe_snapshot(window_id) # called each window
|
|
95
|
+
# On resume:
|
|
96
|
+
mgr.restore_from_file(path)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
warm_store: WarmStateStore,
|
|
102
|
+
event_log: FactEventLog,
|
|
103
|
+
interval: int = SNAPSHOT_INTERVAL,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._store = warm_store
|
|
106
|
+
self._log = event_log
|
|
107
|
+
self._interval = interval
|
|
108
|
+
self._snapshots: list[EventLogSnapshot] = []
|
|
109
|
+
self._windows_since_snapshot: int = 0
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def snapshot_count(self) -> int:
|
|
113
|
+
return len(self._snapshots)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def last_snapshot(self) -> EventLogSnapshot | None:
|
|
117
|
+
return self._snapshots[-1] if self._snapshots else None
|
|
118
|
+
|
|
119
|
+
# --- Snapshot creation ----------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def snapshot(self, window_id: str) -> EventLogSnapshot:
|
|
122
|
+
"""Create a snapshot of the current warm store state."""
|
|
123
|
+
snap = EventLogSnapshot(
|
|
124
|
+
snapshot_id=f"snap-{len(self._snapshots) + 1}-{int(time.time())}",
|
|
125
|
+
window_id=window_id,
|
|
126
|
+
window_count=self._store.window_count,
|
|
127
|
+
warm_store_data=self._store.to_dict(),
|
|
128
|
+
last_event_id=self._log.size,
|
|
129
|
+
)
|
|
130
|
+
snap.checksum = snap.compute_checksum()
|
|
131
|
+
self._snapshots.append(snap)
|
|
132
|
+
self._windows_since_snapshot = 0
|
|
133
|
+
return snap
|
|
134
|
+
|
|
135
|
+
def maybe_snapshot(self, window_id: str) -> EventLogSnapshot | None:
|
|
136
|
+
"""Create snapshot if interval has elapsed."""
|
|
137
|
+
self._windows_since_snapshot += 1
|
|
138
|
+
if self._windows_since_snapshot >= self._interval:
|
|
139
|
+
return self.snapshot(window_id)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def truncate_before_snapshot(self) -> int:
|
|
143
|
+
"""Truncate event log before the last snapshot. Returns events removed."""
|
|
144
|
+
snap = self.last_snapshot
|
|
145
|
+
if snap is None:
|
|
146
|
+
return 0
|
|
147
|
+
removed = self._log.truncate_before(snap.last_event_id)
|
|
148
|
+
return len(removed)
|
|
149
|
+
|
|
150
|
+
# --- Restore --------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def restore_from_snapshot(self, snapshot: EventLogSnapshot) -> list[str]:
|
|
153
|
+
"""Restore warm store from *snapshot*, returning validation warnings.
|
|
154
|
+
|
|
155
|
+
Performs consistency checks (§22.4):
|
|
156
|
+
1. Schema version compatibility
|
|
157
|
+
2. Checksum integrity
|
|
158
|
+
3. Fact count consistency
|
|
159
|
+
4. Edge integrity (both endpoints exist)
|
|
160
|
+
5. No orphaned edges
|
|
161
|
+
6. Critical state present
|
|
162
|
+
7. Window count non-negative
|
|
163
|
+
8. Fact IDs unique
|
|
164
|
+
9. No circular supersession
|
|
165
|
+
10. Timestamps reasonable
|
|
166
|
+
"""
|
|
167
|
+
warnings: list[str] = []
|
|
168
|
+
|
|
169
|
+
# Check 1: Schema version
|
|
170
|
+
if snapshot.schema_version != SCHEMA_VERSION:
|
|
171
|
+
warnings.append(f"Schema version mismatch: {snapshot.schema_version} vs {SCHEMA_VERSION}")
|
|
172
|
+
|
|
173
|
+
# Check 2: Checksum
|
|
174
|
+
if not snapshot.verify_checksum():
|
|
175
|
+
warnings.append("Checksum verification FAILED — data may be corrupted")
|
|
176
|
+
|
|
177
|
+
# Load the data
|
|
178
|
+
data = snapshot.warm_store_data
|
|
179
|
+
self._store.load_from_dict(data)
|
|
180
|
+
|
|
181
|
+
# Check 3: Fact count
|
|
182
|
+
expected_count = len(data.get("facts", {}))
|
|
183
|
+
actual_count = self._store.fact_count
|
|
184
|
+
if expected_count != actual_count:
|
|
185
|
+
warnings.append(f"Fact count mismatch: expected {expected_count}, got {actual_count}")
|
|
186
|
+
|
|
187
|
+
# Check 4-5: Edge integrity
|
|
188
|
+
graph = self._store.graph
|
|
189
|
+
fact_ids = set(graph.nodes.keys())
|
|
190
|
+
for edge in graph.edges:
|
|
191
|
+
if edge.source_id not in fact_ids:
|
|
192
|
+
warnings.append(f"Orphaned edge source: {edge.source_id}")
|
|
193
|
+
if edge.target_id not in fact_ids:
|
|
194
|
+
warnings.append(f"Orphaned edge target: {edge.target_id}")
|
|
195
|
+
|
|
196
|
+
# Check 6: Critical state
|
|
197
|
+
cs = self._store.critical_state
|
|
198
|
+
if not cs.goal and not cs.phase:
|
|
199
|
+
warnings.append("Critical state has no goal or phase set")
|
|
200
|
+
|
|
201
|
+
# Check 7: Window count
|
|
202
|
+
if self._store.window_count < 0:
|
|
203
|
+
warnings.append(f"Invalid window count: {self._store.window_count}")
|
|
204
|
+
|
|
205
|
+
# Check 8: Unique fact IDs (guaranteed by dict, but verify)
|
|
206
|
+
all_facts = self._store.get_facts(include_superseded=True)
|
|
207
|
+
ids = [f.id for f in all_facts]
|
|
208
|
+
if len(ids) != len(set(ids)):
|
|
209
|
+
warnings.append("Duplicate fact IDs detected")
|
|
210
|
+
|
|
211
|
+
# Check 9: No circular supersession
|
|
212
|
+
for sf in all_facts:
|
|
213
|
+
if sf.is_superseded and sf.superseded_by == sf.id:
|
|
214
|
+
warnings.append(f"Circular supersession: {sf.id}")
|
|
215
|
+
|
|
216
|
+
# Check 10: Timestamps
|
|
217
|
+
now = time.time()
|
|
218
|
+
for sf in all_facts:
|
|
219
|
+
if sf.created_at > now + 3600:
|
|
220
|
+
warnings.append(f"Future timestamp on fact {sf.id}")
|
|
221
|
+
|
|
222
|
+
return warnings
|
|
223
|
+
|
|
224
|
+
# --- File I/O -------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def save_to_file(self, path: str | Path) -> None:
|
|
227
|
+
"""Save latest snapshot to disk using atomic write (§4G.4)."""
|
|
228
|
+
snap = self.last_snapshot
|
|
229
|
+
if snap is None:
|
|
230
|
+
snap = self.snapshot("auto-save")
|
|
231
|
+
p = Path(path)
|
|
232
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
# Atomic write: temp file then rename
|
|
234
|
+
tmp = p.with_suffix(".tmp")
|
|
235
|
+
try:
|
|
236
|
+
tmp.write_text(json.dumps(snap.to_dict(), default=str), encoding="utf-8")
|
|
237
|
+
os.replace(str(tmp), str(p))
|
|
238
|
+
except BaseException:
|
|
239
|
+
tmp.unlink(missing_ok=True)
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
def restore_from_file(self, path: str | Path) -> list[str]:
|
|
243
|
+
"""Load snapshot from disk, verify, restore. Returns warnings.
|
|
244
|
+
|
|
245
|
+
Handles partial-write corruption by checking for .tmp files (§4G.4).
|
|
246
|
+
After snapshot restore, replays event log entries since the snapshot
|
|
247
|
+
to rebuild full state (§4D.3).
|
|
248
|
+
"""
|
|
249
|
+
p = Path(path)
|
|
250
|
+
if not p.exists():
|
|
251
|
+
# Check for interrupted atomic write (§4G.4)
|
|
252
|
+
tmp = p.with_suffix(".tmp")
|
|
253
|
+
if tmp.exists():
|
|
254
|
+
try:
|
|
255
|
+
data = json.loads(tmp.read_text(encoding="utf-8"))
|
|
256
|
+
snapshot = EventLogSnapshot.from_dict(data)
|
|
257
|
+
warnings = self.restore_from_snapshot(snapshot)
|
|
258
|
+
warnings.insert(0, "Recovered from interrupted write (.tmp file)")
|
|
259
|
+
return warnings
|
|
260
|
+
except (json.JSONDecodeError, OSError):
|
|
261
|
+
return [f"Snapshot file not found and .tmp is corrupt: {path}"]
|
|
262
|
+
return [f"Snapshot file not found: {path}"]
|
|
263
|
+
try:
|
|
264
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
265
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
266
|
+
return [f"Failed to read snapshot: {exc}"]
|
|
267
|
+
|
|
268
|
+
snapshot = EventLogSnapshot.from_dict(data)
|
|
269
|
+
warnings = self.restore_from_snapshot(snapshot)
|
|
270
|
+
|
|
271
|
+
# Replay events since snapshot to rebuild full state (§4D.3)
|
|
272
|
+
replayed = self._replay_events_since(snapshot.last_event_id)
|
|
273
|
+
if replayed > 0:
|
|
274
|
+
warnings.append(f"Replayed {replayed} events since snapshot")
|
|
275
|
+
|
|
276
|
+
return warnings
|
|
277
|
+
|
|
278
|
+
def _replay_events_since(self, last_event_id: int) -> int:
|
|
279
|
+
"""Replay event log entries after a snapshot to rebuild state (§4D.3)."""
|
|
280
|
+
from .event_log import EVENT_SUPERSEDED
|
|
281
|
+
replayed = 0
|
|
282
|
+
for event in self._log.all_events():
|
|
283
|
+
if event.event_id <= last_event_id:
|
|
284
|
+
continue
|
|
285
|
+
if event.event_type == EVENT_SUPERSEDED:
|
|
286
|
+
new_id = event.payload.get("superseded_by", "")
|
|
287
|
+
conf = event.payload.get("confidence", 1.0)
|
|
288
|
+
self._store.supersede(event.fact_id, new_id, conf)
|
|
289
|
+
replayed += 1
|
|
290
|
+
return replayed
|