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/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
@@ -0,0 +1,5 @@
1
+ from ccrt.loaders.acled import load_acled_csv
2
+ from ccrt.loaders.base import ValidationError, load_csv
3
+ from ccrt.loaders.ucdp import load_ucdp_csv
4
+
5
+ __all__ = ["ValidationError", "load_csv", "load_ucdp_csv", "load_acled_csv"]
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)