sidegraph 0.1.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.
@@ -0,0 +1,603 @@
1
+ """Confirmed bootstrap writes, recovery reconciliation, and aggregate reporting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Mapping
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from ulid import ULID
11
+
12
+ from sidegraph.bootstrap.catalog import (
13
+ CanonicalCatalog,
14
+ fingerprint_catalog,
15
+ load_canonical_catalog,
16
+ )
17
+ from sidegraph.bootstrap.model import (
18
+ AcceptedRecord,
19
+ BootstrapPlan,
20
+ BootstrapReport,
21
+ IntegrationResult,
22
+ ProofResult,
23
+ Reconciliation,
24
+ ReviewAction,
25
+ ReviewedCandidate,
26
+ ReviewResult,
27
+ RunStatus,
28
+ )
29
+ from sidegraph.doc_import import DocWriteRequest, ParsedDoc, apply_doc_candidate
30
+ from sidegraph.engine.reader import GraphifyReader
31
+ from sidegraph.profiles import FlowProfile, get_profile
32
+ from sidegraph.schema import Decision, DecisionStatus, DomainStatus
33
+ from sidegraph.store import Store
34
+ from sidegraph.verify import verify_snapshot
35
+
36
+ _CANONICAL_DIRS = (
37
+ "decisions",
38
+ "facts",
39
+ "domains",
40
+ "entities",
41
+ "bindings",
42
+ "initiatives",
43
+ "archive",
44
+ )
45
+ _CANONICAL_ROOT_FILES = ("format", ".gitignore")
46
+
47
+
48
+ def _error_text(error: BaseException) -> str:
49
+ return str(error) or type(error).__name__
50
+
51
+
52
+ def canonical_manifest(store_dir: Path) -> dict[str, str]:
53
+ """Hash committed store files, excluding derived and temporary state."""
54
+ store_dir = Path(store_dir)
55
+ if not store_dir.is_dir():
56
+ return {}
57
+
58
+ paths: list[Path] = []
59
+ for name in _CANONICAL_ROOT_FILES:
60
+ path = store_dir / name
61
+ if path.is_file() and not path.is_symlink():
62
+ paths.append(path)
63
+ for name in _CANONICAL_DIRS:
64
+ root = store_dir / name
65
+ if not root.is_dir():
66
+ continue
67
+ paths.extend(
68
+ path
69
+ for path in root.rglob("*")
70
+ if path.is_file() and not path.is_symlink() and not path.name.endswith(".tmp")
71
+ )
72
+
73
+ store_label = store_dir.name
74
+ return {
75
+ f"{store_label}/{path.relative_to(store_dir).as_posix()}": hashlib.sha256(
76
+ path.read_bytes()
77
+ ).hexdigest()
78
+ for path in sorted(paths)
79
+ }
80
+
81
+
82
+ def validate_plan_inputs(
83
+ plan: BootstrapPlan,
84
+ store_dir: Path,
85
+ reader: GraphifyReader,
86
+ ) -> tuple[str, ...]:
87
+ """Validate all preview inputs without constructing persistent state."""
88
+ failures: list[str] = []
89
+ root = Path(plan.root)
90
+
91
+ for source in plan.source_fingerprints:
92
+ path = root / source.path
93
+ try:
94
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
95
+ except OSError as error:
96
+ failures.append(f"source unavailable: {source.path}: {_error_text(error)}")
97
+ continue
98
+ if digest != source.sha256:
99
+ failures.append(f"source changed after preview: {source.path}")
100
+
101
+ try:
102
+ current_catalog = fingerprint_catalog(load_canonical_catalog(Path(store_dir)))
103
+ except (OSError, ValueError) as error:
104
+ failures.append(f"canonical catalog unavailable: {_error_text(error)}")
105
+ else:
106
+ if current_catalog != plan.catalog_fingerprint:
107
+ failures.append("canonical catalog changed after preview")
108
+
109
+ try:
110
+ current_graph = GraphifyReader(reader.path).graph_version()
111
+ except (OSError, UnicodeError, ValueError) as error:
112
+ failures.append(f"graph unavailable: {reader.path}: {_error_text(error)}")
113
+ else:
114
+ if current_graph != plan.graph_version:
115
+ failures.append("graph changed after preview")
116
+
117
+ return tuple(failures)
118
+
119
+
120
+ def build_doc_request(
121
+ item: ReviewedCandidate,
122
+ graph_version: str | None,
123
+ profile: FlowProfile,
124
+ ) -> DocWriteRequest:
125
+ """Convert only explicitly write-approved, already-redacted review state.
126
+
127
+ ``rel_path`` (E2, design note §6) is derived from ``profile.normalized_rel_path`` of
128
+ the candidate's ON-DISK ``file_path`` — never the on-disk path itself — so the
129
+ resulting ``request.ref`` equals ``candidate.ref`` (also normalized, at plan time) by
130
+ construction: both derive from the same transform applied to the same input.
131
+ """
132
+ if item.action == ReviewAction.SKIP:
133
+ raise ValueError(f"skipped candidate cannot be converted: {item.candidate.ref}")
134
+
135
+ candidate = item.candidate
136
+ status = (
137
+ DecisionStatus.ACCEPTED if item.action == ReviewAction.ACCEPT else DecisionStatus.PROPOSED
138
+ )
139
+ return DocWriteRequest(
140
+ parsed=ParsedDoc(
141
+ title=candidate.title,
142
+ context=candidate.context,
143
+ choice=candidate.choice,
144
+ rejected=candidate.rejected,
145
+ consequences=candidate.consequences,
146
+ frontmatter_status=None,
147
+ suggested_kind=candidate.kind,
148
+ fragment=candidate.fragment,
149
+ ),
150
+ rel_path=profile.normalized_rel_path(candidate.file_path),
151
+ source_hash=candidate.source_hash,
152
+ status=status,
153
+ anchors=candidate.anchor_intents,
154
+ file_anchor=candidate.file_anchor_intent,
155
+ graph_version=graph_version,
156
+ # The human chose `accept` in the review loop; this is the only place in the codebase
157
+ # that grants the ratification authority (spec §3.1).
158
+ ratify_matching_proposal=item.action == ReviewAction.ACCEPT,
159
+ )
160
+
161
+
162
+ def _matching_reviewed_decision(
163
+ item: ReviewedCandidate, catalog: CanonicalCatalog
164
+ ) -> Decision | None:
165
+ candidate = item.candidate
166
+ expected_status = (
167
+ DecisionStatus.ACCEPTED if item.action == ReviewAction.ACCEPT else DecisionStatus.PROPOSED
168
+ )
169
+ expected_content = (
170
+ candidate.title,
171
+ candidate.context,
172
+ candidate.choice,
173
+ candidate.rejected,
174
+ candidate.consequences,
175
+ )
176
+ return next(
177
+ (
178
+ decision
179
+ for decision in catalog.decisions
180
+ if decision.provenance.source == "doc-import"
181
+ and decision.provenance.ref == candidate.ref
182
+ and decision.status == expected_status
183
+ and (
184
+ decision.title,
185
+ decision.context,
186
+ decision.choice,
187
+ decision.rejected,
188
+ decision.consequences,
189
+ )
190
+ == expected_content
191
+ ),
192
+ None,
193
+ )
194
+
195
+
196
+ def reconcile_plan(
197
+ plan: BootstrapPlan,
198
+ review: ReviewResult,
199
+ store_dir: Path,
200
+ before: Mapping[str, str],
201
+ ) -> Reconciliation:
202
+ """Compare reviewed writes with canonical truth after reopen/rebuild."""
203
+ del plan # Review items carry the possibly edited candidates that are authoritative here.
204
+ catalog = load_canonical_catalog(Path(store_dir))
205
+ durable: list[str] = []
206
+ pending: list[str] = []
207
+ accepted_records: list[AcceptedRecord] = []
208
+ for item in review.items:
209
+ if item.action == ReviewAction.SKIP:
210
+ continue
211
+ match = _matching_reviewed_decision(item, catalog)
212
+ target = durable if match is not None else pending
213
+ target.append(item.candidate.key)
214
+ if match is not None and item.action == ReviewAction.ACCEPT:
215
+ accepted_records.append(AcceptedRecord(record_id=match.id, ref=item.candidate.ref))
216
+
217
+ after = canonical_manifest(Path(store_dir))
218
+ changed = tuple(
219
+ sorted(path for path in set(before) | set(after) if before.get(path) != after.get(path))
220
+ )
221
+ return Reconciliation(
222
+ durable=tuple(durable),
223
+ pending=tuple(pending),
224
+ canonical_files=changed,
225
+ durable_accepted_records=tuple(accepted_records),
226
+ )
227
+
228
+
229
+ def proposal_debt(catalog: CanonicalCatalog) -> tuple[int, int | None]:
230
+ """Return proposed-record count and the age of the oldest parseable ULID."""
231
+ proposed_ids = [
232
+ decision.id for decision in catalog.decisions if decision.status == DecisionStatus.PROPOSED
233
+ ]
234
+ proposed_ids.extend(fact.id for fact in catalog.facts if fact.status == DecisionStatus.PROPOSED)
235
+ proposed_ids.extend(
236
+ domain.domain_id for domain in catalog.domains if domain.status == DomainStatus.PROPOSED
237
+ )
238
+
239
+ created: list[datetime] = []
240
+ for record_id in proposed_ids:
241
+ try:
242
+ created.append(ULID.from_str(record_id).datetime)
243
+ except (TypeError, ValueError):
244
+ continue
245
+ if not created:
246
+ return len(proposed_ids), None
247
+ age = datetime.now(UTC) - min(created)
248
+ return len(proposed_ids), max(0, age.days)
249
+
250
+
251
+ def _counts(plan: BootstrapPlan, review: ReviewResult) -> dict[str, int | float]:
252
+ accepted_without_edit = sum(
253
+ item.action == ReviewAction.ACCEPT and not item.edited for item in review.items
254
+ )
255
+ edited_then_accepted = sum(
256
+ item.action == ReviewAction.ACCEPT and item.edited for item in review.items
257
+ )
258
+ kept_proposed_without_edit = sum(
259
+ item.action == ReviewAction.KEEP_PROPOSED and not item.edited for item in review.items
260
+ )
261
+ edited_then_kept_proposed = sum(
262
+ item.action == ReviewAction.KEEP_PROPOSED and item.edited for item in review.items
263
+ )
264
+ return {
265
+ "documents_scanned": len(plan.files_read),
266
+ "candidates": len(plan.candidates),
267
+ "accepted": sum(item.action == ReviewAction.ACCEPT for item in review.items),
268
+ "kept_proposed": sum(item.action == ReviewAction.KEEP_PROPOSED for item in review.items),
269
+ "skipped": sum(item.action == ReviewAction.SKIP for item in review.items),
270
+ "reviewed_candidates": len(review.items),
271
+ "accepted_without_edit": accepted_without_edit,
272
+ "edited_then_accepted": edited_then_accepted,
273
+ "kept_proposed_without_edit": kept_proposed_without_edit,
274
+ "edited_then_kept_proposed": edited_then_kept_proposed,
275
+ "candidate_precision_numerator": sum(
276
+ item.action == ReviewAction.ACCEPT or item.edited for item in review.items
277
+ ),
278
+ "review_elapsed_seconds": review.elapsed_seconds,
279
+ "accept_action_seconds": sum(
280
+ item.action_elapsed_seconds
281
+ for item in review.items
282
+ if item.action == ReviewAction.ACCEPT and not item.edited
283
+ ),
284
+ "edit_accept_action_seconds": sum(
285
+ item.action_elapsed_seconds
286
+ for item in review.items
287
+ if item.action == ReviewAction.ACCEPT and item.edited
288
+ ),
289
+ "keep_proposed_action_seconds": sum(
290
+ item.action_elapsed_seconds
291
+ for item in review.items
292
+ if item.action == ReviewAction.KEEP_PROPOSED and not item.edited
293
+ ),
294
+ "edit_keep_proposed_action_seconds": sum(
295
+ item.action_elapsed_seconds
296
+ for item in review.items
297
+ if item.action == ReviewAction.KEEP_PROPOSED and item.edited
298
+ ),
299
+ "skip_action_seconds": sum(
300
+ item.action_elapsed_seconds for item in review.items if item.action == ReviewAction.SKIP
301
+ ),
302
+ "live_anchors": sum(
303
+ any(anchor.status == "resolved" and anchor.tier == 2 for anchor in candidate.anchors)
304
+ for candidate in plan.candidates
305
+ ),
306
+ }
307
+
308
+
309
+ def _pending_keys(review: ReviewResult) -> tuple[str, ...]:
310
+ return tuple(item.candidate.key for item in review.items if item.action != ReviewAction.SKIP)
311
+
312
+
313
+ def _incomplete_report(
314
+ plan: BootstrapPlan,
315
+ review: ReviewResult,
316
+ store_dir: Path,
317
+ *,
318
+ error: str,
319
+ before: Mapping[str, str] | None = None,
320
+ ) -> BootstrapReport:
321
+ try:
322
+ debt_count, oldest_days = proposal_debt(load_canonical_catalog(store_dir))
323
+ except (OSError, ValueError):
324
+ debt_count, oldest_days = 0, None
325
+ canonical_files: tuple[str, ...] = ()
326
+ if before is not None:
327
+ try:
328
+ after = canonical_manifest(store_dir)
329
+ canonical_files = tuple(
330
+ sorted(
331
+ path for path in set(before) | set(after) if before.get(path) != after.get(path)
332
+ )
333
+ )
334
+ except OSError:
335
+ pass
336
+ return BootstrapReport(
337
+ status=RunStatus.INCOMPLETE,
338
+ **_counts(plan, review),
339
+ review_debt_count=debt_count,
340
+ oldest_proposal_days=oldest_days,
341
+ pending_candidate_keys=_pending_keys(review),
342
+ canonical_files=canonical_files,
343
+ error=error,
344
+ next_command="sidegraph-bootstrap --resume",
345
+ )
346
+
347
+
348
+ def _diagnostic_report(
349
+ plan: BootstrapPlan,
350
+ review: ReviewResult,
351
+ store_dir: Path,
352
+ ) -> BootstrapReport:
353
+ try:
354
+ debt_count, oldest_days = proposal_debt(load_canonical_catalog(store_dir))
355
+ except (OSError, ValueError):
356
+ debt_count, oldest_days = 0, None
357
+ return BootstrapReport(
358
+ status=RunStatus.DIAGNOSTIC,
359
+ **_counts(plan, review),
360
+ review_debt_count=debt_count,
361
+ oldest_proposal_days=oldest_days,
362
+ )
363
+
364
+
365
+ def _finalize(
366
+ plan: BootstrapPlan,
367
+ review: ReviewResult,
368
+ store_dir: Path,
369
+ before: Mapping[str, str],
370
+ error: str | None,
371
+ failed_ref: str | None,
372
+ ) -> BootstrapReport:
373
+ reopen_error: str | None = None
374
+ try:
375
+ healed = Store(store_dir)
376
+ healed.close()
377
+ except BaseException as exc:
378
+ reopen_error = _error_text(exc)
379
+
380
+ verification_errors: tuple[str, ...] = ()
381
+ if reopen_error is None:
382
+ try:
383
+ violations = tuple(verify_snapshot(store_dir))
384
+ verification_errors = tuple(
385
+ f"{violation.code} {violation.path} {violation.detail}" for violation in violations
386
+ )
387
+ except BaseException as exc:
388
+ verification_errors = (f"verification failed: {_error_text(exc)}",)
389
+
390
+ reconciliation_error: str | None = None
391
+ try:
392
+ reconciliation = reconcile_plan(plan, review, store_dir, before)
393
+ except (OSError, ValueError) as exc:
394
+ reconciliation_error = _error_text(exc)
395
+ try:
396
+ after = canonical_manifest(store_dir)
397
+ changed = tuple(
398
+ sorted(
399
+ path for path in set(before) | set(after) if before.get(path) != after.get(path)
400
+ )
401
+ )
402
+ except OSError:
403
+ changed = ()
404
+ reconciliation = Reconciliation(pending=_pending_keys(review), canonical_files=changed)
405
+
406
+ complete = (
407
+ not error
408
+ and not reopen_error
409
+ and not verification_errors
410
+ and not reconciliation_error
411
+ and not reconciliation.pending
412
+ )
413
+ status = (
414
+ RunStatus.COMPLETE
415
+ if complete
416
+ else RunStatus.PARTIAL_RECOVERABLE
417
+ if reconciliation.durable
418
+ else RunStatus.INCOMPLETE
419
+ )
420
+ try:
421
+ debt_count, oldest_days = proposal_debt(load_canonical_catalog(store_dir))
422
+ except (OSError, ValueError):
423
+ debt_count, oldest_days = 0, None
424
+ report_error = error or reopen_error or reconciliation_error
425
+ if report_error is None and verification_errors:
426
+ report_error = verification_errors[0]
427
+ return BootstrapReport(
428
+ status=status,
429
+ **_counts(plan, review),
430
+ review_debt_count=debt_count,
431
+ oldest_proposal_days=oldest_days,
432
+ durable_candidate_keys=reconciliation.durable,
433
+ pending_candidate_keys=reconciliation.pending,
434
+ durable_accepted_records=reconciliation.durable_accepted_records,
435
+ canonical_files=reconciliation.canonical_files,
436
+ verification_failures=verification_errors,
437
+ failed_ref=failed_ref,
438
+ error=report_error,
439
+ next_command=(
440
+ None
441
+ if complete
442
+ else f"sidegraph-verify --db {store_dir}"
443
+ if verification_errors
444
+ else "sidegraph-bootstrap --resume"
445
+ ),
446
+ )
447
+
448
+
449
+ def apply_review(
450
+ plan: BootstrapPlan,
451
+ review: ReviewResult,
452
+ *,
453
+ store_dir: Path,
454
+ reader: GraphifyReader,
455
+ ) -> BootstrapReport:
456
+ """Apply confirmed review items and reconcile any durable partial writes."""
457
+ store_dir = Path(store_dir)
458
+ failures = validate_plan_inputs(plan, store_dir, reader)
459
+ if failures:
460
+ return _incomplete_report(
461
+ plan,
462
+ review,
463
+ store_dir,
464
+ error="; ".join(failures),
465
+ )
466
+ if not review.has_writes:
467
+ return _diagnostic_report(plan, review, store_dir)
468
+
469
+ try:
470
+ before = canonical_manifest(store_dir)
471
+ except OSError as exc:
472
+ return _incomplete_report(plan, review, store_dir, error=_error_text(exc))
473
+
474
+ try:
475
+ store = Store(store_dir)
476
+ except BaseException as exc:
477
+ return _incomplete_report(
478
+ plan,
479
+ review,
480
+ store_dir,
481
+ error=_error_text(exc),
482
+ before=before,
483
+ )
484
+
485
+ error: str | None = None
486
+ failed_ref: str | None = None
487
+ profile = get_profile(plan.profile)
488
+ try:
489
+ for item in review.items:
490
+ if item.action == ReviewAction.SKIP:
491
+ continue
492
+ request = build_doc_request(item, plan.graph_version, profile)
493
+ if request.ref != item.candidate.ref:
494
+ raise AssertionError(
495
+ f"reviewed candidate ref changed during conversion: {item.candidate.ref}"
496
+ )
497
+ try:
498
+ apply_doc_candidate(store, reader, request)
499
+ except BaseException as exc:
500
+ error = _error_text(exc)
501
+ failed_ref = item.candidate.ref
502
+ break
503
+ finally:
504
+ try:
505
+ store.close()
506
+ except BaseException as exc:
507
+ if error is None:
508
+ error = _error_text(exc)
509
+
510
+ return _finalize(plan, review, store_dir, before, error, failed_ref)
511
+
512
+
513
+ def render_markdown_report(
514
+ report: BootstrapReport,
515
+ *,
516
+ integration: IntegrationResult | None = None,
517
+ proof: ProofResult | None = None,
518
+ task_proof: ProofResult | None = None,
519
+ elapsed_seconds: float | None = None,
520
+ ) -> str:
521
+ """Render operational aggregates without candidate/source/error content."""
522
+ reviewed = report.reviewed_candidates
523
+
524
+ def rate(count: int) -> str:
525
+ if reviewed == 0:
526
+ return "not applicable (0 reviewed)"
527
+ return f"{count}/{reviewed} ({count / reviewed:.1%})"
528
+
529
+ lines = [
530
+ "# Sidegraph bootstrap report",
531
+ "",
532
+ f"- status: {report.status.value}",
533
+ f"- documents scanned: {report.documents_scanned}",
534
+ f"- candidates: {report.candidates}",
535
+ f"- accepted: {report.accepted}",
536
+ f"- kept proposed: {report.kept_proposed}",
537
+ f"- skipped: {report.skipped}",
538
+ f"- reviewed candidates: {reviewed}",
539
+ f"- candidate precision: {rate(report.candidate_precision_numerator)}",
540
+ f"- accept: {rate(report.accepted_without_edit)}; "
541
+ f"action seconds: {report.accept_action_seconds:.3f}",
542
+ f"- edit then accept: {rate(report.edited_then_accepted)}; "
543
+ f"action seconds: {report.edit_accept_action_seconds:.3f}",
544
+ f"- keep proposed: {rate(report.kept_proposed_without_edit)}; "
545
+ f"action seconds: {report.keep_proposed_action_seconds:.3f}",
546
+ f"- edit then keep proposed: {rate(report.edited_then_kept_proposed)}; "
547
+ f"action seconds: {report.edit_keep_proposed_action_seconds:.3f}",
548
+ f"- skip: {rate(report.skipped)}; action seconds: {report.skip_action_seconds:.3f}",
549
+ f"- review elapsed seconds: {report.review_elapsed_seconds:.3f}",
550
+ f"- live anchors: {report.live_anchors}",
551
+ f"- review debt (proposed): {report.review_debt_count}",
552
+ f"- oldest proposal days: {report.oldest_proposal_days}",
553
+ f"- durable candidates: {len(report.durable_candidate_keys)}",
554
+ f"- pending candidates: {len(report.pending_candidate_keys)}",
555
+ f"- verification failures: {len(report.verification_failures)}",
556
+ ]
557
+ if elapsed_seconds is not None:
558
+ lines.append(f"- elapsed seconds: {elapsed_seconds:.3f}")
559
+ if report.next_command is not None:
560
+ lines.append(f"- next command: `{report.next_command}`")
561
+
562
+ if report.canonical_files:
563
+ lines.extend(("", "## Changed canonical files", ""))
564
+ lines.extend(f"- `{path}`" for path in report.canonical_files)
565
+
566
+ if integration is not None:
567
+ lines.extend(
568
+ (
569
+ "",
570
+ "## Integration",
571
+ "",
572
+ f"- host: {integration.host.value}",
573
+ f"- fully supported: {str(integration.fully_supported).lower()}",
574
+ f"- mcp: {integration.mcp}",
575
+ f"- session start: {integration.session_start}",
576
+ f"- stop: {integration.stop}",
577
+ f"- pretool read/grep: {integration.pretool_read_grep}",
578
+ )
579
+ )
580
+ if integration.next_action is not None:
581
+ lines.append(f"- next action: {integration.next_action}")
582
+
583
+ if proof is not None:
584
+ lines.extend(
585
+ (
586
+ "",
587
+ "## Proof",
588
+ "",
589
+ f"- complete: {str(proof.complete).lower()}",
590
+ )
591
+ )
592
+
593
+ if task_proof is not None:
594
+ lines.extend(
595
+ (
596
+ "",
597
+ "## Optional task proof",
598
+ "",
599
+ f"- complete: {str(task_proof.complete).lower()}",
600
+ )
601
+ )
602
+
603
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,92 @@
1
+ """Pure, immutable snapshot of canonical Sidegraph record files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from sidegraph.bootstrap.model import FrozenModel
12
+ from sidegraph.schema import Decision, DecisionStatus, Domain, Fact
13
+
14
+
15
+ class CanonicalCatalog(FrozenModel):
16
+ decisions: tuple[Decision, ...] = ()
17
+ facts: tuple[Fact, ...] = ()
18
+ domains: tuple[Domain, ...] = ()
19
+
20
+ def find_by_ref(
21
+ self,
22
+ source: str,
23
+ ref: str,
24
+ statuses: tuple[DecisionStatus, ...],
25
+ ) -> tuple[Decision, ...]:
26
+ return tuple(
27
+ decision
28
+ for decision in self.decisions
29
+ if decision.provenance.source == source
30
+ and decision.provenance.ref == ref
31
+ and decision.status in statuses
32
+ )
33
+
34
+
35
+ def _load_model[ModelT: BaseModel](path: Path, model: type[ModelT]) -> ModelT:
36
+ try:
37
+ return model.model_validate_json(path.read_text(encoding="utf-8"))
38
+ except (OSError, UnicodeError, ValidationError) as exc:
39
+ raise ValueError(f"invalid canonical file {path}: {exc}") from exc
40
+
41
+
42
+ def load_canonical_catalog(store_dir: Path) -> CanonicalCatalog:
43
+ """Read canonical files directly, without constructing ``Store`` or derived state."""
44
+ if not store_dir.is_dir():
45
+ return CanonicalCatalog()
46
+
47
+ decisions: dict[str, Decision] = {}
48
+ domains: dict[str, Domain] = {}
49
+ for segment in sorted((store_dir / "archive").glob("*.jsonl")):
50
+ try:
51
+ lines = segment.read_text(encoding="utf-8").splitlines()
52
+ except (OSError, UnicodeError) as exc:
53
+ raise ValueError(f"invalid canonical archive {segment}: {exc}") from exc
54
+ for lineno, line in enumerate(lines, 1):
55
+ if not line.strip():
56
+ continue
57
+ try:
58
+ raw = json.loads(line)
59
+ if not isinstance(raw, dict):
60
+ raise TypeError("archive record must be a JSON object")
61
+ payload = {key: value for key, value in raw.items() if key != "record_type"}
62
+ if raw.get("record_type") == "decision":
63
+ decision = Decision.model_validate(payload)
64
+ decisions.setdefault(decision.id, decision)
65
+ elif raw.get("record_type") == "domain":
66
+ domain = Domain.model_validate(payload)
67
+ domains.setdefault(domain.domain_id, domain)
68
+ except (json.JSONDecodeError, TypeError, ValidationError) as exc:
69
+ raise ValueError(f"invalid canonical archive {segment}:{lineno}: {exc}") from exc
70
+
71
+ for path in sorted((store_dir / "decisions").glob("*.json")):
72
+ decision = _load_model(path, Decision)
73
+ decisions[decision.id] = decision
74
+ facts = tuple(_load_model(path, Fact) for path in sorted((store_dir / "facts").glob("*.json")))
75
+ for path in sorted((store_dir / "domains").glob("*.json")):
76
+ domain = _load_model(path, Domain)
77
+ domains[domain.domain_id] = domain
78
+
79
+ return CanonicalCatalog(
80
+ decisions=tuple(decisions[key] for key in sorted(decisions)),
81
+ facts=facts,
82
+ domains=tuple(domains[key] for key in sorted(domains)),
83
+ )
84
+
85
+
86
+ def fingerprint_catalog(catalog: CanonicalCatalog) -> str:
87
+ material = json.dumps(
88
+ catalog.model_dump(mode="json"),
89
+ sort_keys=True,
90
+ separators=(",", ":"),
91
+ )
92
+ return hashlib.sha256(material.encode()).hexdigest()