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/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)