fsi-anomaly / research /timeline.py
FerrellSyntheticIntelligence's picture
backup all: 100 files (batch)
76b78ee verified
Raw
History Blame Contribute Delete
5.27 kB
"""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()