← Files APODICTIC Development EditorARCHIVED FILE
scripts/timeline_checks.py
28.5 KB · Nov 3, 2026 · 18:57 UTC
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Real parser + verifier for the Pass 10 Timeline artifact (validate.sh timeline-* arms).
Implements the Phase 7 Timeline parser deferred in core-editor/references/pass-10.md
§Phase 7 Work Items:
* timeline-diff — structural diff (Section 1 Event Ledger rows + Section 3
Temporal Marker bullets) vs Section 8 Diff Notes coverage.
Faithful re-implementation of the bash arm.
* timeline-arithmetic — marker hygiene (negative gaps, pre-labeled conflicts) PLUS
TRUE arithmetic: a scene's computed end must not overrun the
next scene's computed start (unless flagged flashback/concurrent).
* timeline-anchor-conflict — pre-labeled conflict surfacing PLUS TRUE anchor-drift: the same
Scene ID resolved to two different absolute day anchors.
The arithmetic / anchor-drift detection is deliberately conservative: it only fires on rows
whose anchors normalize to a (day, time-of-day) / day number with HIGH/MEDIUM confidence and
that are not marked flashback / concurrent / ambiguous. Unparseable, LOW/UNCERTAIN, or
flashback rows are exempt — the model still owns the judgment for those.
CLI (called by validate.sh; degrades to the bash implementation when python3 is absent):
timeline_checks.py timeline-diff <prior> <current>
timeline_checks.py timeline-arithmetic <timeline>
timeline_checks.py timeline-anchor-conflict <timeline>
timeline_checks.py --self-test [<check-name>]
Output contract: WARN: / ERROR: / OK: line prefixes + exit code (0 ok, 1 fail, 2 usage),
matching the bash arms so callers and the release gate are unaffected.
"""
import os
import re
import sys
from override_marker import has_override # SSoT: state-machine code-span stripper + boundary-matched slug
# Time-of-day word -> hour-of-day. Order matters for substring precedence
# (check "late night" / "midnight" before "night", "midday" before "day").
_TOD_WORDS = [
("midnight", 0.0), ("dawn", 6.0), ("early morning", 6.0), ("morning", 8.0),
("midday", 12.0), ("noon", 12.0), ("midafternoon", 15.0), ("afternoon", 14.0),
("evening", 19.0), ("dusk", 20.0), ("late night", 23.0), ("night", 22.0),
]
# Duration unit -> hours.
_UNIT_HOURS = {
"minute": 1.0 / 60.0, "min": 1.0 / 60.0, "hour": 1.0, "hr": 1.0,
"day": 24.0, "week": 168.0, "month": 720.0, "year": 8760.0,
}
# Anchors carrying these tokens are exempt from arithmetic-overrun checks
# (legitimate non-linear structure the model owns).
_NONLINEAR_RE = re.compile(
r"(flashback|flash-forward|flashforward|concurrent|simultaneous|meanwhile|"
r"dream|POV[- ]split|out of order|nonlinear|non-linear)", re.IGNORECASE)
_LEVEL2_RE = re.compile(r"^##[^#]")
def _has_override(body, slug):
"""A genuine body override comment for `slug` — delegated to the shared `override_marker`.
The earlier `"<!-- override: <slug>" in body` substring test matched a suffixed slug and a
backtick'd documentation example. This had used a LOCAL `re.compile(r"```...```|`...`")` stripper,
but that hand-rolled parser was bypassable by a multi-backtick / `~~~`-fenced / multiline /
malformed-fence example (Codex P1). It now delegates to `override_marker.has_override` — the single
state-machine code-span stripper + boundary-matched slug — so timeline overrides can no longer be
activated by a documentation example in any CommonMark code-span form."""
return has_override(body, slug)
# --------------------------------------------------------------------------
# Shared parsing helpers.
# --------------------------------------------------------------------------
def _content_lines(text):
lines = text.split("\n")
if lines and lines[-1] == "":
lines = lines[:-1]
return lines
def _split_section8(text):
"""Return (body, section8) split at the first 'Section 8' heading (1-based,
Section 8 inclusive). Section 8 is the appendix-equivalent — markers there are
non-canonical, mirroring the bash arms."""
lines = _content_lines(text)
s8 = None
rx = re.compile(r"^#{1,4}.*Section 8", re.IGNORECASE)
for i, ln in enumerate(lines):
if rx.search(ln):
s8 = i
break
if s8 is None:
return "\n".join(lines), ""
return "\n".join(lines[:s8]), "\n".join(lines[s8:])
# Number parsing tolerant of comma thousands-separators. The bare `\d+`/`\d` digit runs below used to
# drop the leading groups under `.search()` — "1,000 days" -> 0.0, "Day 1,000" -> 1 — a false POSITIVE
# on a self-consistent timeline and a false NEGATIVE on a real conflict (the same blind spot fixed in
# world_bible.py, PR #135). The grouped alternative requires AT LEAST ONE ",ddd" group (`+`, not `*`)
# so an ungrouped multi-digit number can't be prefix-matched ("1000" stays "1000", never "100").
# Commas are stripped via _strip_grouping before float()/int().
_NUM_UNIT_RE = re.compile(r"(-?\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?)\s*([A-Za-z]+)")
_ANCHOR_NUM = r"(\d{1,3}(?:,\d{3})+|\d+)"
_ANCHOR_DAY_RE = re.compile(r"\bDay\s+" + _ANCHOR_NUM, re.IGNORECASE)
_ANCHOR_WEEK_RE = re.compile(r"\bWeek\s+" + _ANCHOR_NUM, re.IGNORECASE)
def _strip_grouping(num):
"""Drop comma thousands-separators from a captured numeral so float()/int() see the real
magnitude ('1,000' -> '1000'). Comma is the only grouping char authors use here; '.' is the
decimal point and is preserved."""
return num.replace(",", "")
def _norm_duration(s):
"""'3 hours' -> 3.0, '2 days' -> 48.0, '30 minutes' -> 0.5, '-2 days' -> -48.0;
None for 'n/a' / empty / unparseable. Comma-grouped counts ('1,000 days') parse correctly."""
if s is None:
return None
m = _NUM_UNIT_RE.search(s)
if not m:
return None
value = float(_strip_grouping(m.group(1)))
unit = m.group(2).lower().rstrip("s")
if unit not in _UNIT_HOURS:
return None
return value * _UNIT_HOURS[unit]
def _norm_anchor_day(*texts):
"""Best-effort absolute day number from any of the given anchor texts.
Recognizes 'Day N' and 'Week M' (-> day (M-1)*7+1). Returns int or None."""
for t in texts:
if not t:
continue
m = _ANCHOR_DAY_RE.search(t)
if m:
return int(_strip_grouping(m.group(1)))
for t in texts:
if not t:
continue
m = _ANCHOR_WEEK_RE.search(t)
if m:
return (int(_strip_grouping(m.group(1))) - 1) * 7 + 1
return None
def _norm_anchor_tod(*texts):
"""Best-effort hour-of-day from anchor texts: a time-of-day word or explicit
clock time ('14:30', '2pm', '9 am'). Returns float hours or None."""
for t in texts:
if not t:
continue
m = re.search(r"\b(\d{1,2}):(\d{2})\b", t)
if m:
return int(m.group(1)) + int(m.group(2)) / 60.0
m = re.search(r"\b(\d{1,2})\s*([ap])\.?m\.?\b", t, re.IGNORECASE)
if m:
h = int(m.group(1)) % 12
if m.group(2).lower() == "p":
h += 12
return float(h)
low = t.lower()
# Word-boundary match, not substring: "noon" must not match inside "afternoon"
# (and "morning" not inside a longer token). Order still resolves "early morning"
# before "morning" and "late night" before "night".
for word, hour in _TOD_WORDS:
if re.search(r"\b" + re.escape(word) + r"\b", low):
return hour
return None
def _confidence_ok(conf):
"""Arithmetic fires only on HIGH/MEDIUM (or unstated) confidence rows; LOW/UNCERTAIN
rows are exempt (the model flagged them as not firm)."""
if not conf:
return True
c = conf.strip().upper()
return c not in ("LOW", "UNCERTAIN")
def _parse_event_ledger(text):
"""Parse pipe-table rows whose header contains 'Scene ID' into dicts keyed by
normalized column name. Returns a list of row dicts (in document order)."""
rows = []
header = None
for ln in text.split("\n"):
if not ln.lstrip().startswith("|"):
header = None
continue
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if re.match(r"^\|?\s*:?-{3,}", ln.strip()) or all(set(c) <= set("-: ") for c in cells):
continue # alignment row
low = [c.lower() for c in cells]
if any("scene id" in c for c in low):
header = low
continue
if header is None:
continue
row = {}
for i, c in enumerate(cells):
if i < len(header):
row[header[i]] = c
rows.append(row)
return rows
def _row_get(row, *needles):
for key, val in row.items():
for n in needles:
if n in key:
return val
return None
def _parse_section3_anchors(text):
"""Map Scene ID -> set of distinct day numbers from Section 3 bullets like
'- Ch 1 §1: "Monday morning" -> Day 1' and from the Event Ledger."""
by_scene = {}
def add(scene, day):
if scene and day is not None:
by_scene.setdefault(scene.strip(), set()).add(day)
in_s3 = False
for ln in text.split("\n"):
if re.match(r"^##\s+.*Section 3", ln, re.IGNORECASE):
in_s3 = True
continue
if _LEVEL2_RE.match(ln):
in_s3 = False
if in_s3 and ln.lstrip().startswith("- "):
m = re.match(r"^\s*-\s*([^:]+):", ln)
if m:
add(m.group(1), _norm_anchor_day(ln))
for row in _parse_event_ledger(text):
# Honor the contract: LOW/UNCERTAIN Event Ledger rows are exempt from drift,
# parallel to the arithmetic check (an explicitly uncertain guess must not become
# a hard validator failure). Section 3 markers carry no confidence field, so they
# are always counted.
if not _confidence_ok(_row_get(row, "confidence")):
continue
scene = _row_get(row, "scene id")
add(scene, _norm_anchor_day(_row_get(row, "calculated date"),
_row_get(row, "anchor")))
return by_scene
# --------------------------------------------------------------------------
# Checks. Each returns (errors, warnings, ok_line, failed_line).
# --------------------------------------------------------------------------
_PASS10 = "core-editor/references/pass-10.md"
def timeline_arithmetic(text):
errors, warnings = [], []
body, _ = _split_section8(text)
ov = _has_override(body, "timeline-arithmetic-conflict")
# Marker hygiene (faithful to the bash arm) — body only.
neg_gaps = sum(1 for ln in body.split("\n")
if re.search(r"\|\s*-[0-9]+\s*(hours?|days?|minutes?|weeks?|months?|years?)",
ln, re.IGNORECASE))
conflict_markers = sum(1 for ln in body.split("\n")
if re.search(r"\|.*\((conflicts|contradicts)", ln, re.IGNORECASE))
# True arithmetic: a scene's computed end must not overrun the next scene's
# computed start, unless the next scene is flagged non-linear.
overruns = 0
rows = _parse_event_ledger(body)
parsed = []
for row in rows:
day = _norm_anchor_day(_row_get(row, "calculated date"), _row_get(row, "anchor"))
tod = _norm_anchor_tod(_row_get(row, "calculated time"), _row_get(row, "anchor"))
span = _norm_duration(_row_get(row, "span"))
conf_ok = _confidence_ok(_row_get(row, "confidence"))
anchor_txt = " ".join(filter(None, [_row_get(row, "anchor"),
_row_get(row, "calculated date")]))
nonlinear = bool(_NONLINEAR_RE.search(anchor_txt))
start = day * 24.0 + tod if (day is not None and tod is not None) else None
parsed.append({"start": start, "span": span, "conf_ok": conf_ok, "nonlinear": nonlinear})
for i in range(1, len(parsed)):
prev, cur = parsed[i - 1], parsed[i]
# Exempt the pair if EITHER side is non-linear (a flashback/concurrent row is
# exempt whether it is the current row or the predecessor used for overrun math)
# or either side is LOW/UNCERTAIN confidence.
if (cur["nonlinear"] or prev["nonlinear"]
or not cur["conf_ok"] or not prev["conf_ok"]):
continue
if prev["start"] is None or cur["start"] is None or prev["span"] is None:
continue
if cur["start"] < prev["start"] + prev["span"] - 1e-9:
overruns += 1
total = neg_gaps + conflict_markers + overruns
if total == 0:
return [], [], ("OK: Timeline arithmetic consistent (no negative gaps, no pre-labeled "
"conflicts, no span overruns)."), ""
detail = ("%d negative gap(s); %d pre-labeled conflict(s); %d computed span overrun(s)"
% (neg_gaps, conflict_markers, overruns))
if ov:
warnings.append("WARN: %d timeline-arithmetic candidate(s) detected (%s); body override "
"marker present." % (total, detail))
else:
errors.append("ERROR: %d timeline-arithmetic candidate(s) surfaced (%s). Surface each in "
"Section 4 (Inconsistency Ledger), correct the spans/anchors, or place a body "
"override marker <!-- override: timeline-arithmetic-conflict — <reason> --> "
"above Section 8. Canonical home: %s §Section 4." % (total, detail, _PASS10))
return errors, warnings, "", ("FAILED: timeline-arithmetic — %s." % detail)
def timeline_anchor_conflict(text):
errors, warnings = [], []
body, _ = _split_section8(text)
ov = _has_override(body, "timeline-anchor-conflict")
candidates = sum(1 for ln in body.split("\n")
if re.search(r"\((contradicts|paradox with|conflicts with)", ln, re.IGNORECASE))
drift = 0
drift_scenes = []
for scene, days in _parse_section3_anchors(body).items():
if len(days) >= 2:
drift += 1
drift_scenes.append("%s -> Day %s" % (scene, "/".join(str(d) for d in sorted(days))))
total = candidates + drift
if total == 0:
return [], [], "OK: No anchor conflicts (no pre-labeled candidates, no same-scene anchor drift).", ""
detail = "%d pre-labeled candidate(s); %d same-scene anchor drift(s)" % (candidates, drift)
if drift_scenes:
detail += " [" + "; ".join(drift_scenes) + "]"
if ov:
warnings.append("WARN: %d anchor-conflict candidate(s) surfaced (%s); body override "
"marker present." % (total, detail))
else:
errors.append("ERROR: %d anchor-conflict candidate(s) surfaced (%s). Classify each in "
"Section 4 (Inconsistency Ledger), reconcile the anchors, or place a body "
"override marker <!-- override: timeline-anchor-conflict — <reason> --> above "
"Section 8. Canonical home: %s §Section 4." % (total, detail, _PASS10))
return errors, warnings, "", ("FAILED: timeline-anchor-conflict — %s." % detail)
def _diff_event_rows(text):
out = set()
for ln in text.split("\n"):
if not ln.startswith("|"):
continue
if re.match(r"^\|\s*---", ln) or re.match(r"^\|\s*Scene ID", ln):
continue
out.add(ln)
return out
def _diff_section3_markers(text):
out, in_s3 = set(), False
for ln in text.split("\n"):
if re.match(r"^##\s+.*Section 3", ln):
in_s3 = True
continue
if re.match(r"^##\s", ln):
in_s3 = False
if in_s3 and ln.startswith("- "):
out.add(ln)
return out
def timeline_diff(prior_text, current_text):
errors, warnings = [], []
body, section8 = _split_section8(current_text)
ov = _has_override(body, "timeline-diff-undocumented")
prior_rows, cur_rows = _diff_event_rows(prior_text), _diff_event_rows(current_text)
prior_s3, cur_s3 = _diff_section3_markers(prior_text), _diff_section3_markers(current_text)
added = len(cur_rows - prior_rows)
removed = len(prior_rows - cur_rows)
s3_added = len(cur_s3 - prior_s3)
s3_removed = len(prior_s3 - cur_s3)
diff_total = added + removed + s3_added + s3_removed
exp_added, exp_removed = added + s3_added, removed + s3_removed
ok_summary = ("OK: No structural diff between prior and current Timeline (Section 1 + "
"Section 3 checked).")
if diff_total == 0:
return [], [], ok_summary, ""
counts = ("%d change(s); §1: %d added, %d removed; §3: %d added, %d removed"
% (diff_total, added, removed, s3_added, s3_removed))
# Coverage gate (per diff class). Section 8 must actually cover the structural diff:
# added rows need 'Added'/'Anchors added' bullets, removed rows need 'Removed'/'Anchors
# removed'. A row *edit* shows up in the line-level structural diff as one added + one
# removed line, so a 'Changed'/'Anchors changed'/'Calculations changed' bullet documents
# one add+remove pair. Generic 'Changed' text can no longer mask a pure addition or
# removal (a 'Changed' bullet only offsets a matched add/remove pair).
s8 = section8.split("\n")
doc_added = sum(1 for ln in s8
if re.match(r"^[-*]\s+(Added|Events? added|Anchors? added)", ln, re.IGNORECASE))
doc_removed = sum(1 for ln in s8
if re.match(r"^[-*]\s+(Removed|Events? removed|Anchors? removed)", ln, re.IGNORECASE))
doc_changed = sum(1 for ln in s8
if re.match(r"^[-*]\s+(Changed|Events? changed|Anchors? changed|"
r"Calculations? changed)", ln, re.IGNORECASE))
changed_applied = min(doc_changed, min(exp_added, exp_removed)) # edit pairs
need_added = exp_added - changed_applied
need_removed = exp_removed - changed_applied
if doc_added >= need_added and doc_removed >= need_removed:
return [], [], "OK: Diff detected (%s) and documented in Section 8." % counts, ""
msg = ("Section 8 documentation does not cover the structural diff: need %d added / %d "
"removed bullet(s) (have %d added, %d removed, %d changed) for §1+§3 totals "
"%d added / %d removed"
% (need_added, need_removed, doc_added, doc_removed, doc_changed, exp_added, exp_removed))
if ov:
warnings.append("WARN: %s; body override marker present." % msg)
return errors, warnings, "OK: override (under-documented diff).", ""
errors.append("ERROR: %s. Document each class in Section 8 (added rows need 'Added', removed "
"rows need 'Removed', a row edit may be one 'Changed'), or place a body override "
"marker <!-- override: timeline-diff-undocumented — <reason> --> above Section 8. "
"Canonical home: %s §Section 8." % (msg, _PASS10))
return errors, warnings, "", ("FAILED: timeline-diff — %s." % msg)
SINGLE_FILE_CHECKS = {
"timeline-arithmetic": timeline_arithmetic,
"timeline-anchor-conflict": timeline_anchor_conflict,
}
def _emit(errors, warnings, ok_line, failed_line):
for w in warnings:
print(w)
for e in errors:
print(e)
if errors:
if failed_line:
print("")
print(failed_line)
return 1
if ok_line:
print(ok_line)
return 0
def _read(path):
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
# --------------------------------------------------------------------------
# Self-test.
# --------------------------------------------------------------------------
def _fixture_dir():
"""Validator fixtures live in the repo at tests/fixtures/validators/.
Returns that path whenever the repo tests/ dir is found, even if the
fixtures dir is missing, so a moved dir fails the self-test. Returns None
in an installed plugin (no repo).
"""
tests = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "tests")
return os.path.join(tests, "fixtures", "validators") if os.path.isdir(tests) else None
def run_self_test(which=None):
rc = {"v": 0}
def expect(name, got, want):
if got == want:
print(" %s: OK" % name)
else:
print(" %s: FAIL (got %s, expected %s)" % (name, got, want))
rc["v"] = 1
# Single-file fixtures: tl.<pass|fail>.<check>.<name>.md
fdir = _fixture_dir()
if fdir and not os.path.isdir(fdir):
expect("fixtures-dir-present", False, True)
elif fdir:
for fn in sorted(os.listdir(fdir)):
if not fn.startswith("tl.") or not fn.endswith(".md"):
continue
parts = fn.split(".")
chk = parts[2] if len(parts) > 3 else ""
if chk not in SINGLE_FILE_CHECKS:
continue
if which not in (None, chk):
continue
errs = SINGLE_FILE_CHECKS[chk](_read(os.path.join(fdir, fn)))[0]
want_clean = parts[1] == "pass"
expect("fixture:%s" % fn, len(errs) == 0, want_clean)
# timeline-diff: in-code prior/current pairs (two-file check).
if which in (None, "timeline-diff"):
import tempfile
prior = ("# Timeline\n## Section 1: Event Ledger\n| Scene ID | Anchor text | Span |\n"
"|---|---|---|\n| Ch 1 §1 | Monday morning | 3 hours |\n"
"| Ch 1 §2 | Tuesday afternoon | 2 hours |\n## Section 8: Diff Notes\nn/a.\n")
cur_same = prior.replace("n/a.", "n/a — no changes.")
added_row = "| Ch 2 §1 | Wednesday morning | 1 hour |\n## Section 8: Diff Notes\n"
cur_undoc = prior.replace("## Section 8: Diff Notes\n", added_row).replace("n/a.", "n/a — no changes.")
cur_doc = prior.replace("## Section 8: Diff Notes\n", added_row).replace(
"n/a.", "- Added: Ch 2 §1 (Wednesday morning) — new scene.")
cur_over = ("# Timeline\n<!-- override: timeline-diff-undocumented — deferred. -->\n"
+ prior.split("\n", 1)[1].replace("## Section 8: Diff Notes\n", added_row).replace(
"n/a.", "n/a — no changes."))
def diff_rc(p, c):
with tempfile.TemporaryDirectory() as td:
pp = os.path.join(td, "p.md"); open(pp, "w", encoding="utf-8", newline="").write(p)
cc = os.path.join(td, "c.md"); open(cc, "w", encoding="utf-8", newline="").write(c)
return _emit(*timeline_diff(_read(pp), _read(cc)))
# Generic 'Changed' text must NOT cover a pure addition (review fix).
cur_changed_masks = prior.replace("## Section 8: Diff Notes\n", added_row).replace(
"n/a.", "- Changed: refreshed timeline wording only.")
# A genuine row edit (span 2h->3h = one added + one removed line) IS covered by
# a single 'Changed' bullet.
cur_edit = prior.replace("| Ch 1 §2 | Tuesday afternoon | 2 hours |",
"| Ch 1 §2 | Tuesday afternoon | 3 hours |").replace(
"n/a.", "- Changed: Ch 1 §2 span 2h->3h.")
expect("diff_no_change", diff_rc(prior, cur_same), 0)
expect("diff_undocumented", diff_rc(prior, cur_undoc), 1)
expect("diff_documented", diff_rc(prior, cur_doc), 0)
expect("diff_override_body", diff_rc(prior, cur_over), 0)
# 2026-06-20 override-parse hardening: a SUFFIXED slug must NOT be accepted as the override
cur_over_suffixed = cur_over.replace("timeline-diff-undocumented —", "timeline-diff-undocumented-later —")
expect("diff_override_suffixed_slug_rejected", diff_rc(prior, cur_over_suffixed), 1)
# a marker inside a backtick code span (a documentation example) must NOT count as a live override
cur_over_backtick = cur_over.replace(
"<!-- override: timeline-diff-undocumented — deferred. -->",
"Use `<!-- override: timeline-diff-undocumented -->` to defer.")
expect("diff_override_in_backticks_rejected", diff_rc(prior, cur_over_backtick), 1)
# migration to override_marker (Codex P1): a TILDE-fenced and a MULTI-backtick documentation
# example must also be rejected (the old local stripper only handled ``` and single backticks)
cur_over_tilde = cur_over.replace(
"<!-- override: timeline-diff-undocumented — deferred. -->",
"~~~\n<!-- override: timeline-diff-undocumented -->\n~~~")
expect("diff_override_in_tilde_fence_rejected", diff_rc(prior, cur_over_tilde), 1)
cur_over_mbtick = cur_over.replace(
"<!-- override: timeline-diff-undocumented — deferred. -->",
"``<!-- override: timeline-diff-undocumented -->``")
expect("diff_override_in_multibacktick_rejected", diff_rc(prior, cur_over_mbtick), 1)
expect("diff_changed_masks_add", diff_rc(prior, cur_changed_masks), 1)
expect("diff_edit_changed_covers", diff_rc(prior, cur_edit), 0)
# Comma thousands-separator parsing — the same blind spot fixed in world_bible.py (#135) lived
# in this validator too. Each assertion below FAILS against pre-fix code.
if which in (None, "timeline-arithmetic", "timeline-anchor-conflict"):
# Unit: grouped numerals parse to full magnitude; ungrouped multi-digit numbers are UNCHANGED
# (the grouped alternative requires a comma, so "1000" never prefix-matches down to 100).
expect("comma_duration_thousands", _norm_duration("1,000 days"), 1000 * 24.0) # pre-fix: 0.0
expect("comma_duration_ungrouped_unchanged", _norm_duration("1000 days"), 1000 * 24.0)
expect("comma_anchor_day", _norm_anchor_day("Day 1,000"), 1000) # pre-fix: 1
expect("comma_anchor_week", _norm_anchor_day("Week 1,000"), (1000 - 1) * 7 + 1) # pre-fix: 1
expect("comma_anchor_ungrouped_unchanged", _norm_anchor_day("Day 1000"), 1000)
# Arm-level: an agree-clean timeline must NOT flag (pre-fix: false POSITIVE) and a genuine
# conflict MUST flag (pre-fix: false NEGATIVE).
anchor_clean = ('# Timeline\n## Section 3: Scene Anchors\n'
'- Ch 2 §1: "the thousandth dawn" -> Day 1,000\n'
'- Ch 2 §1: "a thousand days in" -> Day 1000\n') # same day, comma vs not
anchor_conflict = ('# Timeline\n## Section 3: Scene Anchors\n'
'- Ch 3 §2: "the thousandth dawn" -> Day 1,000\n'
'- Ch 3 §2: "five days later, same scene" -> Day 1,005\n') # genuinely different days
expect("anchor_comma_consistent_clean", _emit(*timeline_anchor_conflict(anchor_clean)), 0)
expect("anchor_comma_conflict_flagged", _emit(*timeline_anchor_conflict(anchor_conflict)), 1)
_tbl = ("# Timeline\n## Section 1: Event Ledger\n"
"| Scene ID | Anchor text | Span |\n|---|---|---|\n")
arith_clean = _tbl + ("| Ch 1 §1 | Day 1,000 0:00 | 10 hours |\n"
"| Ch 1 §2 | Day 1,001 0:00 | 1 hour |\n") # sequential days, no overrun
arith_conflict = _tbl + ("| Ch 1 §1 | Day 1 0:00 | 1,000 hours |\n"
"| Ch 1 §2 | Day 2 0:00 | 1 hour |\n") # 1,000h span overruns Day 2
expect("arith_comma_anchor_sequential_clean", _emit(*timeline_arithmetic(arith_clean)), 0)
expect("arith_comma_span_overrun_flagged", _emit(*timeline_arithmetic(arith_conflict)), 1)
if rc["v"] == 0:
print("Self-test: PASS")
else:
print("Self-test: FAIL")
return rc["v"]
def main(argv):
if len(argv) < 2:
sys.stderr.write("Usage: timeline_checks.py <timeline-diff|timeline-arithmetic|"
"timeline-anchor-conflict|--self-test> ...\n")
return 2
if argv[1] == "--self-test":
return run_self_test(argv[2] if len(argv) > 2 else None)
if argv[1] == "timeline-diff":
if len(argv) < 4:
sys.stderr.write("Usage: timeline_checks.py timeline-diff <prior> <current>\n")
return 2
for p in (argv[2], argv[3]):
if not os.path.isfile(p):
sys.stderr.write("Error: File not found: %s\n" % p)
return 2
return _emit(*timeline_diff(_read(argv[2]), _read(argv[3])))
if argv[1] in SINGLE_FILE_CHECKS:
if len(argv) < 3:
sys.stderr.write("Usage: timeline_checks.py %s <timeline>\n" % argv[1])
return 2
if not os.path.isfile(argv[2]):
sys.stderr.write("Error: File not found: %s\n" % argv[2])
return 2
return _emit(*SINGLE_FILE_CHECKS[argv[1]](_read(argv[2])))
sys.stderr.write("Error: unknown command: %s\n" % argv[1])
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))
SHA-256: 214888e0cd073d64e6503e6bbe3781352c182e16613734a99ffcc4abf886db68