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/interactive.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Small terminal workflow for reviewing candidate links with their source accounts."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _short(value, limit=110):
|
|
7
|
+
value = " ".join(str(value or "").split())
|
|
8
|
+
return value if len(value) <= limit else value[: limit - 1] + "…"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _date(record):
|
|
12
|
+
if record.date_start is None:
|
|
13
|
+
return "unknown date"
|
|
14
|
+
start, end = record.date_start.isoformat(), record.date_end.isoformat()
|
|
15
|
+
return start if start == end else f"{start} to {end}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _place(record):
|
|
19
|
+
raw = record.raw
|
|
20
|
+
return _short(raw.get("where_description") or raw.get("location") or raw.get("where_coordinates"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _coordinates(record):
|
|
24
|
+
if record.latitude is None or record.longitude is None:
|
|
25
|
+
return "not supplied"
|
|
26
|
+
return f"lat {record.latitude}, lon {record.longitude}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _account(record, emit):
|
|
30
|
+
raw = record.raw
|
|
31
|
+
emit(f"{record.source.upper()} {record.source_id} | {_date(record)} | {record.country}")
|
|
32
|
+
place = raw.get("where_description") or raw.get("location") or raw.get("where_coordinates")
|
|
33
|
+
emit(f"Place: {place or 'not supplied'}")
|
|
34
|
+
emit(f"Coordinates: {_coordinates(record)}")
|
|
35
|
+
emit(f"Actors: {record.actor1 or '?'} / {record.actor2 or '?'}")
|
|
36
|
+
deaths = f"best {record.total_deaths_best}"
|
|
37
|
+
if record.total_deaths_low is not None or record.total_deaths_high is not None:
|
|
38
|
+
deaths += f" (low {record.total_deaths_low}, high {record.total_deaths_high})"
|
|
39
|
+
emit(f"Event type: {record.event_type or '?'} | reported deaths: {deaths}")
|
|
40
|
+
emit(f"Date precision: {record.date_precision} | location precision: {record.geo_precision}")
|
|
41
|
+
for label, key in (
|
|
42
|
+
("Notes", "notes"),
|
|
43
|
+
("Source articles", "source_article"),
|
|
44
|
+
("Original sources", "source_original"),
|
|
45
|
+
("Source", "source"),
|
|
46
|
+
):
|
|
47
|
+
if raw.get(key):
|
|
48
|
+
emit(f"{label}: {raw[key]}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def review_interactive(
|
|
52
|
+
result, review, *, reviewer, save, left_id=None, show_all=False, ask=input, emit=print
|
|
53
|
+
):
|
|
54
|
+
"""Review UCDP/other left-side groups; call save after every decision.
|
|
55
|
+
|
|
56
|
+
``ask`` and ``emit`` are injectable so the terminal workflow can be tested.
|
|
57
|
+
The caller owns loading the run, validating the review, and persisting it.
|
|
58
|
+
"""
|
|
59
|
+
review.validate(result)
|
|
60
|
+
if not reviewer.strip():
|
|
61
|
+
raise ValueError("--reviewer needs a nonempty name")
|
|
62
|
+
grouped = defaultdict(list)
|
|
63
|
+
for candidate in result.candidates:
|
|
64
|
+
grouped[candidate.left.source_id].append(candidate)
|
|
65
|
+
if left_id is not None and left_id not in grouped:
|
|
66
|
+
raise ValueError(f"no candidate group for left source ID {left_id}")
|
|
67
|
+
decided = {decision.candidate_id: decision for decision in review.decisions}
|
|
68
|
+
ids = sorted(
|
|
69
|
+
(
|
|
70
|
+
key
|
|
71
|
+
for key, items in grouped.items()
|
|
72
|
+
if key == left_id
|
|
73
|
+
or show_all
|
|
74
|
+
or any(decided[c.candidate_id].status == "unresolved" for c in items)
|
|
75
|
+
),
|
|
76
|
+
key=lambda key: (-grouped[key][0].score, key),
|
|
77
|
+
)
|
|
78
|
+
if not ids:
|
|
79
|
+
emit("No candidate groups to review. Use --all to revisit decisions.")
|
|
80
|
+
return review
|
|
81
|
+
|
|
82
|
+
emit("Scores rank nearby records; coordinates may represent shared place labels.")
|
|
83
|
+
emit("Compare both source accounts before deciding.")
|
|
84
|
+
position = ids.index(left_id) if left_id is not None else 0
|
|
85
|
+
while True:
|
|
86
|
+
items = grouped[ids[position]]
|
|
87
|
+
left = items[0].left
|
|
88
|
+
emit(f"\nGroup {position + 1}/{len(ids)} | {result.left.source.upper()} {left.source_id}")
|
|
89
|
+
_account(left, emit)
|
|
90
|
+
for number, candidate in enumerate(items, 1):
|
|
91
|
+
right = candidate.right
|
|
92
|
+
status = decided[candidate.candidate_id].status
|
|
93
|
+
emit(
|
|
94
|
+
f" {number}. [{status}] {right.source_id} | score {candidate.score:.3f}"
|
|
95
|
+
f" | {candidate.days_apart} days | {candidate.distance_km:.1f} km"
|
|
96
|
+
f" | deaths {right.total_deaths_best} | {_date(right)} | {_place(right)}"
|
|
97
|
+
f" | {_coordinates(right)}"
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
choice = ask("Number to inspect; n next, p previous, q quit: ").strip().lower()
|
|
101
|
+
except (EOFError, KeyboardInterrupt):
|
|
102
|
+
emit("\nReview saved. Re-run the command to continue.")
|
|
103
|
+
return review
|
|
104
|
+
if choice == "q":
|
|
105
|
+
break
|
|
106
|
+
if choice == "n":
|
|
107
|
+
if position == len(ids) - 1:
|
|
108
|
+
emit("End of review queue; returning to the first group.")
|
|
109
|
+
position = (position + 1) % len(ids)
|
|
110
|
+
continue
|
|
111
|
+
if choice == "p":
|
|
112
|
+
if position == 0:
|
|
113
|
+
emit("Start of review queue; returning to the last group.")
|
|
114
|
+
position = (position - 1) % len(ids)
|
|
115
|
+
continue
|
|
116
|
+
if not choice.isdecimal() or not 1 <= int(choice) <= len(items):
|
|
117
|
+
emit("Choose a listed number, n, p, or q.")
|
|
118
|
+
continue
|
|
119
|
+
candidate = items[int(choice) - 1]
|
|
120
|
+
decision = decided[candidate.candidate_id]
|
|
121
|
+
emit("\nLeft account:")
|
|
122
|
+
_account(candidate.left, emit)
|
|
123
|
+
emit("\nCandidate account:")
|
|
124
|
+
_account(candidate.right, emit)
|
|
125
|
+
emit(f"Coordinate separation: {candidate.distance_km:.3f} km (limit {result.config.max_km:g} km)")
|
|
126
|
+
emit(
|
|
127
|
+
f"Date interval gap: {candidate.days_apart} days"
|
|
128
|
+
f" (limit {result.config.max_days} days) | proximity score: {candidate.score:.3f}"
|
|
129
|
+
)
|
|
130
|
+
emit("Location precision codes are source specific, not kilometer uncertainty radii.")
|
|
131
|
+
emit(f"Current: {decision.status}" + (f" | note: {decision.note}" if decision.note else ""))
|
|
132
|
+
emit(f"Warnings: {', '.join(candidate.warnings) or 'none'}")
|
|
133
|
+
try:
|
|
134
|
+
action = ask("a accept, r reject, u unresolved, b back, q quit: ").strip().lower()
|
|
135
|
+
except (EOFError, KeyboardInterrupt):
|
|
136
|
+
emit("\nReview saved. Re-run the command to continue.")
|
|
137
|
+
return review
|
|
138
|
+
if action == "q":
|
|
139
|
+
break
|
|
140
|
+
if action == "b":
|
|
141
|
+
continue
|
|
142
|
+
statuses = {"a": "accepted", "r": "rejected", "u": "unresolved"}
|
|
143
|
+
if action not in statuses:
|
|
144
|
+
emit("Choose a, r, u, b, or q.")
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
note = ask("Reason (optional; Enter keeps existing note): ").strip()
|
|
148
|
+
except (EOFError, KeyboardInterrupt):
|
|
149
|
+
emit("\nNo change saved for this candidate.")
|
|
150
|
+
return review
|
|
151
|
+
review = review.decide(
|
|
152
|
+
candidate.candidate_id, statuses[action], reviewer=reviewer, note=note or decision.note
|
|
153
|
+
)
|
|
154
|
+
save(review)
|
|
155
|
+
decided[candidate.candidate_id] = next(
|
|
156
|
+
d for d in review.decisions if d.candidate_id == candidate.candidate_id
|
|
157
|
+
)
|
|
158
|
+
emit(f"Saved {statuses[action]} for {candidate.left.source_id} / {candidate.right.source_id}.")
|
|
159
|
+
emit("Review saved. Re-run the command to continue.")
|
|
160
|
+
return review
|
ccrt/loaders/__init__.py
ADDED
ccrt/loaders/acled.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""ACLED event CSV mapping (current lowercase columns, codebook 2024 onward)."""
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ccrt.loaders.base import integer, normalize, number, parse_date, read_csv_rows, text
|
|
8
|
+
from ccrt.schema import SourceRecord, canonical_json
|
|
9
|
+
|
|
10
|
+
REQUIRED = {
|
|
11
|
+
"event_id_cnty",
|
|
12
|
+
"event_date",
|
|
13
|
+
"time_precision",
|
|
14
|
+
"latitude",
|
|
15
|
+
"longitude",
|
|
16
|
+
"geo_precision",
|
|
17
|
+
"country",
|
|
18
|
+
"fatalities",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def map_record(row, version):
|
|
23
|
+
day = parse_date(row["event_date"])
|
|
24
|
+
precision = integer(row["time_precision"])
|
|
25
|
+
geo = integer(row["geo_precision"])
|
|
26
|
+
if precision not in {None, 1, 2, 3} or geo not in {None, 1, 2, 3}:
|
|
27
|
+
raise ValueError("ACLED precision codes must be 1, 2, 3, or blank")
|
|
28
|
+
start = end = day
|
|
29
|
+
basis = "reported"
|
|
30
|
+
if day and precision == 2:
|
|
31
|
+
start, end = day - timedelta(days=3), day + timedelta(days=3)
|
|
32
|
+
basis = "inferred-week-envelope"
|
|
33
|
+
elif day and precision == 3:
|
|
34
|
+
start = day.replace(day=1)
|
|
35
|
+
end = day.replace(day=calendar.monthrange(day.year, day.month)[1])
|
|
36
|
+
basis = "inferred-month-envelope"
|
|
37
|
+
return SourceRecord(
|
|
38
|
+
source="acled",
|
|
39
|
+
source_id=text(row["event_id_cnty"]),
|
|
40
|
+
version=version,
|
|
41
|
+
date_start=start,
|
|
42
|
+
date_end=end,
|
|
43
|
+
latitude=number(row["latitude"]),
|
|
44
|
+
longitude=number(row["longitude"]),
|
|
45
|
+
country=text(row["country"]),
|
|
46
|
+
date_precision=precision,
|
|
47
|
+
geo_precision=geo,
|
|
48
|
+
date_basis=basis,
|
|
49
|
+
actor1=text(row.get("actor1")),
|
|
50
|
+
actor2=text(row.get("actor2")),
|
|
51
|
+
event_type=text(row.get("event_type")),
|
|
52
|
+
total_deaths_best=integer(row["fatalities"]),
|
|
53
|
+
raw_json=canonical_json(row),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_acled_csv(path, *, version, errors="raise"):
|
|
58
|
+
"""version is a user-supplied export snapshot label, not an ACLED release number."""
|
|
59
|
+
rows, sha = read_csv_rows(path, REQUIRED)
|
|
60
|
+
return normalize(
|
|
61
|
+
rows,
|
|
62
|
+
source="acled",
|
|
63
|
+
version=version,
|
|
64
|
+
mapper=map_record,
|
|
65
|
+
input_sha256=sha,
|
|
66
|
+
origin=Path(path).name,
|
|
67
|
+
errors=errors,
|
|
68
|
+
metadata={"adapter": "acled-event-v1", "date_policy": "week ±3 days; month envelope"},
|
|
69
|
+
)
|
ccrt/loaders/base.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Shared validation and CSV mechanics for explicit source adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import re
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ccrt.schema import Dataset, SourceRecord, ValidationIssue, canonical_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ValidationError(ValueError):
|
|
16
|
+
def __init__(self, issues):
|
|
17
|
+
self.issues = tuple(issues)
|
|
18
|
+
first = self.issues[0]
|
|
19
|
+
super().__init__(f"{len(self.issues)} invalid row(s); row {first.row}: {first.message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def text(value) -> str:
|
|
23
|
+
return "" if value is None else str(value).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def integer(value) -> int | None:
|
|
27
|
+
if value is None or text(value) == "":
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(value, bool) or not re.fullmatch(r"-?\d+", text(value)):
|
|
30
|
+
raise ValueError("expected an integer")
|
|
31
|
+
return int(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def number(value) -> float | None:
|
|
35
|
+
return None if value is None or text(value) == "" else float(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_date(value) -> date | None:
|
|
39
|
+
value = text(value)
|
|
40
|
+
if not value:
|
|
41
|
+
return None
|
|
42
|
+
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
|
43
|
+
return date.fromisoformat(value)
|
|
44
|
+
# UCDP CSV exports can contain an ISO timestamp; reject ambiguous locale dates.
|
|
45
|
+
if re.fullmatch(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?", value):
|
|
46
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).date()
|
|
47
|
+
raise ValueError("expected an ISO date (YYYY-MM-DD) or ISO timestamp")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_csv_rows(path, required):
|
|
51
|
+
payload = Path(path).read_bytes()
|
|
52
|
+
reader = csv.DictReader(io.StringIO(payload.decode("utf-8-sig"), newline=""))
|
|
53
|
+
fields = reader.fieldnames or []
|
|
54
|
+
if len(fields) != len(set(fields)):
|
|
55
|
+
raise ValueError("duplicate CSV column names")
|
|
56
|
+
missing = set(required) - set(fields)
|
|
57
|
+
if missing:
|
|
58
|
+
raise ValueError(f"missing columns: {', '.join(sorted(missing))}")
|
|
59
|
+
rows = []
|
|
60
|
+
for row in reader:
|
|
61
|
+
if None in row or any(v is None for v in row.values()):
|
|
62
|
+
raise ValueError(f"row {reader.line_num}: CSV field count does not match header")
|
|
63
|
+
rows.append((reader.line_num, row))
|
|
64
|
+
return rows, hashlib.sha256(payload).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize(rows, *, source, version, mapper, input_sha256, origin="", errors="raise", metadata=None):
|
|
68
|
+
if errors not in {"raise", "collect"}:
|
|
69
|
+
raise ValueError("errors must be 'raise' or 'collect'")
|
|
70
|
+
if not isinstance(version, str) or not version.strip():
|
|
71
|
+
raise ValueError("an explicit source version or export snapshot label is required")
|
|
72
|
+
records, issues, seen = [], [], set()
|
|
73
|
+
for line, row in rows:
|
|
74
|
+
raw = canonical_json(row)
|
|
75
|
+
try:
|
|
76
|
+
record = mapper(row, version)
|
|
77
|
+
if record.key in seen:
|
|
78
|
+
raise ValueError(f"duplicate source ID: {record.source_id}")
|
|
79
|
+
seen.add(record.key)
|
|
80
|
+
records.append(record)
|
|
81
|
+
estimates = [
|
|
82
|
+
value
|
|
83
|
+
for value in (record.total_deaths_low, record.total_deaths_best, record.total_deaths_high)
|
|
84
|
+
if value is not None
|
|
85
|
+
]
|
|
86
|
+
if len(estimates) > 1 and estimates != sorted(estimates):
|
|
87
|
+
issues.append(
|
|
88
|
+
ValidationIssue(
|
|
89
|
+
line,
|
|
90
|
+
"source total death estimates are not ordered low <= best <= high; preserved as reported",
|
|
91
|
+
"warning",
|
|
92
|
+
raw,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
if record.date_start is None or record.latitude is None or record.longitude is None:
|
|
96
|
+
issues.append(
|
|
97
|
+
ValidationIssue(
|
|
98
|
+
line, "missing date or coordinate; retained but cannot match", "warning", raw
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
except (ValueError, TypeError, KeyError, OverflowError) as exc:
|
|
102
|
+
issues.append(ValidationIssue(line, str(exc), "error", raw))
|
|
103
|
+
failures = [i for i in issues if i.severity == "error"]
|
|
104
|
+
if failures and errors == "raise":
|
|
105
|
+
raise ValidationError(failures)
|
|
106
|
+
return Dataset(
|
|
107
|
+
source, version, tuple(records), input_sha256, origin, tuple(issues), canonical_json(metadata or {})
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_csv(path, source, *, version="user-supplied", errors="raise") -> Dataset:
|
|
112
|
+
"""Import the original minimal CSV format; casualty bounds mean TOTAL deaths."""
|
|
113
|
+
rows, sha = read_csv_rows(path, {"source_id", "date", "latitude", "longitude"})
|
|
114
|
+
|
|
115
|
+
def mapper(row, version):
|
|
116
|
+
day = parse_date(row["date"])
|
|
117
|
+
low = integer(row.get("fatalities_min"))
|
|
118
|
+
high = integer(row.get("fatalities_max"))
|
|
119
|
+
if low is not None and high is not None and low > high:
|
|
120
|
+
raise ValueError("fatalities_min cannot exceed fatalities_max")
|
|
121
|
+
return SourceRecord(
|
|
122
|
+
source,
|
|
123
|
+
text(row["source_id"]),
|
|
124
|
+
version,
|
|
125
|
+
day,
|
|
126
|
+
day,
|
|
127
|
+
number(row["latitude"]),
|
|
128
|
+
number(row["longitude"]),
|
|
129
|
+
text(row.get("country")),
|
|
130
|
+
total_deaths_low=low,
|
|
131
|
+
total_deaths_high=high,
|
|
132
|
+
raw_json=canonical_json(row),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return normalize(
|
|
136
|
+
rows,
|
|
137
|
+
source=source,
|
|
138
|
+
version=version,
|
|
139
|
+
mapper=mapper,
|
|
140
|
+
input_sha256=sha,
|
|
141
|
+
origin=Path(path).name,
|
|
142
|
+
errors=errors,
|
|
143
|
+
metadata={"adapter": "generic-v1"},
|
|
144
|
+
)
|
ccrt/loaders/ucdp.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""UCDP GED 25.1/26.1 field mapping. Other versions require independent validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ccrt.loaders.base import integer, normalize, number, parse_date, read_csv_rows, text
|
|
6
|
+
from ccrt.schema import SourceRecord, canonical_json, digest
|
|
7
|
+
|
|
8
|
+
REQUIRED = {"id", "date_start", "date_end", "latitude", "longitude", "country"}
|
|
9
|
+
SUPPORTED_VERSIONS = {"25.1", "26.1"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_version(version):
|
|
13
|
+
if version not in SUPPORTED_VERSIONS:
|
|
14
|
+
raise ValueError("supported UCDP GED versions: 25.1, 26.1")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def map_record(row, version):
|
|
18
|
+
missing = REQUIRED - row.keys()
|
|
19
|
+
if missing:
|
|
20
|
+
raise ValueError(f"missing UCDP fields: {', '.join(sorted(missing))}")
|
|
21
|
+
date_precision = integer(row.get("date_prec"))
|
|
22
|
+
geo_precision = integer(row.get("where_prec"))
|
|
23
|
+
if date_precision is not None and date_precision not in range(1, 6):
|
|
24
|
+
raise ValueError("UCDP date_prec must be 1 through 5")
|
|
25
|
+
if geo_precision is not None and geo_precision not in range(1, 8):
|
|
26
|
+
raise ValueError("UCDP where_prec must be 1 through 7")
|
|
27
|
+
return SourceRecord(
|
|
28
|
+
source="ucdp",
|
|
29
|
+
source_id=text(row["id"]),
|
|
30
|
+
version=version,
|
|
31
|
+
date_start=parse_date(row["date_start"]),
|
|
32
|
+
date_end=parse_date(row["date_end"]),
|
|
33
|
+
latitude=number(row["latitude"]),
|
|
34
|
+
longitude=number(row["longitude"]),
|
|
35
|
+
country=text(row["country"]),
|
|
36
|
+
date_precision=date_precision,
|
|
37
|
+
geo_precision=geo_precision,
|
|
38
|
+
actor1=text(row.get("side_a")),
|
|
39
|
+
actor2=text(row.get("side_b")),
|
|
40
|
+
event_type=text(row.get("type_of_violence")),
|
|
41
|
+
total_deaths_low=integer(row.get("low")),
|
|
42
|
+
total_deaths_best=integer(row.get("best")),
|
|
43
|
+
total_deaths_high=integer(row.get("high")),
|
|
44
|
+
civilian_deaths=integer(row.get("deaths_civilians")),
|
|
45
|
+
raw_json=canonical_json(row),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_ucdp_csv(path, *, version, errors="raise"):
|
|
50
|
+
check_version(version)
|
|
51
|
+
rows, sha = read_csv_rows(path, REQUIRED)
|
|
52
|
+
return normalize(
|
|
53
|
+
rows,
|
|
54
|
+
source="ucdp",
|
|
55
|
+
version=version,
|
|
56
|
+
mapper=map_record,
|
|
57
|
+
input_sha256=sha,
|
|
58
|
+
origin=Path(path).name,
|
|
59
|
+
errors=errors,
|
|
60
|
+
metadata={"adapter": "ucdp-ged-v1"},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def from_api_records(records, *, version, metadata=None, errors="raise"):
|
|
65
|
+
check_version(version)
|
|
66
|
+
return normalize(
|
|
67
|
+
enumerate(records, 1),
|
|
68
|
+
source="ucdp",
|
|
69
|
+
version=version,
|
|
70
|
+
mapper=map_record,
|
|
71
|
+
input_sha256=digest(records),
|
|
72
|
+
origin="ucdp-api",
|
|
73
|
+
errors=errors,
|
|
74
|
+
metadata={"adapter": "ucdp-ged-v1", **(metadata or {})},
|
|
75
|
+
)
|
ccrt/merge.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Inspectable candidate generation. No automatic assignment or casualty aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_left, bisect_right
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from math import asin, cos, isfinite, radians, sin, sqrt
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
from ccrt.schema import Dataset, SourceRecord, digest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class MatchConfig:
|
|
16
|
+
max_days: int = 3
|
|
17
|
+
max_km: float = 25.0
|
|
18
|
+
include_coarse_locations: bool = False
|
|
19
|
+
country_aliases: dict[str, str] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def __post_init__(self):
|
|
22
|
+
if type(self.max_days) is not int or self.max_days < 0:
|
|
23
|
+
raise ValueError("max_days must be a nonnegative integer")
|
|
24
|
+
if (
|
|
25
|
+
isinstance(self.max_km, bool)
|
|
26
|
+
or not isinstance(self.max_km, (int, float))
|
|
27
|
+
or not isfinite(self.max_km)
|
|
28
|
+
or self.max_km <= 0
|
|
29
|
+
):
|
|
30
|
+
raise ValueError("max_km must be finite and positive")
|
|
31
|
+
if type(self.include_coarse_locations) is not bool:
|
|
32
|
+
raise ValueError("include_coarse_locations must be boolean")
|
|
33
|
+
if any(
|
|
34
|
+
not isinstance(k, str) or not isinstance(v, str) or not k.strip() or not v.strip()
|
|
35
|
+
for k, v in self.country_aliases.items()
|
|
36
|
+
):
|
|
37
|
+
raise ValueError("country_aliases must map nonempty strings to nonempty strings")
|
|
38
|
+
object.__setattr__(
|
|
39
|
+
self,
|
|
40
|
+
"country_aliases",
|
|
41
|
+
MappingProxyType(
|
|
42
|
+
{k.strip().casefold(): v.strip().casefold() for k, v in self.country_aliases.items()}
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def to_dict(self):
|
|
47
|
+
return {
|
|
48
|
+
"max_days": self.max_days,
|
|
49
|
+
"max_km": self.max_km,
|
|
50
|
+
"include_coarse_locations": self.include_coarse_locations,
|
|
51
|
+
"country_aliases": dict(self.country_aliases),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Candidate:
|
|
57
|
+
candidate_id: str
|
|
58
|
+
left: SourceRecord
|
|
59
|
+
right: SourceRecord
|
|
60
|
+
days_apart: int
|
|
61
|
+
distance_km: float
|
|
62
|
+
score: float
|
|
63
|
+
warnings: tuple[str, ...] = ()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class UnlinkedRecord:
|
|
68
|
+
record: SourceRecord
|
|
69
|
+
reason: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Reconciliation:
|
|
74
|
+
left: Dataset
|
|
75
|
+
right: Dataset
|
|
76
|
+
config: MatchConfig
|
|
77
|
+
candidates: tuple[Candidate, ...]
|
|
78
|
+
unlinked: tuple[UnlinkedRecord, ...]
|
|
79
|
+
|
|
80
|
+
@cached_property
|
|
81
|
+
def run_id(self):
|
|
82
|
+
return digest(
|
|
83
|
+
{
|
|
84
|
+
"left": self.left.fingerprint,
|
|
85
|
+
"right": self.right.fingerprint,
|
|
86
|
+
"config": self.config.to_dict(),
|
|
87
|
+
"algorithm": "interval-haversine-v1",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def distance_km(left: SourceRecord, right: SourceRecord) -> float:
|
|
93
|
+
if any(v is None for v in (left.latitude, left.longitude, right.latitude, right.longitude)):
|
|
94
|
+
raise ValueError("distance needs complete coordinates")
|
|
95
|
+
lat1, lat2 = radians(left.latitude), radians(right.latitude)
|
|
96
|
+
a = (
|
|
97
|
+
sin((lat2 - lat1) / 2) ** 2
|
|
98
|
+
+ cos(lat1) * cos(lat2) * sin(radians(right.longitude - left.longitude) / 2) ** 2
|
|
99
|
+
)
|
|
100
|
+
return 6371.0088 * 2 * asin(min(1.0, sqrt(max(0.0, a))))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ineligible(record, config):
|
|
104
|
+
if record.date_start is None:
|
|
105
|
+
return "missing_date"
|
|
106
|
+
if record.latitude is None or record.longitude is None:
|
|
107
|
+
return "missing_coordinates"
|
|
108
|
+
if (
|
|
109
|
+
not config.include_coarse_locations
|
|
110
|
+
and record.source in {"ucdp", "acled"}
|
|
111
|
+
and record.geo_precision is not None
|
|
112
|
+
and record.geo_precision >= 3
|
|
113
|
+
):
|
|
114
|
+
return "coarse_location_excluded"
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _country(record, config):
|
|
119
|
+
country = record.country.strip().casefold()
|
|
120
|
+
return config.country_aliases.get(country, country)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def reconcile(left: Dataset, right: Dataset, *, config: MatchConfig | None = None) -> Reconciliation:
|
|
124
|
+
"""Return all eligible pairs; a record may occur in several candidates.
|
|
125
|
+
|
|
126
|
+
Date distance is the gap between closed intervals. Location precision codes are
|
|
127
|
+
source-specific categories, not radii. Coarse locations are excluded by default.
|
|
128
|
+
"""
|
|
129
|
+
config = config or MatchConfig()
|
|
130
|
+
if left.source == right.source:
|
|
131
|
+
raise ValueError("reconciliation requires distinct sources")
|
|
132
|
+
seed = digest(
|
|
133
|
+
{
|
|
134
|
+
"left": left.fingerprint,
|
|
135
|
+
"right": right.fingerprint,
|
|
136
|
+
"config": config.to_dict(),
|
|
137
|
+
"algorithm": "interval-haversine-v1",
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
eligible_right = sorted(
|
|
141
|
+
(r for r in right if not _ineligible(r, config)), key=lambda r: (r.date_start, r.key)
|
|
142
|
+
)
|
|
143
|
+
starts = [r.date_start.toordinal() for r in eligible_right]
|
|
144
|
+
prefix_ends = []
|
|
145
|
+
for record in eligible_right:
|
|
146
|
+
prefix_ends.append(max(record.date_end.toordinal(), prefix_ends[-1] if prefix_ends else 0))
|
|
147
|
+
candidates, linked = [], set()
|
|
148
|
+
for a in sorted(left, key=lambda r: r.key):
|
|
149
|
+
if _ineligible(a, config):
|
|
150
|
+
continue
|
|
151
|
+
lower = a.date_start.toordinal() - config.max_days
|
|
152
|
+
upper = a.date_end.toordinal() + config.max_days
|
|
153
|
+
begin = bisect_left(prefix_ends, lower)
|
|
154
|
+
end = bisect_right(starts, upper)
|
|
155
|
+
for index in range(begin, end):
|
|
156
|
+
b = eligible_right[index]
|
|
157
|
+
if b.date_end.toordinal() < lower:
|
|
158
|
+
continue
|
|
159
|
+
ca, cb = _country(a, config), _country(b, config)
|
|
160
|
+
if ca and cb and ca != cb:
|
|
161
|
+
continue
|
|
162
|
+
gap = max(0, (a.date_start - b.date_end).days, (b.date_start - a.date_end).days)
|
|
163
|
+
km = distance_km(a, b)
|
|
164
|
+
if km > config.max_km:
|
|
165
|
+
continue
|
|
166
|
+
warnings = []
|
|
167
|
+
if not ca or not cb:
|
|
168
|
+
warnings.append("country_missing")
|
|
169
|
+
for side, record in (("left", a), ("right", b)):
|
|
170
|
+
if record.date_start != record.date_end or record.date_precision != 1:
|
|
171
|
+
warnings.append(f"{side}_date_uncertain")
|
|
172
|
+
if record.geo_precision != 1:
|
|
173
|
+
warnings.append(f"{side}_location_uncertain")
|
|
174
|
+
if record.date_basis != "reported":
|
|
175
|
+
warnings.append(f"{side}_date_interval_inferred")
|
|
176
|
+
score = 1 - (gap / (config.max_days + 1) + km / config.max_km) / 2
|
|
177
|
+
identifier = digest([seed, a.key, b.key])
|
|
178
|
+
candidates.append(Candidate(identifier, a, b, gap, km, score, tuple(warnings)))
|
|
179
|
+
linked.update((a.key, b.key))
|
|
180
|
+
candidates.sort(key=lambda c: (-c.score, c.left.key, c.right.key))
|
|
181
|
+
unlinked = tuple(
|
|
182
|
+
UnlinkedRecord(r, _ineligible(r, config) or "no_candidate")
|
|
183
|
+
for r in sorted((*left.records, *right.records), key=lambda r: r.key)
|
|
184
|
+
if r.key not in linked
|
|
185
|
+
)
|
|
186
|
+
return Reconciliation(left, right, config, tuple(candidates), unlinked)
|