"""Timeline reconstruction + gap detection (journalism suite layer 2). A good investigation reads the ABSENCES as much as the events. This module sorts dated events, measures intervals, and surfaces: - gaps: intervals that exceed a heuristic threshold (2x median, >= 1 year) - cliffs: active years whose neighbors are active but themselves silent - anachronisms: an event whose text cites a year different from its date - density: per-year event counts (where did the reporting thin out?) Deterministic only — the model reasons over the surfaced gaps; the suite never invents the missing event. Usage: from research.timeline import TimelineAnalyzer tl = TimelineAnalyzer() tl.add_event("2010-05-01", "bridge opens per DOT filing", "s1") tl.report() """ import re from collections import defaultdict from dataclasses import dataclass, field _YEAR = re.compile(r"\b(19|20)\d{2}\b") @dataclass class Event: when: str # ISO date YYYY-MM-DD or year YYYY what: str source_id: str = "-" def date(self): """Sortable key: YYYY -> YYYY-01-01; ISO kept as-is.""" if len(self.when) == 4 and self.when.isdigit(): return f"{self.when}-01-01" return self.when class TimelineAnalyzer: def __init__(self): self.events = [] def add_event(self, when, what, source_id="-"): self.events.append(Event(when=when, what=what, source_id=source_id)) def sorted(self): return sorted(self.events, key=lambda e: e.date()) def years(self): out = defaultdict(int) for e in self.events: out[e.date()[:4]] += 1 return dict(sorted(out.items())) def _gaps_raw(self, gap_min_days=None): """(start_date, end_date, days) for each interval above threshold.""" ev = self.sorted() if len(ev) < 2: return [] deltas = [] for a, b in zip(ev, ev[1:]): try: deltas.append((_days(b.date()) - _days(a.date()), a, b)) except ValueError: continue if not deltas: return [] median = sorted(d for d, _, _ in deltas)[len(deltas) // 2] floor = gap_min_days or max(365, 2 * median) return [(a, b, d) for d, a, b in deltas if d > floor] def gaps(self, gap_min_days=None): """Gap cards: missing period + the bookend events + absent line.""" out = [] for a, b, days in self._gaps_raw(gap_min_days): out.append({ "from": a.date(), "to": b.date(), "days": days, "between": [a.what, b.what], "absent": f"no recorded event between {a.date()} and {b.date()} " f"({days} days) - what happened there?", }) return out def cliffs(self): """Years silent while both neighbors have events (missing period).""" ys = self.years() if len(ys) < 2: return [] lo, hi = int(min(ys)), int(max(ys)) out = [] for y in range(lo, hi + 1): yk = str(y) if ys.get(yk, 0) == 0 and ys.get(str(y - 1), 0) and ys.get(str(y + 1), 0): out.append({"year": yk, "before": str(y - 1), "after": str(y + 1), "absent": f"year {yk} is silent between active " f"years {y - 1} and {y + 1}"}) return out def anachronisms(self): """Event text cites a year that differs from its own date year.""" out = [] for e in self.events: cited = set(_YEAR.findall(e.what)) if cited and e.date()[:4] not in cited: out.append({"when": e.date(), "what": e.what, "cited_years": sorted(cited), "flag": "cited year != event date year"}) return out def report(self, gap_min_days=None): lines = ["# Timeline", ""] lines.append("| date | event | source |") lines.append("|---|---|---|") for e in self.sorted(): lines.append(f"| {e.date()} | {e.what} | {e.source_id} |") lines.append("") lines.append("## Density (events/year)") for y, n in self.years().items(): lines.append(f"- {y}: {n}") lines.append("") lines.append("## Gaps (what is absent)") gaps = self.gaps(gap_min_days) for g in gaps: lines.append(f"- {g['absent']}") lines.append(f" - between: {g['between'][0]} | {g['between'][1]}") if not gaps: lines.append("- no gaps above threshold") lines.append("") lines.append("## Cliffs & anachronisms") for c in self.cliffs(): lines.append(f"- {c['absent']}") for a in self.anachronisms(): lines.append(f"- `{a['when']}` {a['what']} -> cites {a['cited_years']} " f"({a['flag']})") if not self.cliffs() and not self.anachronisms(): lines.append("- none") return "\n".join(lines) def _days(iso): from datetime import date y, m, d = (int(x) for x in iso.split("-")) return date(y, m, d).toordinal()