Files
imbad0202__academic-researc…/scripts/test_temporal_integrity_audit.py
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

439 lines
20 KiB
Python

"""Tests for v3.9.4 temporal_integrity_audit.py 5-pass verifier."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts/temporal_integrity_audit.py"
def _run_audit(tmp_path: Path, draft: str, timeline: dict, citation_provenance: dict | None = None,
report_reference_date: str = "2026-05-18") -> dict:
"""Helper: write inputs, run audit, return parsed output."""
(tmp_path / "draft.md").write_text(draft)
(tmp_path / "timeline.yaml").write_text(yaml.safe_dump(timeline))
(tmp_path / "citation_provenance.yaml").write_text(yaml.safe_dump(
citation_provenance or {"schema_version": "1.0", "audit_run_id": "2026-05-18T12:34:56Z-a1b2", "entries": []}
))
out = tmp_path / "temporal_audit_results.yaml"
result = subprocess.run(
[sys.executable, str(SCRIPT),
"--draft", str(tmp_path / "draft.md"),
"--timeline", str(tmp_path / "timeline.yaml"),
"--citation-provenance", str(tmp_path / "citation_provenance.yaml"),
"--output", str(out),
"--report-reference-date", report_reference_date,
"--audit-run-id", "2026-05-18T12:34:56Z-a1b2"],
capture_output=True, text=True,
)
assert result.returncode == 0, f"audit failed: stderr={result.stderr!r}"
return yaml.safe_load(out.read_text())
def test_audit_scaffold_returns_empty_findings_on_empty_draft(tmp_path):
"""Scaffold sanity: empty draft → 0 findings, valid output shape."""
result = _run_audit(tmp_path, draft="", timeline={"schema_version": "1.0", "sources": [], "events": []})
assert result["schema_version"] == "1.0"
assert result["audit_run_id"] == "2026-05-18T12:34:56Z-a1b2"
assert result["report_reference_date"] == "2026-05-18"
assert result["findings"] == []
def test_p5_currently_emits_deictic_finding(tmp_path):
"""Mode 5 time-bomb: 'currently' triggers TEMPORAL-DEICTIC."""
result = _run_audit(
tmp_path,
draft="Currently, the most recent edition prescribes annual review.\n",
timeline={"schema_version": "1.0", "sources": [], "events": []},
)
deictic = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-DEICTIC"]
assert len(deictic) >= 1, f"expected >=1 TEMPORAL-DEICTIC, got: {result['findings']}"
first = deictic[0]
assert first["mode"] == 5
assert first["severity"] == "LOW"
assert first["matched_span"] is not None
assert "currently" in first["matched_span"]["text"].lower()
def test_p5_anchored_phrase_no_finding(tmp_path):
"""Mode 5 legitimate: 'As of 2026-05-18, the 2024 edition prescribes' must NOT trigger."""
result = _run_audit(
tmp_path,
draft="As of 2026-05-18, the 2024 edition prescribes annual review.\n",
timeline={"schema_version": "1.0", "sources": [], "events": []},
)
deictic = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-DEICTIC"]
assert deictic == [], f"unexpected deictic findings: {deictic}"
def test_p1_future_as_past_emits_arithmetic_impossible(tmp_path):
"""Mode 1: 'As of March 2025, ... had already completed June 2025 deliverables' is physically impossible."""
result = _run_audit(
tmp_path,
draft="As of March 2025, the report noted that the system had already completed June 2025 deliverables.\n",
timeline={"schema_version": "1.0", "sources": [], "events": []},
)
arith = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-ARITHMETIC-IMPOSSIBLE"]
assert len(arith) == 1, f"expected 1 TEMPORAL-ARITHMETIC-IMPOSSIBLE, got: {result['findings']}"
first = arith[0]
assert first["mode"] == 1
assert first["severity"] == "HIGH"
assert first["bound_dates"] is not None
# anchor=March 2025, event=June 2025; event > anchor
assert first["bound_dates"]["left"]["role"] == "anchor"
assert "2025-03" in first["bound_dates"]["left"]["value"]
assert first["bound_dates"]["right"]["role"] == "event"
assert "2025-06" in first["bound_dates"]["right"]["value"]
def test_p1_prospective_already_past(tmp_path):
"""Mode 1 Pattern B: forthcoming event but anchor is later than event date."""
result = _run_audit(
tmp_path,
draft="The June 2025 delivery to be completed by the project team, as of December 2025 the project is unfinished.\n",
timeline={"schema_version": "1.0", "sources": [], "events": []},
)
arith = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-ARITHMETIC-IMPOSSIBLE"]
assert len(arith) >= 1
def test_p2_2026_handbook_governing_2022_event(tmp_path):
"""Mode 2: 2026 handbook cited for 2022 review cycle → anachronism."""
result = _run_audit(
tmp_path,
draft="The 2026 Handbook governed the 2022 review cycle.<!--ref:handbook-2026ed-->\n",
timeline={
"schema_version": "1.0",
"sources": [{
"citation_key": "handbook-2026ed",
"type": "institutional-document",
"published_date": {
"value": "2026-09-15", "precision": "day", "open_ended": False,
"provenance": {"method": "crossref_lookup", "confidence": "high"},
},
"effective_date_range": {
"start": {
"value": "2026-09-15", "precision": "day", "open_ended": False,
"provenance": {"method": "crossref_lookup", "confidence": "high"},
},
"end": {
"value": None, "precision": "unknown", "open_ended": True,
"provenance": {"method": "user_override", "confidence": "high"},
},
},
}],
"events": [],
},
)
anachronism = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-ANACHRONISTIC-CITATION"]
assert len(anachronism) == 1, f"expected 1 anachronism, got: {result['findings']}"
f0 = anachronism[0]
assert f0["mode"] == 2
assert f0["bound_event"] is not None
assert f0["bound_refs"][0]["ref_slug"] == "handbook-2026ed"
def test_p3_unmaterialized_comparator(tmp_path):
"""Mode 3: prose mentions '1998 edition' but timeline only has 2020 edition."""
result = _run_audit(
tmp_path,
draft="This differs from the 1998 edition of the standard.<!--ref:standard-2020ed-->\n",
timeline={
"schema_version": "1.0",
"sources": [{
"citation_key": "standard-2020ed",
"type": "standard",
"version_family_id": "standard-family",
"published_date": {
"value": "2020-01-01", "precision": "year", "open_ended": False,
"provenance": {"method": "user_override", "confidence": "high"},
},
}],
"events": [],
},
)
comparator = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-COMPARATOR-UNMATERIALIZED"]
assert len(comparator) == 1, f"expected 1 comparator finding, got: {result['findings']}"
assert comparator[0]["matched_span"] is not None
assert "1998" in comparator[0]["matched_span"]["text"]
def test_p4_causal_inversion(tmp_path):
"""Mode 4: 'Policy A enabled Policy B' but timeline has A AFTER B."""
result = _run_audit(
tmp_path,
draft="Policy A<!--ref:policy-a--> enabled Policy B<!--ref:policy-b-->.\n",
timeline={
"schema_version": "1.0",
"sources": [
{"citation_key": "policy-a", "type": "policy",
"published_date": {"value": "2026-03-01", "precision": "day", "open_ended": False,
"provenance": {"method": "user_override", "confidence": "high"}}},
{"citation_key": "policy-b", "type": "policy",
"published_date": {"value": "2020-05-15", "precision": "day", "open_ended": False,
"provenance": {"method": "user_override", "confidence": "high"}}},
],
"events": [],
},
)
causal = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-CAUSAL-INVERSION"]
assert len(causal) == 1, f"expected 1 causal finding, got: {result['findings']}"
f0 = causal[0]
assert f0["bound_dates"] is not None
assert f0["bound_dates"]["left"]["ref_slug"] == "policy-a"
assert f0["bound_dates"]["right"]["ref_slug"] == "policy-b"
FIXTURE_ROOT = REPO_ROOT / "tests/fixtures/v3.9.4-temporal"
@pytest.mark.parametrize("fixture_name", [
"mode_1_future_as_past",
"mode_2_version_as_evidence_past",
"mode_3_comparator_unmaterialized",
"mode_4_causal_inversion",
"mode_5_time_bomb",
])
def test_positive_fixture_golden(tmp_path, fixture_name):
fixture_dir = FIXTURE_ROOT / fixture_name
out = tmp_path / "temporal_audit_results.yaml"
result = subprocess.run(
[sys.executable, str(SCRIPT),
"--draft", str(fixture_dir / "draft.md"),
"--timeline", str(fixture_dir / "timeline.yaml"),
"--citation-provenance", str(fixture_dir / "citation_provenance.yaml"),
"--output", str(out),
"--report-reference-date", "2026-05-18",
"--audit-run-id", "2026-05-18T12:34:56Z-a1b2"],
capture_output=True, text=True,
)
assert result.returncode == 0, f"stderr={result.stderr}"
actual = yaml.safe_load(out.read_text())
expected = yaml.safe_load((fixture_dir / "expected_temporal_audit_results.yaml").read_text())
assert actual == expected, f"diff for {fixture_name}:\nactual={actual}\nexpected={expected}"
@pytest.mark.parametrize("fixture_name", [
"mode_1_legitimate",
"mode_2_legitimate",
"mode_3_legitimate",
"mode_4_legitimate",
"mode_5_legitimate",
])
def test_negative_fixture_golden(tmp_path, fixture_name):
"""Legitimate prose fixtures: verifier output matches captured baseline (typically zero violation findings)."""
fixture_dir = FIXTURE_ROOT / fixture_name
out = tmp_path / "temporal_audit_results.yaml"
result = subprocess.run(
[sys.executable, str(SCRIPT),
"--draft", str(fixture_dir / "draft.md"),
"--timeline", str(fixture_dir / "timeline.yaml"),
"--citation-provenance", str(fixture_dir / "citation_provenance.yaml"),
"--output", str(out),
"--report-reference-date", "2026-05-18",
"--audit-run-id", "2026-05-18T12:34:56Z-a1b2"],
capture_output=True, text=True,
)
assert result.returncode == 0, f"audit failed: stderr={result.stderr!r}"
actual = yaml.safe_load(out.read_text())
expected = yaml.safe_load((fixture_dir / "expected_temporal_audit_results.yaml").read_text())
assert actual == expected, f"diff for {fixture_name}: actual={actual}\nexpected={expected}"
# Optional: assert no Mode N findings for the matching mode
mode_n = int(fixture_name.split("_")[1])
mode_findings = [f for f in actual["findings"]
if f.get("mode") == mode_n
and f["finding_kind"] != "TEMPORAL-METADATA-MISSING"]
assert mode_findings == [], f"unexpected mode-{mode_n} violation: {mode_findings}"
def test_audit_writes_markdown_report(tmp_path):
"""Audit must write phase4_composition/temporal_audit.md alongside the YAML."""
(tmp_path / "draft.md").write_text("Currently, the framework is under review.\n")
(tmp_path / "timeline.yaml").write_text(yaml.safe_dump(
{"schema_version": "1.0", "sources": [], "events": []}))
(tmp_path / "citation_provenance.yaml").write_text(yaml.safe_dump(
{"schema_version": "1.0", "audit_run_id": "2026-05-18T12:34:56Z-a1b2", "entries": []}))
out_yaml = tmp_path / "temporal_audit_results.yaml"
out_md = tmp_path / "temporal_audit.md"
result = subprocess.run(
[sys.executable, str(SCRIPT),
"--draft", str(tmp_path / "draft.md"),
"--timeline", str(tmp_path / "timeline.yaml"),
"--citation-provenance", str(tmp_path / "citation_provenance.yaml"),
"--output", str(out_yaml),
"--markdown-output", str(out_md),
"--report-reference-date", "2026-05-18",
"--audit-run-id", "2026-05-18T12:34:56Z-a1b2"],
capture_output=True, text=True,
)
assert result.returncode == 0, f"stderr={result.stderr}"
assert out_yaml.exists()
assert out_md.exists()
md = out_md.read_text()
assert "# Temporal Audit Results" in md
assert "TEMPORAL-DEICTIC" in md
def test_metadata_missing_fixture(tmp_path):
"""METADATA-MISSING fires when ref's timeline source lacks effective_date_range."""
fixture_dir = FIXTURE_ROOT / "metadata_missing_p2"
out = tmp_path / "temporal_audit_results.yaml"
result = subprocess.run(
[sys.executable, str(SCRIPT),
"--draft", str(fixture_dir / "draft.md"),
"--timeline", str(fixture_dir / "timeline.yaml"),
"--citation-provenance", str(fixture_dir / "citation_provenance.yaml"),
"--output", str(out),
"--report-reference-date", "2026-05-18",
"--audit-run-id", "2026-05-18T12:34:56Z-a1b2"],
capture_output=True, text=True,
)
assert result.returncode == 0
actual = yaml.safe_load(out.read_text())
expected = yaml.safe_load((fixture_dir / "expected_temporal_audit_results.yaml").read_text())
assert actual == expected
# Spec contract: METADATA-MISSING for the ref
metadata_missing = [f for f in actual["findings"] if f["finding_kind"] == "TEMPORAL-METADATA-MISSING"]
assert len(metadata_missing) == 1
assert metadata_missing[0]["bound_refs"][0]["ref_slug"] == "handbook-2024ed"
def test_freeze_regression_byte_identical_across_dates(tmp_path):
"""CC6: report_reference_date is frozen — output must be byte-identical regardless of wall-clock today.
This test does NOT depend on time.time() or datetime.now() behavior, because the verifier itself
uses the --report-reference-date arg. The regression asserts the SAME --report-reference-date
produces byte-identical output across two independent runs.
"""
fixture_dir = FIXTURE_ROOT / "report_reference_date_freeze"
out1 = tmp_path / "run1.yaml"
out2 = tmp_path / "run2.yaml"
for out in [out1, out2]:
result = subprocess.run(
[sys.executable, str(SCRIPT),
"--draft", str(fixture_dir / "draft.md"),
"--timeline", str(fixture_dir / "timeline.yaml"),
"--citation-provenance", str(fixture_dir / "citation_provenance.yaml"),
"--output", str(out),
"--report-reference-date", "2026-05-18",
"--audit-run-id", "2026-05-18T12:34:56Z-a1b2"],
capture_output=True, text=True,
)
assert result.returncode == 0, f"stderr={result.stderr}"
assert out1.read_bytes() == out2.read_bytes(), "byte-identical regression failed"
# v3.9.4.1 hotfix: _date_to_interval coverage for all schema-valid shapes
import importlib.util
_AUDIT_SPEC = importlib.util.spec_from_file_location(
"temporal_integrity_audit", REPO_ROOT / "scripts/temporal_integrity_audit.py"
)
_audit_mod = importlib.util.module_from_spec(_AUDIT_SPEC)
_AUDIT_SPEC.loader.exec_module(_audit_mod)
@pytest.mark.parametrize("raw,expected", [
# Day precision (already worked in v3.9.4)
("2024-09-15", ("2024-09-15", "2024-09-15")),
# Year precision (already worked in v3.9.4)
("2024", ("2024-01-01", "2024-12-31")),
# Prose month (already worked in v3.9.4)
("March 2025", ("2025-03-01", "2025-03-31")),
# v3.9.4.1 hotfix: month precision YYYY-MM
("2024-09", ("2024-09-01", "2024-09-30")),
("2024-02", ("2024-02-01", "2024-02-28")),
("2024-12", ("2024-12-01", "2024-12-31")),
# v3.9.4.1 hotfix: interval precision YYYY-MM-DD..YYYY-MM-DD
("2022-04-01..2022-12-31", ("2022-04-01", "2022-12-31")),
("2020-10-01..2024-09-30", ("2020-10-01", "2024-09-30")),
])
def test_date_to_interval_parses_all_schema_valid_shapes(raw, expected):
"""v3.9.4.1 hotfix: verifier handles all 5 v3.9.4 schema date shapes.
v3.9.4 only parsed day/year/prose-month — schema-valid month (YYYY-MM)
and interval (YYYY-MM-DD..YYYY-MM-DD) shapes raised ValueError, causing
P2/P4 to silently skip checks. Real-world Crossref returns month precision;
real-world effective_date_range uses interval. This test locks the fix."""
assert _audit_mod._date_to_interval(raw) == expected
def test_date_to_interval_rejects_invalid_month():
"""Defensive: YYYY-13 (month > 12) should raise, not produce garbage."""
with pytest.raises(ValueError):
_audit_mod._date_to_interval("2024-13")
def test_p2_provenance_low_emits_metadata_missing_skips_anachronism(tmp_path):
"""v3.9.4.1 fix #1: P2 must consult citation_provenance and emit METADATA-MISSING
(not ANACHRONISTIC-CITATION) when confidence is low — spec §3.4 first-party safety check."""
# Even though timeline says handbook-2026ed is way in the future, citation_provenance
# confidence:low must downgrade to METADATA-MISSING instead of anachronism finding.
timeline = {
"schema_version": "1.0",
"sources": [{
"citation_key": "handbook-2026ed",
"type": "institutional-document",
"effective_date_range": {
"start": {"value": "2026-09-15", "precision": "day", "open_ended": False,
"provenance": {"method": "crossref_lookup", "confidence": "high"}},
"end": {"value": None, "precision": "unknown", "open_ended": True,
"provenance": {"method": "user_override", "confidence": "high"}},
},
}],
"events": [],
}
citation_provenance = {
"schema_version": "1.0",
"audit_run_id": "2026-05-19T00:00:00Z-test",
"entries": [{
"citation_key": "handbook-2026ed",
"crossref_issued": None,
"pdftotext_cover_first_line": None,
"verification_method": "none",
"confidence": "low",
"notes": None,
}],
}
result = _run_audit(
tmp_path,
draft="The 2026 Handbook governed the 2022 review cycle.<!--ref:handbook-2026ed-->\n",
timeline=timeline,
citation_provenance=citation_provenance,
)
# No anachronism findings (the v3.9.4 silent-bypass bug would have emitted one).
anachronism = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-ANACHRONISTIC-CITATION"]
assert anachronism == [], f"v3.9.4.1 fix #1 broken — anachronism emitted despite confidence:low: {anachronism}"
# Exactly one METADATA-MISSING citing the provenance reason.
metadata_missing = [f for f in result["findings"]
if f["finding_kind"] == "TEMPORAL-METADATA-MISSING"
and "confidence=low" in f.get("rationale", "")]
assert len(metadata_missing) == 1, f"expected 1 METADATA-MISSING with provenance reason; got {result['findings']}"
def test_p4_direct_date_causal_inversion_no_refs(tmp_path):
"""v3.9.4.1 fix #3: P4 must bind to direct date captures when ref markers absent
(spec §3.2 P4: each side may bind to ref marker OR direct date capture)."""
# No ref markers; both sides are bare dates. "enabled" requires left.date < right.date.
# Here left=2026 right=2020 → violation.
result = _run_audit(
tmp_path,
draft="The 2026 policy enabled the 2020 rollout.\n",
timeline={"schema_version": "1.0", "sources": [], "events": []},
)
causal = [f for f in result["findings"] if f["finding_kind"] == "TEMPORAL-CAUSAL-INVERSION"]
assert len(causal) == 1, f"expected 1 causal inversion via direct date binding; got {result['findings']}"
f0 = causal[0]
assert f0["bound_dates"] is not None
assert f0["bound_dates"]["left"]["source"] == "draft_capture"
assert f0["bound_dates"]["right"]["source"] == "draft_capture"
# bound_refs should be empty (no slugs bound).
assert f0["bound_refs"] == []