ccrt 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.
- ccrt/__init__.py +37 -0
- ccrt/__main__.py +3 -0
- ccrt/_version.py +1 -0
- ccrt/cli.py +205 -0
- ccrt/client.py +166 -0
- ccrt/config.py +23 -0
- ccrt/evaluation.py +30 -0
- ccrt/export.py +152 -0
- ccrt/interactive.py +160 -0
- ccrt/loaders/__init__.py +5 -0
- ccrt/loaders/acled.py +69 -0
- ccrt/loaders/base.py +144 -0
- ccrt/loaders/ucdp.py +75 -0
- ccrt/merge.py +186 -0
- ccrt/report.py +170 -0
- ccrt/review.py +56 -0
- ccrt/schema.py +162 -0
- ccrt/storage.py +168 -0
- ccrt/tui.py +349 -0
- ccrt-0.1.0.dist-info/METADATA +118 -0
- ccrt-0.1.0.dist-info/RECORD +25 -0
- ccrt-0.1.0.dist-info/WHEEL +5 -0
- ccrt-0.1.0.dist-info/entry_points.txt +2 -0
- ccrt-0.1.0.dist-info/licenses/LICENSE +21 -0
- ccrt-0.1.0.dist-info/top_level.txt +1 -0
ccrt/report.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Descriptive review diagnostics for partially or fully reviewed candidates."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
|
|
6
|
+
from ccrt._version import __version__
|
|
7
|
+
from ccrt.review import Review
|
|
8
|
+
from ccrt.schema import digest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MultipleAcceptedLinks:
|
|
13
|
+
source_id: str
|
|
14
|
+
counterpart_ids: tuple[str, ...]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class FatalityDifference:
|
|
19
|
+
left_id: str
|
|
20
|
+
right_id: str
|
|
21
|
+
left_deaths: int
|
|
22
|
+
right_deaths: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ReviewReport:
|
|
27
|
+
run_id: str
|
|
28
|
+
review_fingerprint: str
|
|
29
|
+
candidate_count: int
|
|
30
|
+
reviewed_count: int
|
|
31
|
+
accepted_count: int
|
|
32
|
+
rejected_count: int
|
|
33
|
+
unresolved_count: int
|
|
34
|
+
reviewed_fraction: float | None
|
|
35
|
+
reviewed_without_notes: int
|
|
36
|
+
reviewed_score_min: float | None
|
|
37
|
+
reviewed_score_max: float | None
|
|
38
|
+
left_source: str
|
|
39
|
+
right_source: str
|
|
40
|
+
left_record_count: int
|
|
41
|
+
right_record_count: int
|
|
42
|
+
left_with_candidates: int
|
|
43
|
+
right_with_candidates: int
|
|
44
|
+
left_unlinked_reasons: dict[str, int]
|
|
45
|
+
right_unlinked_reasons: dict[str, int]
|
|
46
|
+
accepted_left_multiple: tuple[MultipleAcceptedLinks, ...]
|
|
47
|
+
accepted_right_multiple: tuple[MultipleAcceptedLinks, ...]
|
|
48
|
+
accepted_fatality_differences: tuple[FatalityDifference, ...]
|
|
49
|
+
accepted_missing_fatalities: tuple[tuple[str, str], ...]
|
|
50
|
+
|
|
51
|
+
def to_dict(self):
|
|
52
|
+
"""JSON-ready diagnostics, with no raw accounts or casualty aggregation."""
|
|
53
|
+
return {
|
|
54
|
+
"kind": "review-report",
|
|
55
|
+
"schema_version": 1,
|
|
56
|
+
"ccrt_version": __version__,
|
|
57
|
+
"scope": "generated candidates only; coverage is not matching accuracy",
|
|
58
|
+
**asdict(self),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def summarize_review(result, review=None):
|
|
63
|
+
"""Summarize decisions without treating unresolved pairs as negative labels.
|
|
64
|
+
|
|
65
|
+
Multiple accepted counterparts and differing fatality claims are descriptive
|
|
66
|
+
checks, not proof of an incorrect link. Missed links outside the candidate
|
|
67
|
+
set are unknown, so this function does not calculate precision or recall.
|
|
68
|
+
"""
|
|
69
|
+
review = review if review is not None else Review.create(result)
|
|
70
|
+
review.validate(result)
|
|
71
|
+
decisions = {decision.candidate_id: decision for decision in review.decisions}
|
|
72
|
+
counts = Counter(decision.status for decision in review.decisions)
|
|
73
|
+
reviewed = [c for c in result.candidates if decisions[c.candidate_id].status != "unresolved"]
|
|
74
|
+
accepted = [c for c in result.candidates if decisions[c.candidate_id].status == "accepted"]
|
|
75
|
+
left_links, right_links = defaultdict(set), defaultdict(set)
|
|
76
|
+
differences, missing = [], []
|
|
77
|
+
for candidate in accepted:
|
|
78
|
+
left, right = candidate.left, candidate.right
|
|
79
|
+
left_links[left.source_id].add(right.source_id)
|
|
80
|
+
right_links[right.source_id].add(left.source_id)
|
|
81
|
+
a, b = left.total_deaths_best, right.total_deaths_best
|
|
82
|
+
if a is None or b is None:
|
|
83
|
+
missing.append((left.source_id, right.source_id))
|
|
84
|
+
elif a != b:
|
|
85
|
+
differences.append(FatalityDifference(left.source_id, right.source_id, a, b))
|
|
86
|
+
|
|
87
|
+
def multiple(groups):
|
|
88
|
+
return tuple(
|
|
89
|
+
MultipleAcceptedLinks(source_id, tuple(sorted(counterparts)))
|
|
90
|
+
for source_id, counterparts in sorted(groups.items())
|
|
91
|
+
if len(counterparts) > 1
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def unlinked_reasons(source):
|
|
95
|
+
return dict(sorted(Counter(u.reason for u in result.unlinked if u.record.source == source).items()))
|
|
96
|
+
|
|
97
|
+
scores = [c.score for c in reviewed]
|
|
98
|
+
return ReviewReport(
|
|
99
|
+
run_id=result.run_id,
|
|
100
|
+
review_fingerprint=digest(
|
|
101
|
+
{
|
|
102
|
+
"run_id": review.run_id,
|
|
103
|
+
"decisions": sorted((asdict(d) for d in review.decisions), key=lambda d: d["candidate_id"]),
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
candidate_count=len(result.candidates),
|
|
107
|
+
reviewed_count=len(reviewed),
|
|
108
|
+
accepted_count=counts["accepted"],
|
|
109
|
+
rejected_count=counts["rejected"],
|
|
110
|
+
unresolved_count=counts["unresolved"],
|
|
111
|
+
reviewed_fraction=len(reviewed) / len(result.candidates) if result.candidates else None,
|
|
112
|
+
reviewed_without_notes=sum(d.status != "unresolved" and not d.note.strip() for d in review.decisions),
|
|
113
|
+
reviewed_score_min=min(scores) if scores else None,
|
|
114
|
+
reviewed_score_max=max(scores) if scores else None,
|
|
115
|
+
left_source=result.left.source,
|
|
116
|
+
right_source=result.right.source,
|
|
117
|
+
left_record_count=len(result.left),
|
|
118
|
+
right_record_count=len(result.right),
|
|
119
|
+
left_with_candidates=len({c.left.key for c in result.candidates}),
|
|
120
|
+
right_with_candidates=len({c.right.key for c in result.candidates}),
|
|
121
|
+
left_unlinked_reasons=unlinked_reasons(result.left.source),
|
|
122
|
+
right_unlinked_reasons=unlinked_reasons(result.right.source),
|
|
123
|
+
accepted_left_multiple=multiple(left_links),
|
|
124
|
+
accepted_right_multiple=multiple(right_links),
|
|
125
|
+
accepted_fatality_differences=tuple(sorted(differences, key=lambda d: (d.left_id, d.right_id))),
|
|
126
|
+
accepted_missing_fatalities=tuple(sorted(missing)),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def format_review_report(report):
|
|
131
|
+
"""Compact CLI summary; the JSON report retains all diagnostic IDs."""
|
|
132
|
+
fraction = f" ({report.reviewed_fraction:.1%})" if report.reviewed_fraction is not None else ""
|
|
133
|
+
lines = [
|
|
134
|
+
f"Reviewed {report.reviewed_count}/{report.candidate_count} candidate links{fraction}",
|
|
135
|
+
f"{report.accepted_count} accepted, {report.rejected_count} rejected, {report.unresolved_count} unresolved",
|
|
136
|
+
f"{report.reviewed_without_notes} decided links have no review note",
|
|
137
|
+
"",
|
|
138
|
+
"Source records with candidates:",
|
|
139
|
+
f" {report.left_source}: {report.left_with_candidates}/{report.left_record_count}",
|
|
140
|
+
f" {report.right_source}: {report.right_with_candidates}/{report.right_record_count}",
|
|
141
|
+
]
|
|
142
|
+
for source, reasons in (
|
|
143
|
+
(report.left_source, report.left_unlinked_reasons),
|
|
144
|
+
(report.right_source, report.right_unlinked_reasons),
|
|
145
|
+
):
|
|
146
|
+
if reasons:
|
|
147
|
+
lines.append(f" {source} unlinked: " + ", ".join(f"{k}={v}" for k, v in reasons.items()))
|
|
148
|
+
lines.extend(["", "Accepted links with multiple counterparts (allowed):"])
|
|
149
|
+
for source, groups in (
|
|
150
|
+
(report.left_source, report.accepted_left_multiple),
|
|
151
|
+
(report.right_source, report.accepted_right_multiple),
|
|
152
|
+
):
|
|
153
|
+
lines.append(f" {source}: {len(groups)} records")
|
|
154
|
+
for group in groups[:5]:
|
|
155
|
+
lines.append(f" {group.source_id}: {', '.join(group.counterpart_ids)}")
|
|
156
|
+
if len(groups) > 5:
|
|
157
|
+
lines.append(f" {len(groups) - 5} more; see JSON report")
|
|
158
|
+
lines.extend(
|
|
159
|
+
[
|
|
160
|
+
"",
|
|
161
|
+
f"Different reported fatality counts: {len(report.accepted_fatality_differences)} accepted links",
|
|
162
|
+
f"Missing fatality counts: {len(report.accepted_missing_fatalities)} accepted links",
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
if report.reviewed_score_min is not None:
|
|
166
|
+
lines.append(
|
|
167
|
+
f"Reviewed score range: {report.reviewed_score_min:.3f} to {report.reviewed_score_max:.3f}"
|
|
168
|
+
)
|
|
169
|
+
lines.append("\nCoverage describes generated candidates. Matching accuracy needs exhaustive labels.")
|
|
170
|
+
return "\n".join(lines)
|
ccrt/review.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Explicit review decisions bound to a reproducible candidate run."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from ccrt.merge import Reconciliation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Decision:
|
|
11
|
+
candidate_id: str
|
|
12
|
+
status: str = "unresolved"
|
|
13
|
+
reviewer: str = ""
|
|
14
|
+
note: str = ""
|
|
15
|
+
reviewed_at: str = ""
|
|
16
|
+
|
|
17
|
+
def __post_init__(self):
|
|
18
|
+
for field in ("candidate_id", "status", "reviewer", "note", "reviewed_at"):
|
|
19
|
+
if not isinstance(getattr(self, field), str):
|
|
20
|
+
raise ValueError(f"{field} must be a string")
|
|
21
|
+
if self.reviewed_at:
|
|
22
|
+
try:
|
|
23
|
+
datetime.fromisoformat(self.reviewed_at.replace("Z", "+00:00"))
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise ValueError("reviewed_at must be an ISO timestamp") from None
|
|
26
|
+
if self.status not in {"accepted", "rejected", "unresolved"}:
|
|
27
|
+
raise ValueError("status must be accepted, rejected, or unresolved")
|
|
28
|
+
if self.status != "unresolved" and not self.reviewer.strip():
|
|
29
|
+
raise ValueError("accepted/rejected decisions need a reviewer")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Review:
|
|
34
|
+
run_id: str
|
|
35
|
+
decisions: tuple[Decision, ...]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def create(cls, result: Reconciliation):
|
|
39
|
+
return cls(result.run_id, tuple(Decision(c.candidate_id) for c in result.candidates))
|
|
40
|
+
|
|
41
|
+
def validate(self, result: Reconciliation):
|
|
42
|
+
ids = [d.candidate_id for d in self.decisions]
|
|
43
|
+
if (
|
|
44
|
+
self.run_id != result.run_id
|
|
45
|
+
or len(ids) != len(set(ids))
|
|
46
|
+
or set(ids) != {c.candidate_id for c in result.candidates}
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("review belongs to a different run or has missing/duplicate/unknown candidates")
|
|
49
|
+
|
|
50
|
+
def decide(self, candidate_id, status, *, reviewer, note=""):
|
|
51
|
+
if candidate_id not in {d.candidate_id for d in self.decisions}:
|
|
52
|
+
raise ValueError("unknown candidate ID")
|
|
53
|
+
updated = Decision(candidate_id, status, reviewer, note, datetime.now(timezone.utc).isoformat())
|
|
54
|
+
return replace(
|
|
55
|
+
self, decisions=tuple(updated if d.candidate_id == candidate_id else d for d in self.decisions)
|
|
56
|
+
)
|
ccrt/schema.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Source accounts and provenance; linking accounts never changes these records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import date
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from math import isfinite
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def canonical_json(value: object) -> str:
|
|
16
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def digest(value: object) -> str:
|
|
20
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SourceRecord:
|
|
25
|
+
source: str
|
|
26
|
+
source_id: str
|
|
27
|
+
version: str
|
|
28
|
+
date_start: date | None
|
|
29
|
+
date_end: date | None
|
|
30
|
+
latitude: float | None = None
|
|
31
|
+
longitude: float | None = None
|
|
32
|
+
country: str = ""
|
|
33
|
+
date_precision: int | None = None
|
|
34
|
+
geo_precision: int | None = None
|
|
35
|
+
date_basis: str = "reported"
|
|
36
|
+
actor1: str = ""
|
|
37
|
+
actor2: str = ""
|
|
38
|
+
event_type: str = ""
|
|
39
|
+
total_deaths_low: int | None = None
|
|
40
|
+
total_deaths_best: int | None = None
|
|
41
|
+
total_deaths_high: int | None = None
|
|
42
|
+
civilian_deaths: int | None = None
|
|
43
|
+
raw_json: str = "{}"
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
for field in ("source", "source_id", "version"):
|
|
47
|
+
value = getattr(self, field)
|
|
48
|
+
if not isinstance(value, str) or not value.strip():
|
|
49
|
+
raise ValueError(f"{field} must be a nonempty string")
|
|
50
|
+
for field in ("date_start", "date_end"):
|
|
51
|
+
value = getattr(self, field)
|
|
52
|
+
if value is not None and type(value) is not date:
|
|
53
|
+
raise ValueError(f"{field} must be a date or None")
|
|
54
|
+
if (self.date_start is None) != (self.date_end is None):
|
|
55
|
+
raise ValueError("both date bounds must be present or missing")
|
|
56
|
+
if self.date_start is not None and self.date_start > self.date_end:
|
|
57
|
+
raise ValueError("date_start exceeds date_end")
|
|
58
|
+
for field, limit in (("latitude", 90), ("longitude", 180)):
|
|
59
|
+
value = getattr(self, field)
|
|
60
|
+
if value is not None and (
|
|
61
|
+
isinstance(value, bool)
|
|
62
|
+
or not isinstance(value, (int, float))
|
|
63
|
+
or not isfinite(value)
|
|
64
|
+
or not -limit <= value <= limit
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(f"{field} must be finite and within ±{limit}")
|
|
67
|
+
for field in ("total_deaths_low", "total_deaths_best", "total_deaths_high", "civilian_deaths"):
|
|
68
|
+
value = getattr(self, field)
|
|
69
|
+
if value is not None and (type(value) is not int or value < 0):
|
|
70
|
+
raise ValueError(f"{field} must be a nonnegative integer or None")
|
|
71
|
+
for field in ("date_precision", "geo_precision"):
|
|
72
|
+
value = getattr(self, field)
|
|
73
|
+
if value is not None and (type(value) is not int or value < 1):
|
|
74
|
+
raise ValueError(f"{field} must be a positive integer or None")
|
|
75
|
+
if not isinstance(json.loads(self.raw_json), dict):
|
|
76
|
+
raise ValueError("raw_json must encode an object")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def key(self) -> str:
|
|
80
|
+
return canonical_json([self.source, self.version, self.source_id])
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def raw(self) -> dict:
|
|
84
|
+
"""Return a fresh copy so callers cannot mutate stored provenance."""
|
|
85
|
+
return json.loads(self.raw_json)
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
result = asdict(self)
|
|
89
|
+
for field in ("date_start", "date_end"):
|
|
90
|
+
result[field] = getattr(self, field).isoformat() if getattr(self, field) else None
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dict(cls, value: dict) -> SourceRecord:
|
|
95
|
+
values = dict(value)
|
|
96
|
+
for field in ("date_start", "date_end"):
|
|
97
|
+
values[field] = date.fromisoformat(values[field]) if values[field] else None
|
|
98
|
+
return cls(**values)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ValidationIssue:
|
|
103
|
+
row: int
|
|
104
|
+
message: str
|
|
105
|
+
severity: str = "error"
|
|
106
|
+
raw_json: str = "{}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class Dataset:
|
|
111
|
+
source: str
|
|
112
|
+
version: str
|
|
113
|
+
records: tuple[SourceRecord, ...]
|
|
114
|
+
input_sha256: str
|
|
115
|
+
origin: str = ""
|
|
116
|
+
issues: tuple[ValidationIssue, ...] = ()
|
|
117
|
+
metadata_json: str = "{}"
|
|
118
|
+
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
object.__setattr__(self, "records", tuple(self.records))
|
|
121
|
+
object.__setattr__(self, "issues", tuple(self.issues))
|
|
122
|
+
if not self.source.strip() or not self.version.strip():
|
|
123
|
+
raise ValueError("dataset source and version are required")
|
|
124
|
+
keys = set()
|
|
125
|
+
for record in self.records:
|
|
126
|
+
if record.source != self.source or record.version != self.version:
|
|
127
|
+
raise ValueError("record source/version differs from dataset")
|
|
128
|
+
if record.key in keys:
|
|
129
|
+
raise ValueError(f"duplicate record ID: {record.source_id}")
|
|
130
|
+
keys.add(record.key)
|
|
131
|
+
if not isinstance(json.loads(self.metadata_json), dict):
|
|
132
|
+
raise ValueError("metadata_json must encode an object")
|
|
133
|
+
|
|
134
|
+
@cached_property
|
|
135
|
+
def fingerprint(self) -> str:
|
|
136
|
+
"""Content identity includes normalized records, mapping metadata and rejected rows."""
|
|
137
|
+
return digest(
|
|
138
|
+
{
|
|
139
|
+
"source": self.source,
|
|
140
|
+
"version": self.version,
|
|
141
|
+
"input_sha256": self.input_sha256,
|
|
142
|
+
"metadata": json.loads(self.metadata_json),
|
|
143
|
+
"issues": [asdict(i) for i in self.issues],
|
|
144
|
+
"records": sorted((r.to_dict() for r in self.records), key=lambda r: r["source_id"]),
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def __len__(self) -> int:
|
|
149
|
+
return len(self.records)
|
|
150
|
+
|
|
151
|
+
def __iter__(self):
|
|
152
|
+
return iter(self.records)
|
|
153
|
+
|
|
154
|
+
def to_pandas(self):
|
|
155
|
+
"""Return flat columns with nullable types. Requires the tables extra."""
|
|
156
|
+
try:
|
|
157
|
+
import pandas as pd
|
|
158
|
+
except ImportError:
|
|
159
|
+
raise ImportError('Install table support with: pip install "ccrt[tables]"') from None
|
|
160
|
+
return pd.DataFrame(
|
|
161
|
+
[r.to_dict() for r in self.records], columns=SourceRecord.__dataclass_fields__
|
|
162
|
+
).convert_dtypes()
|
ccrt/storage.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Versioned JSON bundles and atomic writes for reproducible analysis."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ccrt._version import __version__
|
|
10
|
+
from ccrt.merge import MatchConfig, reconcile
|
|
11
|
+
from ccrt.review import Decision, Review
|
|
12
|
+
from ccrt.schema import SCHEMA_VERSION, Dataset, SourceRecord, ValidationIssue, canonical_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def atomic_write(path, content, *, overwrite=False):
|
|
16
|
+
path = Path(path)
|
|
17
|
+
if path.exists() and not overwrite:
|
|
18
|
+
raise FileExistsError(f"output exists: {path}")
|
|
19
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as stream:
|
|
20
|
+
temporary = Path(stream.name)
|
|
21
|
+
try:
|
|
22
|
+
stream.write(content)
|
|
23
|
+
stream.flush()
|
|
24
|
+
os.fsync(stream.fileno())
|
|
25
|
+
except BaseException:
|
|
26
|
+
temporary.unlink(missing_ok=True)
|
|
27
|
+
raise
|
|
28
|
+
try:
|
|
29
|
+
if overwrite:
|
|
30
|
+
os.replace(temporary, path)
|
|
31
|
+
else:
|
|
32
|
+
# A hard link fails atomically if another writer created the destination.
|
|
33
|
+
os.link(temporary, path)
|
|
34
|
+
finally:
|
|
35
|
+
temporary.unlink(missing_ok=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dataset_dict(dataset):
|
|
39
|
+
return {
|
|
40
|
+
"schema_version": SCHEMA_VERSION,
|
|
41
|
+
"ccrt_version": __version__,
|
|
42
|
+
"kind": "dataset",
|
|
43
|
+
"source": dataset.source,
|
|
44
|
+
"version": dataset.version,
|
|
45
|
+
"input_sha256": dataset.input_sha256,
|
|
46
|
+
"fingerprint": dataset.fingerprint,
|
|
47
|
+
"origin": dataset.origin,
|
|
48
|
+
"metadata_json": dataset.metadata_json,
|
|
49
|
+
"issues": [asdict(i) for i in dataset.issues],
|
|
50
|
+
"records": [r.to_dict() for r in dataset.records],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check(payload, kind):
|
|
55
|
+
if (
|
|
56
|
+
not isinstance(payload, dict)
|
|
57
|
+
or payload.get("schema_version") != SCHEMA_VERSION
|
|
58
|
+
or payload.get("kind") != kind
|
|
59
|
+
):
|
|
60
|
+
raise ValueError(f"unsupported CCRT {kind} bundle/schema version")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def dataset_from_dict(payload):
|
|
64
|
+
_check(payload, "dataset")
|
|
65
|
+
try:
|
|
66
|
+
dataset = Dataset(
|
|
67
|
+
payload["source"],
|
|
68
|
+
payload["version"],
|
|
69
|
+
tuple(SourceRecord.from_dict(r) for r in payload["records"]),
|
|
70
|
+
payload["input_sha256"],
|
|
71
|
+
payload["origin"],
|
|
72
|
+
tuple(ValidationIssue(**i) for i in payload["issues"]),
|
|
73
|
+
payload["metadata_json"],
|
|
74
|
+
)
|
|
75
|
+
if dataset.fingerprint != payload["fingerprint"]:
|
|
76
|
+
raise ValueError("dataset fingerprint mismatch")
|
|
77
|
+
return dataset
|
|
78
|
+
except (KeyError, TypeError) as exc:
|
|
79
|
+
raise ValueError("malformed dataset bundle") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_dataset(dataset, path, *, overwrite=False):
|
|
83
|
+
atomic_write(path, canonical_json(dataset_dict(dataset)) + "\n", overwrite=overwrite)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_dataset(path):
|
|
87
|
+
return dataset_from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_run(result, path, *, overwrite=False):
|
|
91
|
+
payload = {
|
|
92
|
+
"kind": "run",
|
|
93
|
+
"schema_version": SCHEMA_VERSION,
|
|
94
|
+
"ccrt_version": __version__,
|
|
95
|
+
"run_id": result.run_id,
|
|
96
|
+
"algorithm": "interval-haversine-v1",
|
|
97
|
+
"left": dataset_dict(result.left),
|
|
98
|
+
"right": dataset_dict(result.right),
|
|
99
|
+
"config": result.config.to_dict(),
|
|
100
|
+
"candidate_ids": [c.candidate_id for c in result.candidates],
|
|
101
|
+
}
|
|
102
|
+
atomic_write(path, canonical_json(payload) + "\n", overwrite=overwrite)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_run(path):
|
|
106
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
107
|
+
_check(payload, "run")
|
|
108
|
+
try:
|
|
109
|
+
if payload["algorithm"] != "interval-haversine-v1":
|
|
110
|
+
raise ValueError("unsupported matching algorithm")
|
|
111
|
+
result = reconcile(
|
|
112
|
+
dataset_from_dict(payload["left"]),
|
|
113
|
+
dataset_from_dict(payload["right"]),
|
|
114
|
+
config=MatchConfig(**payload["config"]),
|
|
115
|
+
)
|
|
116
|
+
if (
|
|
117
|
+
result.run_id != payload["run_id"]
|
|
118
|
+
or [c.candidate_id for c in result.candidates] != payload["candidate_ids"]
|
|
119
|
+
):
|
|
120
|
+
raise ValueError("run fingerprint/candidates differ; regenerate the run")
|
|
121
|
+
return result
|
|
122
|
+
except (KeyError, TypeError) as exc:
|
|
123
|
+
raise ValueError("malformed run bundle") from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_review(review, result, path, *, overwrite=False):
|
|
127
|
+
review.validate(result)
|
|
128
|
+
payload = {
|
|
129
|
+
"kind": "review",
|
|
130
|
+
"schema_version": SCHEMA_VERSION,
|
|
131
|
+
"ccrt_version": __version__,
|
|
132
|
+
"run_id": review.run_id,
|
|
133
|
+
"decisions": [asdict(d) for d in review.decisions],
|
|
134
|
+
}
|
|
135
|
+
atomic_write(path, canonical_json(payload) + "\n", overwrite=overwrite)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_review(path, result):
|
|
139
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
140
|
+
_check(payload, "review")
|
|
141
|
+
try:
|
|
142
|
+
review = Review(payload["run_id"], tuple(Decision(**d) for d in payload["decisions"]))
|
|
143
|
+
except (KeyError, TypeError) as exc:
|
|
144
|
+
raise ValueError("malformed review bundle") from exc
|
|
145
|
+
review.validate(result)
|
|
146
|
+
return review
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def atomic_write_bytes(path, content, *, overwrite=False):
|
|
150
|
+
path = Path(path)
|
|
151
|
+
if path.exists() and not overwrite:
|
|
152
|
+
raise FileExistsError(f"output exists: {path}")
|
|
153
|
+
with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, delete=False) as stream:
|
|
154
|
+
temporary = Path(stream.name)
|
|
155
|
+
try:
|
|
156
|
+
stream.write(content)
|
|
157
|
+
stream.flush()
|
|
158
|
+
os.fsync(stream.fileno())
|
|
159
|
+
except BaseException:
|
|
160
|
+
temporary.unlink(missing_ok=True)
|
|
161
|
+
raise
|
|
162
|
+
try:
|
|
163
|
+
if overwrite:
|
|
164
|
+
os.replace(temporary, path)
|
|
165
|
+
else:
|
|
166
|
+
os.link(temporary, path)
|
|
167
|
+
finally:
|
|
168
|
+
temporary.unlink(missing_ok=True)
|