Files
Edward Cheng-I Wu c04c18418c fix(v3.9.4.1): hotfix for 4 codex post-ship findings + docs align (#135) (#145)
* fix(v3.9.4.1): citation_provenance.schema.json confidence:high requires presence (#135 codex post-ship P2)

The v3.9.4 allOf used `then.properties` only, so an entry with confidence:high but
crossref_issued and pdftotext_cover_first_line absent silently passed validation
(JSON Schema 'properties' constraints don't fire on absent properties).

v3.9.4.1 adds `then.required: [crossref_issued, pdftotext_cover_first_line]` so the
schema enforces presence in addition to the existing non-null check. Negative test
added: confidence:high + both source fields absent now rejects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.9.4.1): _date_to_interval parses all schema-valid date shapes (#135 codex post-ship P1)

v3.9.4 verifier only parsed 3 prose-friendly forms (YYYY-MM-DD / 'MonthName YYYY' / YYYY).
Schema-valid shapes YYYY-MM (month precision, what Crossref returns) and
YYYY-MM-DD..YYYY-MM-DD (interval precision, used by effective_date_range) raised
ValueError, and P2/P4 silently skipped the check via 'except ValueError: continue'.

v3.9.4.1 adds explicit parse paths for both forms. Now bootstrap_timeline_yaml.py
month-precision Crossref output and timeline.yaml interval entries actually flow
through the verifier predicates instead of silently skipping. Defensive check on
month > 12 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.9.4.1): wire citation_provenance through P2/P4 + direct-date P4 binding (#135 codex post-ship P1 + P2)

Two fixes paired in this commit since both touch P4's binding logic:

Fix #1 (P1, spec §3.4): audit() now passes citation_provenance through to P2 and P4.
When a ref slug has confidence:low or conflict in citation_provenance.yaml, the verifier
emits TEMPORAL-METADATA-MISSING instead of using the timeline dates for arithmetic.
v3.9.4 dropped citation_provenance on the floor — first-party safety check was broken.

Fix #3 (P2, spec §3.2 P4): P4 now binds each side of a causal trigger to either a
<!--ref:slug--> marker OR a direct date capture in the sentence. v3.9.4 required refs
on both sides, silently dropping any sentence with bare dates like 'The 2026 policy
enabled the 2020 rollout.' bound_dates.source records the binding mode per side
(timeline_ref vs draft_capture); bound_refs is empty when both sides came from
direct date capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release(v3.9.4.1): ARCHITECTURE.md align + suite version bump + CHANGELOG (#135)

ARCHITECTURE.md was stale at v3.8.0 — 6 minor versions behind. Updated:
- Header: (v3.8.0) → (v3.9.4.1)
- Section 9 Skill Modes table: deep-research v2.9.2→v2.9.4 / academic-paper v3.1.1→v3.1.2 /
  academic-paper-reviewer v1.9.0→v1.9.1 / academic-pipeline v3.8.0→v3.9.4.1 (added v3.9.4
  temporal verification advisory layer summary)
- Section 8 Evolution Timeline: filled in v3.8.1 / v3.8.2 / v3.9.0 / v3.9.1 / v3.9.2 /
  v3.9.3 / v3.9.4 / v3.9.4.1 entries (was stopping at v3.8.0)

Suite version bumped 3.9.4 → 3.9.4.1 across version-bearing surfaces:
- MODE_REGISTRY.md Last updated line
- README.md + README.zh-TW.md version badge + tag URL + section heading + new v3.9.4.1 entry
- academic-pipeline/SKILL.md frontmatter version + last_updated
- .claude-plugin/plugin.json version
- .claude/CLAUDE.md skill suite table + Suite version line
- scripts/check_spec_consistency.py expected-text constants (8 needles)
- CHANGELOG.md v3.9.4.1 entry above existing v3.9.4

Phase boundary marker (v3.9.4) intentionally NOT renamed — historical version markers
in agent docs and lint regex are version-locked by design; renaming would break the
v3.9.4 boundary lint that v3.9.4.1 inherits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:17:22 +08:00

841 lines
37 KiB
Python
Executable File

#!/usr/bin/env python3
"""v3.9.4 Phase 4 → 5 boundary temporal integrity verifier.
5 passes per spec §3.2:
P1 — Mode 1 future-as-past arithmetic (TEMPORAL-ARITHMETIC-IMPOSSIBLE)
P2 — Mode 2 version-as-evidence-past anachronism (TEMPORAL-ANACHRONISTIC-CITATION)
P3 — Mode 3 comparator unmaterialized (TEMPORAL-COMPARATOR-UNMATERIALIZED)
P4 — Mode 4 causal inversion (TEMPORAL-CAUSAL-INVERSION)
P5 — Mode 5 time-bomb deictic (TEMPORAL-DEICTIC)
+ TEMPORAL-METADATA-MISSING surfacing where ground truth is unavailable.
All findings advisory in v3.9.4 (CC1).
Inputs: finalized draft markdown with v3.7.3 <!--ref:slug--> markers, timeline.yaml, citation_provenance.yaml.
Output: phase4_composition/temporal_audit_results.yaml (machine-readable) + .md (human-readable, Task 17).
This Task 9 ships the scaffold. P1-P5 implementations land in Tasks 10-16. Markdown output in Task 17.
"""
from __future__ import annotations
import argparse
import re
import sys
from datetime import date
from pathlib import Path
import yaml
DEICTIC_PATTERN = re.compile(
r"\b(currently|now|at present|most recent|the latest|new(?:est)?|recently|"
r"last\s+year|this\s+year|nowadays|presently|today|emerging|recent\s+cycle|"
r"latest\s+available)\b",
re.IGNORECASE,
)
MONTH_NAMES = "January|February|March|April|May|June|July|August|September|October|November|December"
DATE_REGEX = (
r"\d{4}-\d{2}-\d{2}"
r"|(?:" + MONTH_NAMES + r")\s+\d{4}"
r"|(?:19|20)\d{2}"
)
PATTERN_A = re.compile(
r"(?:as of|on|in|reported in|stated in|noted in)\s+"
r"(?P<anchor>" + DATE_REGEX + r")"
r".*?\b(?:had already|already|completed|finished|delivered)\b.*?"
r"(?P<event>" + DATE_REGEX + r")",
re.IGNORECASE | re.DOTALL,
)
PATTERN_B = re.compile(
r"(?P<event>" + DATE_REGEX + r")"
r".*?\b(?:will be|to be|scheduled for|forthcoming|upcoming|planned)\b.*?"
r"(?:as of|in|by)\s+"
r"(?P<anchor>" + DATE_REGEX + r")",
re.IGNORECASE | re.DOTALL,
)
REF_MARKER_PATTERN = re.compile(r"<!--ref:([A-Za-z][A-Za-z0-9_:-]*)-->")
COMPARATOR_FORM_A = re.compile(
r"(?P<adj>prior|previous|earlier|older|preceding)\s+"
r"(?P<noun>edition|version|edition\s+\(\d{4}\)|version\s+\(\d{4}\))",
re.IGNORECASE,
)
COMPARATOR_FORM_B = re.compile(
r"\b(?P<year>(?:19|20)\d{2})\s+"
r"(?P<noun>edition|version|standard|handbook|guideline)\b",
re.IGNORECASE,
)
COMPARATOR_FORM_C = re.compile(
r"(?P<noun>edition|version|standard)\s+(?:of|from)\s+"
r"(?P<year>(?:19|20)\d{2})\b",
re.IGNORECASE,
)
# 8 verb-to-required-ordering triggers per spec §3.2 P4
CAUSAL_TRIGGERS = [
(re.compile(r"\benabled\b", re.IGNORECASE), "left<right"),
(re.compile(r"\bcaused\b", re.IGNORECASE), "left<right"),
(re.compile(r"\bled\s+to\b", re.IGNORECASE), "left<right"),
(re.compile(r"\bin\s+response\s+to\b", re.IGNORECASE), "left>right"),
(re.compile(r"\bsuperseded\b", re.IGNORECASE), "left>right"),
(re.compile(r"\bpreceded\b", re.IGNORECASE), "left<right"),
(re.compile(r"\bfollowed\s+by\b", re.IGNORECASE), "left<right"),
(re.compile(r"\bfollowed\b(?!\s+by)", re.IGNORECASE), "left>right"),
]
MONTH_TO_NUM = {name.lower(): f"{i+1:02d}" for i, name in enumerate(MONTH_NAMES.split("|"))}
LAST_DAY = {"01": "31", "02": "28", "03": "31", "04": "30", "05": "31", "06": "30",
"07": "31", "08": "31", "09": "30", "10": "31", "11": "30", "12": "31"}
def _date_to_interval(raw: str) -> tuple[str, str]:
"""Normalize a date capture into (start, end) ISO 8601 day strings.
Handles all v3.9.4 schema-valid date shapes:
- YYYY-MM-DD → (date, date) point interval (day precision)
- YYYY-MM → YYYY-MM-01 .. YYYY-MM-last (month precision, v3.9.4.1 hotfix)
- YYYY-MM-DD..YYYY-MM-DD → parsed interval (interval precision, v3.9.4.1 hotfix)
- 'MonthName YYYY' → first of month .. last of month (prose form)
- YYYY → YYYY-01-01 .. YYYY-12-31 (year precision)
v3.9.4 only handled 3 prose forms — Crossref month-precision lookups from
bootstrap_timeline_yaml.py emit `YYYY-MM`, and effective_date_range with
precision:interval emits `YYYY-MM-DD..YYYY-MM-DD`. Both were schema-valid
but raised ValueError here, causing P2/P4 to silently skip the check.
"""
raw = raw.strip()
# Day precision: YYYY-MM-DD
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
return raw, raw
# Interval precision: YYYY-MM-DD..YYYY-MM-DD (v3.9.4.1 hotfix)
m_interval = re.fullmatch(r"(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})", raw)
if m_interval:
return m_interval.group(1), m_interval.group(2)
# Month precision: YYYY-MM (v3.9.4.1 hotfix)
m_month = re.fullmatch(r"(\d{4})-(\d{2})", raw)
if m_month:
yr = m_month.group(1)
mo = m_month.group(2)
if mo not in LAST_DAY:
raise ValueError(f"invalid month in date: {raw!r}")
return f"{yr}-{mo}-01", f"{yr}-{mo}-{LAST_DAY[mo]}"
# Prose form: "MonthName YYYY"
m = re.fullmatch(r"(" + MONTH_NAMES + r")\s+(\d{4})", raw, re.IGNORECASE)
if m:
mo = MONTH_TO_NUM[m.group(1).lower()]
yr = m.group(2)
return f"{yr}-{mo}-01", f"{yr}-{mo}-{LAST_DAY[mo]}"
# Year precision: YYYY
if re.fullmatch(r"(?:19|20)\d{2}", raw):
return f"{raw}-01-01", f"{raw}-12-31"
raise ValueError(f"unrecognized date format: {raw!r}")
def _date_diff_days(a: str, b: str) -> int:
"""Days between two YYYY-MM-DD strings (a - b)."""
da = date.fromisoformat(a)
db = date.fromisoformat(b)
return (da - db).days
def _sentence_around(draft: str, char_pos: int) -> str:
"""Extract the sentence containing char_pos."""
pre = draft[:char_pos]
post = draft[char_pos:]
# Find last sentence terminator before char_pos
m_pre = re.search(r"[.!?]\s+(?=\S)", pre[::-1])
start = char_pos - m_pre.start() if m_pre else 0
# Find next sentence terminator at/after char_pos
m_post = re.search(r"[.!?](\s|$)", post)
end = char_pos + m_post.end() if m_post else len(draft)
return draft[start:end].strip()
def _next_finding_id(findings: list[dict]) -> int:
"""Compute the next sequential TF-NNN id (1-indexed) from existing findings."""
counter = [int(f["finding_id"].split("-")[1]) for f in findings] or [0]
return max(counter) + 1
def _compute_line_number(draft: str, char_pos: int) -> int:
"""Return 1-indexed line number of char_pos in draft."""
if char_pos <= 0:
return 1
return draft[:char_pos].count("\n") + 1
def _provenance_confidence(slug: str, citation_provenance: dict) -> str | None:
"""v3.9.4.1 hotfix: look up citation_provenance.entries[*].confidence for a ref slug.
Returns the confidence string ('high' | 'medium' | 'low' | 'conflict') or None
if the slug has no provenance entry (treated as 'not first-party verified').
Per spec §3.4: when confidence is 'low' or 'conflict', P2/P4 must NOT use the
timeline dates for arithmetic; instead emit TEMPORAL-METADATA-MISSING. v3.9.4
audit() never threaded citation_provenance through to P2/P4, defeating this
safety check.
"""
if not citation_provenance:
return None
for entry in citation_provenance.get("entries", []):
if entry.get("citation_key") == slug:
return entry.get("confidence")
return None
def _pass_1_arithmetic(draft: str, findings: list[dict]) -> None:
"""P1 Mode 1 future-as-past arithmetic.
Pattern A: retrospective claim '(as of X) ... had already (Y)'; violation when event Y > anchor X.
Pattern B: prospective claim 'X (will be) ... (as of Y)'; violation when event X <= anchor Y.
If multiple violations match in the same sentence, emit the one with the largest
|event.start - anchor.end| gap (most clearly impossible).
"""
pos = 0
for sentence in re.split(r"(?<=[.!?])\s+", draft):
sentence_start = draft.find(sentence, pos)
if sentence_start == -1:
sentence_start = pos
line_no = _compute_line_number(draft, sentence_start)
pos = sentence_start + len(sentence)
violations = []
m_a = PATTERN_A.search(sentence)
if m_a:
anchor_raw = m_a.group("anchor")
event_raw = m_a.group("event")
try:
anchor_start, anchor_end = _date_to_interval(anchor_raw)
event_start, event_end = _date_to_interval(event_raw)
except ValueError:
pass
else:
if event_start > anchor_end:
violations.append(("A", anchor_raw, event_raw,
anchor_start, anchor_end, event_start, event_end))
m_b = PATTERN_B.search(sentence)
if m_b:
event_raw = m_b.group("event")
anchor_raw = m_b.group("anchor")
try:
anchor_start, anchor_end = _date_to_interval(anchor_raw)
event_start, event_end = _date_to_interval(event_raw)
except ValueError:
pass
else:
# Pattern B violation: forthcoming event already past at anchor time
if event_start <= anchor_end:
violations.append(("B", anchor_raw, event_raw,
anchor_start, anchor_end, event_start, event_end))
if not violations:
continue
# Emit one finding per sentence — pick the largest-gap violation.
violations.sort(key=lambda v: abs(_date_diff_days(v[5], v[4])), reverse=True)
which, anchor_raw, event_raw, anchor_start, anchor_end, event_start, event_end = violations[0]
rationale = (
f"Pattern {which}: anchor '{anchor_raw}' ({anchor_start}..{anchor_end}) "
f"{'before' if which == 'A' else 'after'} event '{event_raw}' "
f"({event_start}..{event_end}); "
+ ("event has not yet occurred at anchor time" if which == "A"
else "forthcoming event already past at anchor time")
)
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-ARITHMETIC-IMPOSSIBLE",
"severity": "HIGH",
"mode": 1,
"block_eligible": True,
"draft_locator": {
"file": "phase4_composition/draft.md",
"line": line_no,
"sentence": sentence.strip(),
},
"matched_span": None,
"bound_refs": [],
"bound_event": None,
"bound_dates": {
"left": {"role": "anchor",
"value": f"{anchor_start}..{anchor_end}",
"source": "draft_capture", "ref_slug": None},
"right": {"role": "event",
"value": f"{event_start}..{event_end}",
"source": "draft_capture", "ref_slug": None},
},
"rationale": rationale,
"suggested_fix": "Restate the claim to match the anchor's true time horizon, or hedge.",
})
def _pass_2_anachronism(draft: str, timeline: dict, citation_provenance: dict, findings: list[dict]) -> None:
"""P2 Mode 2 version-as-evidence-past anachronism.
For each <!--ref:slug--> marker:
1. v3.9.4.1 hotfix: Lookup slug in citation_provenance. If confidence is 'low' or
'conflict' → emit TEMPORAL-METADATA-MISSING and skip arithmetic (spec §3.4 promise:
first-party-unverified dates are NOT used as ground truth).
2. Lookup slug in timeline sources. Absent → emit TEMPORAL-METADATA-MISSING.
3. Lookup effective_date_range. Absent or start unverified/low → emit METADATA-MISSING.
4. Find nearest event date in ±200 chars around the ref marker.
5. Predicate (future-version): start > event.end → emit TEMPORAL-ANACHRONISTIC-CITATION.
6. Predicate (superseded-version): end < event.start (only when end.open_ended: false
and end.value known and confidence high/medium) → emit TEMPORAL-ANACHRONISTIC-CITATION.
"""
sources_by_key = {s["citation_key"]: s for s in timeline.get("sources", [])}
for m_ref in REF_MARKER_PATTERN.finditer(draft):
slug = m_ref.group(1)
ref_line_no = _compute_line_number(draft, m_ref.start())
# v3.9.4.1 hotfix: provenance confidence gate (spec §3.4)
prov_conf = _provenance_confidence(slug, citation_provenance)
if prov_conf in {"low", "conflict"}:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-METADATA-MISSING",
"severity": "LOW",
"mode": None,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md",
"line": ref_line_no,
"sentence": _sentence_around(draft, m_ref.start()),
},
"matched_span": None,
"bound_refs": [{"ref_slug": slug, "timeline_entry": None}],
"bound_event": None,
"bound_dates": None,
"rationale": f"<!--ref:{slug}--> citation_provenance confidence={prov_conf}; per spec §3.4 not used as arithmetic ground truth.",
"suggested_fix": None,
})
continue
source = sources_by_key.get(slug)
if source is None:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-METADATA-MISSING",
"severity": "LOW",
"mode": None,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md",
"line": ref_line_no,
"sentence": _sentence_around(draft, m_ref.start()),
},
"matched_span": None,
"bound_refs": [{"ref_slug": slug, "timeline_entry": None}],
"bound_event": None,
"bound_dates": None,
"rationale": f"<!--ref:{slug}--> has no entry in timeline.yaml; cannot verify temporal claims against this citation.",
"suggested_fix": None,
})
continue
edr = source.get("effective_date_range")
if not edr:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-METADATA-MISSING",
"severity": "LOW",
"mode": None,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": ref_line_no,
"sentence": _sentence_around(draft, m_ref.start()),
},
"matched_span": None,
"bound_refs": [{"ref_slug": slug, "timeline_entry": slug}],
"bound_event": None,
"bound_dates": None,
"rationale": f"{slug} has no effective_date_range; anachronism check cannot run.",
"suggested_fix": None,
})
continue
start = edr["start"]
start_conf = start.get("provenance", {}).get("confidence")
if start.get("value") is None or start_conf in {"unverified", "low"}:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-METADATA-MISSING",
"severity": "LOW",
"mode": None,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": ref_line_no,
"sentence": _sentence_around(draft, m_ref.start()),
},
"matched_span": None,
"bound_refs": [{"ref_slug": slug, "timeline_entry": slug}],
"bound_event": None,
"bound_dates": None,
"rationale": f"{slug} effective_date_range.start absent or low/unverified confidence; cannot verify anachronism.",
"suggested_fix": None,
})
continue
# Find nearest event date in ±200 chars around ref marker
# Exclude dates that overlap the ref marker itself (slug digits are not event dates)
window_start = max(0, m_ref.start() - 200)
window_end = min(len(draft), m_ref.end() + 200)
window = draft[window_start:window_end]
date_pattern = re.compile(DATE_REGEX, re.IGNORECASE)
# Compute ref marker span relative to window
ref_in_window_start = m_ref.start() - window_start
ref_in_window_end = m_ref.end() - window_start
event_dates = [
d for d in date_pattern.finditer(window)
if d.end() <= ref_in_window_start or d.start() >= ref_in_window_end
]
if not event_dates:
continue # no event date → no finding
# Pick closest to the ref marker position within the window
rel_ref = m_ref.start() - window_start
nearest = min(event_dates, key=lambda d: abs(d.start() - rel_ref))
event_raw = nearest.group(0)
try:
event_start, event_end = _date_to_interval(event_raw)
edr_start_start, _ = _date_to_interval(start["value"])
except ValueError:
continue
# Future-version check (spec §3.2 P2 step 5 first clause): start > event.end
if edr_start_start > event_end:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-ANACHRONISTIC-CITATION",
"severity": "HIGH",
"mode": 2,
"block_eligible": True,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": ref_line_no,
"sentence": _sentence_around(draft, m_ref.start()),
},
"matched_span": None,
"bound_refs": [{"ref_slug": slug, "timeline_entry": slug}],
"bound_event": {"event_id": None, "date": f"{event_start}..{event_end}"},
"bound_dates": None,
"rationale": (
f"{slug} effective_date_range starts {start['value']}, after cited "
f"event {event_raw} ({event_start}..{event_end}). Cited version postdates the event."
),
"suggested_fix": f"Cite the version of the source that was in effect during {event_raw}.",
})
# Superseded-version check (spec §3.2 P2 step 5 second clause)
end = edr.get("end", {})
end_open_ended = end.get("open_ended", False)
end_value = end.get("value")
end_conf = end.get("provenance", {}).get("confidence")
if (not end_open_ended and end_value is not None
and end_conf in {"high", "medium"}):
try:
_, edr_end_end = _date_to_interval(end_value)
except ValueError:
pass
else:
if edr_end_end < event_start:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-ANACHRONISTIC-CITATION",
"severity": "HIGH",
"mode": 2,
"block_eligible": True,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": ref_line_no,
"sentence": _sentence_around(draft, m_ref.start()),
},
"matched_span": None,
"bound_refs": [{"ref_slug": slug, "timeline_entry": slug}],
"bound_event": {"event_id": None, "date": f"{event_start}..{event_end}"},
"bound_dates": None,
"rationale": (
f"{slug} effective_date_range ended {end_value}, before cited "
f"event {event_raw} ({event_start}..{event_end}). Cited version was "
f"superseded before the event."
),
"suggested_fix": f"Cite the version of the source that was in effect during {event_raw}.",
})
def _pass_3_comparator(draft: str, timeline: dict, findings: list[dict]) -> None:
"""P3 Mode 3 comparator unmaterialized.
Detects prose comparator framing (Form A: 'prior edition', Form B: 'YYYY edition',
Form C: 'edition of YYYY'). For each match, binds version_family_id via the
nearest <!--ref:slug--> in the sentence/paragraph. If no timeline entry in that
family has a matching year, emits TEMPORAL-COMPARATOR-UNMATERIALIZED.
Per spec §3.2 P3: each match independently emits up to one finding per match;
there is no per-sentence cap.
"""
sources_by_key = {s["citation_key"]: s for s in timeline.get("sources", [])}
sources_by_family: dict[str, list[dict]] = {}
for s in timeline.get("sources", []):
fam = s.get("version_family_id")
if fam:
sources_by_family.setdefault(fam, []).append(s)
pos = 0
for sentence in re.split(r"(?<=[.!?])\s+", draft):
sentence_start = draft.find(sentence, pos)
if sentence_start == -1:
sentence_start = pos
line_no = _compute_line_number(draft, sentence_start)
pos = sentence_start + len(sentence)
for pattern_name, pat in [("A", COMPARATOR_FORM_A), ("B", COMPARATOR_FORM_B), ("C", COMPARATOR_FORM_C)]:
for m in pat.finditer(sentence):
# Resolve version_family_id via ref marker in sentence
refs_in_sentence = REF_MARKER_PATTERN.findall(sentence)
if not refs_in_sentence:
continue # binding ambiguous — emit no finding
bound_slug = refs_in_sentence[0]
bound_source = sources_by_key.get(bound_slug)
if not bound_source or not bound_source.get("version_family_id"):
continue
family = bound_source["version_family_id"]
# Determine comparator year
if pattern_name == "A":
year_match = re.search(
r"\b(?:19|20)\d{2}\b",
sentence[max(0, m.start() - 60):min(len(sentence), m.end() + 60)],
)
if not year_match:
continue
comparator_year = year_match.group(0)
else:
comparator_year = m.group("year")
# Check whether any source in this family has matching published_date year
family_sources = sources_by_family.get(family, [])
matched = False
for s in family_sources:
pd = s.get("published_date")
if pd and pd.get("value") and comparator_year in pd["value"]:
matched = True
break
if not matched:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-COMPARATOR-UNMATERIALIZED",
"severity": "MEDIUM",
"mode": 3,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": line_no,
"sentence": sentence.strip(),
},
"matched_span": {
"text": m.group(0),
"char_start": m.start(),
"char_end": m.end(),
},
"bound_refs": [{"ref_slug": bound_slug, "timeline_entry": bound_slug}],
"bound_event": None,
"bound_dates": None,
"rationale": (
f"Comparator '{m.group(0)}' (Form {pattern_name}, year={comparator_year}) "
f"references version family '{family}' but no timeline entry exists for that year. "
f"v3.9.4 reports this as claim-unsupported; v3.10 CC5 may escalate to phantom."
),
"suggested_fix": (
f"Either add a timeline entry for the {comparator_year} version of {family}, "
f"or rewrite the prose to remove the comparator claim."
),
})
# do NOT break — spec allows one finding per match
def _pass_4_causal(draft: str, timeline: dict, citation_provenance: dict, findings: list[dict]) -> None:
"""P4 Mode 4 causal inversion.
For each causal trigger phrase, identifies left and right arguments. Each side
may bind to either a <!--ref:slug--> marker (preferred) or a direct date capture
in the sentence (v3.9.4.1 hotfix: spec §3.2 P4 fallback).
v3.9.4.1 also adds citation_provenance gate (spec §3.4): if either bound slug has
confidence:low or conflict, emit TEMPORAL-METADATA-MISSING and skip predicate.
Looks up refs' published_date OR uses direct date capture, then verifies the
required ordering. If violated, emits TEMPORAL-CAUSAL-INVERSION.
"""
sources_by_key = {s["citation_key"]: s for s in timeline.get("sources", [])}
date_pattern = re.compile(DATE_REGEX, re.IGNORECASE)
pos = 0
for sentence in re.split(r"(?<=[.!?])\s+", draft):
sentence_start = draft.find(sentence, pos)
if sentence_start == -1:
sentence_start = pos
line_no = _compute_line_number(draft, sentence_start)
pos = sentence_start + len(sentence)
for trigger_pat, required_order in CAUSAL_TRIGGERS:
m_trig = trigger_pat.search(sentence)
if not m_trig:
continue
pre = sentence[:m_trig.start()]
post = sentence[m_trig.end():]
# Bind left: nearest ref BEFORE trigger; else nearest direct date BEFORE
left_refs = list(REF_MARKER_PATTERN.finditer(pre))
left_slug = left_refs[-1].group(1) if left_refs else None
left_date_raw = None
if left_slug is None:
# v3.9.4.1 fix #3: direct date fallback (spec §3.2 P4)
left_dates = list(date_pattern.finditer(pre))
if left_dates:
left_date_raw = left_dates[-1].group(0)
# Bind right: nearest ref AFTER trigger; else nearest direct date AFTER
right_refs = list(REF_MARKER_PATTERN.finditer(post))
right_slug = right_refs[0].group(1) if right_refs else None
right_date_raw = None
if right_slug is None:
right_dates = list(date_pattern.finditer(post))
if right_dates:
right_date_raw = right_dates[0].group(0)
# At least one side must bind
if not (left_slug or left_date_raw) or not (right_slug or right_date_raw):
continue
# v3.9.4.1 hotfix: provenance gate for either slug
for chk_slug in [left_slug, right_slug]:
if chk_slug is None:
continue
prov_conf = _provenance_confidence(chk_slug, citation_provenance)
if prov_conf in {"low", "conflict"}:
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-METADATA-MISSING",
"severity": "LOW",
"mode": None,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": line_no,
"sentence": sentence.strip(),
},
"matched_span": None,
"bound_refs": [{"ref_slug": chk_slug, "timeline_entry": None}],
"bound_event": None,
"bound_dates": None,
"rationale": f"<!--ref:{chk_slug}--> citation_provenance confidence={prov_conf}; per spec §3.4 not used as arithmetic ground truth for P4.",
"suggested_fix": None,
})
# Skip this trigger after emitting METADATA-MISSING for either side
break
else:
pass # no provenance issue, continue to predicate
# Re-check: if any prov gate fired, the for-else didn't run, we want to skip predicate
if any(
_provenance_confidence(s, citation_provenance) in {"low", "conflict"}
for s in [left_slug, right_slug] if s is not None
):
continue
# Resolve left date
if left_slug:
left_src = sources_by_key.get(left_slug)
if not left_src:
continue
left_pd = left_src.get("published_date", {}).get("value")
if not left_pd:
continue
try:
left_start, _ = _date_to_interval(left_pd)
except ValueError:
continue
left_source = "timeline_ref"
else:
try:
left_start, _ = _date_to_interval(left_date_raw)
except ValueError:
continue
left_source = "draft_capture"
# Resolve right date
if right_slug:
right_src = sources_by_key.get(right_slug)
if not right_src:
continue
right_pd = right_src.get("published_date", {}).get("value")
if not right_pd:
continue
try:
right_start, _ = _date_to_interval(right_pd)
except ValueError:
continue
right_source = "timeline_ref"
else:
try:
right_start, _ = _date_to_interval(right_date_raw)
except ValueError:
continue
right_source = "draft_capture"
violated = (
(required_order == "left<right" and left_start >= right_start)
or (required_order == "left>right" and left_start <= right_start)
)
if not violated:
continue
# v3.9.4.1 hotfix: bound_refs and bound_dates.source vary by binding mode
bound_refs_list = []
if left_slug:
bound_refs_list.append({"ref_slug": left_slug, "timeline_entry": left_slug})
if right_slug:
bound_refs_list.append({"ref_slug": right_slug, "timeline_entry": right_slug})
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-CAUSAL-INVERSION",
"severity": "MEDIUM",
"mode": 4,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md", "line": line_no,
"sentence": sentence.strip(),
},
"matched_span": {
"text": m_trig.group(0),
"char_start": m_trig.start(),
"char_end": m_trig.end(),
},
"bound_refs": bound_refs_list,
"bound_event": None,
"bound_dates": {
"left": {"role": "left_arg", "value": left_start,
"source": left_source, "ref_slug": left_slug},
"right": {"role": "right_arg", "value": right_start,
"source": right_source, "ref_slug": right_slug},
},
"rationale": (
f"Trigger '{m_trig.group(0)}' requires ordering {required_order}, "
f"but left.date={left_start} and right.date={right_start} violate predicate."
),
"suggested_fix": "Rewrite to match the actual ordering, or revise the causal claim.",
})
break # one finding per sentence
def _pass_5_deictic(draft: str, findings: list[dict]) -> None:
"""P5 Mode 5 time-bomb deictic regex lint."""
lines = draft.splitlines(keepends=True)
for m in DEICTIC_PATTERN.finditer(draft):
line_no = _compute_line_number(draft, m.start())
line_text = lines[line_no - 1].rstrip("\n") if line_no <= len(lines) else ""
findings.append({
"finding_id": f"TF-{_next_finding_id(findings):03d}",
"finding_kind": "TEMPORAL-DEICTIC",
"severity": "LOW",
"mode": 5,
"block_eligible": False,
"draft_locator": {
"file": "phase4_composition/draft.md",
"line": line_no,
"sentence": line_text,
},
"matched_span": {
"text": m.group(0),
"char_start": m.start(),
"char_end": m.end(),
},
"bound_refs": [],
"bound_event": None,
"bound_dates": None,
"rationale": f"Deictic phrase '{m.group(0)}' anchors claim to writing time; rewrite to specific date or version identifier.",
"suggested_fix": "Replace with 'as of YYYY-MM-DD' or a specific edition/year reference.",
})
def audit(draft: str, timeline: dict, citation_provenance: dict,
report_reference_date: str, audit_run_id: str) -> dict:
"""Run the 5-pass verifier. Returns an aggregate matching temporal_audit_results.schema.json.
v3.9.4.1 hotfix: citation_provenance now flows through to P2 and P4 (per spec §3.4).
"""
findings: list[dict] = []
_pass_1_arithmetic(draft, findings)
_pass_2_anachronism(draft, timeline, citation_provenance, findings)
_pass_3_comparator(draft, timeline, findings)
_pass_4_causal(draft, timeline, citation_provenance, findings)
_pass_5_deictic(draft, findings)
return {
"schema_version": "1.0",
"audit_run_id": audit_run_id,
"report_reference_date": report_reference_date,
"findings": findings,
}
def _render_markdown(result: dict) -> str:
"""Render the temporal audit results dict as a human-readable Markdown report."""
lines = [
"# Temporal Audit Results",
"",
f"- audit_run_id: `{result['audit_run_id']}`",
f"- report_reference_date: `{result['report_reference_date']}`",
f"- total findings: **{len(result['findings'])}**",
"",
]
if not result["findings"]:
lines.append("_No temporal-integrity findings in this draft._")
return "\n".join(lines) + "\n"
for f in result["findings"]:
lines.extend([
f"## {f['finding_id']}{f['finding_kind']} ({f['severity']})",
"",
f"- mode: {f['mode']}",
f"- file: `{f['draft_locator']['file']}` line {f['draft_locator']['line']}",
f"- sentence: \"{f['draft_locator']['sentence']}\"",
f"- rationale: {f['rationale']}",
])
if f.get("suggested_fix"):
lines.append(f"- suggested fix: {f['suggested_fix']}")
lines.append("")
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="v3.9.4 temporal integrity verifier (Phase 4 → 5 boundary)")
parser.add_argument("--draft", type=Path, required=True)
parser.add_argument("--timeline", type=Path, required=True)
parser.add_argument("--citation-provenance", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--markdown-output", type=Path, default=None,
help="Optional path for human-readable .md report")
parser.add_argument("--report-reference-date", required=True)
parser.add_argument("--audit-run-id", required=True)
args = parser.parse_args(argv)
draft = args.draft.read_text()
timeline = yaml.safe_load(args.timeline.read_text())
citation_provenance = yaml.safe_load(args.citation_provenance.read_text())
result = audit(draft, timeline, citation_provenance,
args.report_reference_date, args.audit_run_id)
args.output.write_text(yaml.safe_dump(result, sort_keys=False))
if args.markdown_output:
args.markdown_output.write_text(_render_markdown(result))
return 0
if __name__ == "__main__":
sys.exit(main())