mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
86bf0e5c2c
Refs #660. Measurement and issue closure follow in the separately preregistered post-main mechanical conformance PR.
140 lines
5.9 KiB
Python
Executable File
140 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""v3.9.4 spec lint — temporal verification sidecar conformance.
|
|
|
|
Enforces 8 invariants from spec §7:
|
|
1. timeline.yaml schema conformance against timeline.schema.json
|
|
2. supersession chain has no cycles (Task 7)
|
|
3. every date carries precision and provenance.method (implicit via schema)
|
|
4. citation_provenance.yaml schema conformance
|
|
5. temporal_audit_results.yaml schema conformance + finding_kind closed list + per-kind required-field map (via oneOf)
|
|
6. M3 IRON RULE block present in report_compiler_agent.md and draft_writer_agent.md (Task 22 implements lint of this)
|
|
7. M6 Citation Provenance Protocol section present in timeline_extraction_agent.md (Task 21+22 implements)
|
|
8. bibliography_agent.md matches the accepted post-#257 baseline for this temporal lint's ownership guard
|
|
|
|
This Task 6 scaffold implements invariants 1, 4, 5 (schema conformance). Invariants 2, 6-8 are added in later tasks.
|
|
|
|
Usage:
|
|
python scripts/check_v3_9_4_temporal_verification.py \\
|
|
--timeline phase2_investigation/timeline.yaml \\
|
|
--citation-provenance phase2_investigation/citation_provenance.yaml \\
|
|
--temporal-audit phase4_composition/temporal_audit_results.yaml
|
|
|
|
Exit codes:
|
|
0 — all checks pass
|
|
1 — one or more checks failed
|
|
2 — invocation error
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import jsonschema
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SCHEMAS = REPO_ROOT / "shared/contracts/passport"
|
|
|
|
BIBLIOGRAPHY_AGENT_PATH = REPO_ROOT / "deep-research/agents/bibliography_agent.md"
|
|
BIBLIOGRAPHY_AGENT_SHA256 = "19fdf3cc299d230a4cec44ab3a5bee2030f6293c4b60be9e8a9355cdd05adb93" # #660 baseline: added local, non-retrieving tortured-phrase metadata enrichment only; NO temporal/M6/M5 logic touched, ownership invariant intact. Previous accepted #651/#548/#511 additions remain covered by the same rule.
|
|
|
|
|
|
def _validate(yaml_path: Path, schema_path: Path) -> list[str]:
|
|
if not yaml_path.exists():
|
|
return [f"missing: {yaml_path}"]
|
|
if not schema_path.exists():
|
|
return [f"missing schema: {schema_path}"]
|
|
try:
|
|
data = yaml.safe_load(yaml_path.read_text())
|
|
schema = json.loads(schema_path.read_text())
|
|
jsonschema.validate(data, schema)
|
|
return []
|
|
except jsonschema.ValidationError as exc:
|
|
return [f"{yaml_path.name}: {exc.message} at {list(exc.absolute_path)}"]
|
|
except Exception as exc:
|
|
return [f"{yaml_path.name}: {exc}"]
|
|
|
|
|
|
def _check_supersession_cycles(timeline_path: Path) -> list[str]:
|
|
"""Invariant 2: supersession chain has no cycles.
|
|
|
|
Walks each source's `supersedes` chain. Records the visited set; if a citation_key
|
|
is revisited within the same walk, a cycle exists. Emits one error per cycle origin.
|
|
"""
|
|
if not timeline_path.exists():
|
|
return []
|
|
try:
|
|
data = yaml.safe_load(timeline_path.read_text())
|
|
except Exception:
|
|
return [] # schema validation handles parse errors
|
|
sources = {s["citation_key"]: s for s in data.get("sources", []) if "citation_key" in s}
|
|
errors: list[str] = []
|
|
for origin_key in sources:
|
|
visited_set: set[str] = set()
|
|
visited_path: list[str] = []
|
|
cur = origin_key
|
|
while cur is not None:
|
|
if cur in visited_set:
|
|
errors.append(
|
|
f"supersession cycle detected starting at {origin_key}: "
|
|
f"{' -> '.join(visited_path)} -> {cur}"
|
|
)
|
|
break
|
|
visited_set.add(cur)
|
|
visited_path.append(cur)
|
|
entry = sources.get(cur)
|
|
cur = entry.get("supersedes") if entry else None
|
|
return errors
|
|
|
|
|
|
def _check_bibliography_agent_unchanged() -> list[str]:
|
|
"""Invariant 8: bibliography_agent.md must match the accepted baseline sha256.
|
|
|
|
Per spec §3.4 + §3.6 F2 closure: M6 citation provenance and M5-stub version-family awareness
|
|
are owned by timeline_extraction_agent, NOT bibliography_agent. This lint enforces the invariant
|
|
that temporal/version-family logic does not drift into bibliography_agent.md. #257 legitimately
|
|
adds lit-review distributional-skew advisory text; that text is now part of this guard's baseline.
|
|
If a future v3.x release legitimately modifies bibliography_agent.md, update the sha256 constant in
|
|
the same commit.
|
|
"""
|
|
if not BIBLIOGRAPHY_AGENT_PATH.exists():
|
|
return [f"missing: {BIBLIOGRAPHY_AGENT_PATH}"]
|
|
actual = hashlib.sha256(BIBLIOGRAPHY_AGENT_PATH.read_bytes()).hexdigest()
|
|
if actual != BIBLIOGRAPHY_AGENT_SHA256:
|
|
return [
|
|
f"bibliography_agent.md modified — v3.9.4 F2 invariant violated. "
|
|
f"expected sha256 {BIBLIOGRAPHY_AGENT_SHA256}, got {actual}. "
|
|
f"Per spec §3.4 + §3.6, M6 citation provenance and M5-stub version-family awareness "
|
|
f"are owned by timeline_extraction_agent, NOT bibliography_agent. #257 distributional-skew "
|
|
f"advisory text is allowed only as part of the recorded baseline."
|
|
]
|
|
return []
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--timeline", type=Path, required=True)
|
|
parser.add_argument("--citation-provenance", type=Path, required=True)
|
|
parser.add_argument("--temporal-audit", type=Path, required=True)
|
|
args = parser.parse_args(argv)
|
|
|
|
errors: list[str] = []
|
|
errors.extend(_validate(args.timeline, SCHEMAS / "timeline.schema.json"))
|
|
errors.extend(_validate(args.citation_provenance, SCHEMAS / "citation_provenance.schema.json"))
|
|
errors.extend(_validate(args.temporal_audit, SCHEMAS / "temporal_audit_results.schema.json"))
|
|
errors.extend(_check_supersession_cycles(args.timeline))
|
|
errors.extend(_check_bibliography_agent_unchanged())
|
|
|
|
if errors:
|
|
for e in errors:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|