| |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import re |
| import subprocess |
| import sys |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| FORBIDDEN_PATTERNS: tuple[tuple[str, str, str], ...] = ( |
| ( |
| "submission_status_language", |
| r"\bA\*|\bQ1\b|Best Paper", |
| "Submission draft must not contain internal venue/readiness language.", |
| ), |
| ( |
| "future_work_should", |
| r"future work should", |
| "Main method text must not defer the method to future work.", |
| ), |
| ( |
| "generic_q_phi_generator", |
| r"q_phi|q_\s*\\phi|q_\s*\{\\phi\}|q\^\s*\\phi", |
| "The main generator must be CTT transport, not a generic q_phi noise model.", |
| ), |
| ( |
| "distance_proxy_called_ptr", |
| r"(PTR[^.\n]{0,80}(distance|proxy))|((distance|proxy)[^.\n]{0,80}PTR)", |
| "Distance-only support diagnostics must be called PPTC, not PTR.", |
| ), |
| ) |
|
|
|
|
| REQUIRED_PHRASES: tuple[tuple[str, str, str], ...] = ( |
| ( |
| "title_ctt", |
| "Causal Tangent Transport", |
| "Paper title/introduction names CTT as the central method.", |
| ), |
| ( |
| "transport_operator", |
| "T_{\\phi}(z_s,z_t,\\xi_s^+)", |
| "Paper defines train-positive source-to-target transport.", |
| ), |
| ( |
| "outcome_ptr", |
| "OutcomePTR", |
| "Paper names measured rollout positive-tangent recall separately.", |
| ), |
| ( |
| "pptc", |
| "PPTC", |
| "Paper names distance-only Proxy Positive Tangent Coverage.", |
| ), |
| ( |
| "support_gap", |
| "SupportGap", |
| "Paper exposes the support part of CAR.", |
| ), |
| ( |
| "selector_gap", |
| "SelectorGap", |
| "Paper exposes the selector part of CAR.", |
| ), |
| ) |
|
|
|
|
| REQUIRED_PATHS: tuple[tuple[str, str, str], ...] = ( |
| ("metrics_module", "cil/metrics.py", "Canonical measured/proxy metrics."), |
| ("metrics_eval", "scripts/eval_metrics.py", "JSON/TeX metric export and proxy guards."), |
| ("metrics_tests", "tests/test_metrics.py", "Regression tests for metric separation."), |
| ("chart_export", "scripts/export_cil_charts.py", "Chart database export."), |
| ("chart_audit", "scripts/audit_cil_charts.py", "Chart leakage audit."), |
| ("data_accounting", "runs/data_accounting/table.json", "Scripted data accounting."), |
| ("data_accounting_table", "runs/data_accounting/table.tex", "Data accounting table."), |
| ("leakage_audit", "runs/leakage_audit/report.json", "Leakage audit artifact."), |
| ("ctt_model", "cil/models/ctt.py", "Causal Tangent Transport module."), |
| ("tangent_encoder", "cil/models/tangent_encoder.py", "Tangent-code encoder/decoder helpers."), |
| ("chart_encoder", "cil/models/chart_encoder.py", "Chart encoder."), |
| ("utility_energy", "cil/models/utility_energy.py", "Utility energy scorer."), |
| ("ctt_train", "scripts/train_ctt.py", "CTT training script."), |
| ("ctt_proxy_eval", "scripts/eval_ctt_proxy.py", "Proxy support evaluation."), |
| ("ctt_rollout_eval", "scripts/eval_ctt_rollout.py", "Measured rollout evaluation."), |
| ("utility_train", "scripts/train_utility_energy.py", "Utility energy training."), |
| ("dominance_calibration", "scripts/calibrate_dominance.py", "Calibrated dominance rule."), |
| ("selector_diagnostic_sweep", "scripts/build_selector_diagnostic_sweep.py", "Selector diagnostic sweep summary."), |
| ("theory_tex", "paper/sections/theory.tex", "Theory section included by paper."), |
| ("paper_pdf", "latex/main.pdf", "Compiled paper PDF."), |
| ) |
|
|
|
|
| REQUIRED_RUN_FILES: tuple[str, ...] = ( |
| "table.tex", |
| "metrics.json", |
| "command.txt", |
| "git_hash.txt", |
| "data_hash.txt", |
| "split_hash.txt", |
| ) |
|
|
|
|
| ADVISOR_RUN_FILES: tuple[str, ...] = ( |
| "config.yaml", |
| "train.log", |
| "eval.log", |
| "metrics_by_task.json", |
| "metrics_by_seed.json", |
| ) |
|
|
|
|
| INPUT_RE = re.compile(r"\\input\{([^}]+)\}") |
|
|
|
|
| ALLOWED_MARKDOWN_FILES: tuple[str, ...] = ("README.md",) |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| description=( |
| "Audit the CTT paper against the claim-to-artifact contract. " |
| "Outputs JSON and TeX so the repo can keep README.md as the only Markdown file." |
| ) |
| ) |
| parser.add_argument("--repo-root", type=Path, default=Path.cwd()) |
| parser.add_argument("--paper", type=Path, default=Path("latex/main.tex")) |
| parser.add_argument("--out-dir", type=Path, default=Path("runs/paper_ctt_audit")) |
| parser.add_argument( |
| "--skip-implementation-checks", |
| action="store_true", |
| help="Only scan paper text and input artifacts; useful for isolated unit tests.", |
| ) |
| args = parser.parse_args(argv) |
|
|
| repo_root = args.repo_root.resolve() |
| paper_path = _resolve(repo_root, args.paper) |
| if not paper_path.exists(): |
| raise SystemExit(f"paper not found: {paper_path}") |
| paper_text = paper_path.read_text() |
|
|
| forbidden = _forbidden_findings(paper_text) |
| phrase_checks = _phrase_checks(paper_text) |
| paper_inputs = _paper_input_checks(repo_root, paper_path, paper_text) |
| required_paths = [] if args.skip_implementation_checks else _required_path_checks(repo_root) |
| run_artifacts = _run_artifact_checks(repo_root, paper_inputs["run_dirs"]) |
| markdown_policy = _markdown_policy_checks(repo_root) |
| summary = _summary( |
| forbidden, |
| phrase_checks, |
| paper_inputs, |
| required_paths, |
| run_artifacts, |
| markdown_policy, |
| ) |
|
|
| payload = { |
| "schema_version": 1, |
| "audit_policy": { |
| "markdown_policy": "consolidated_readme_only", |
| "markdown_note": ( |
| "Advisor checklist asks for report.md files, but the current workspace " |
| "policy keeps README.md as the only Markdown document. This audit now " |
| "fails on additional Markdown files instead of regenerating report.md." |
| ), |
| "allowed_markdown_files": list(ALLOWED_MARKDOWN_FILES), |
| }, |
| "paper": str(paper_path.relative_to(repo_root)), |
| "summary": summary, |
| "forbidden_patterns": forbidden, |
| "required_phrases": phrase_checks, |
| "paper_inputs": paper_inputs, |
| "required_paths": required_paths, |
| "run_artifacts": run_artifacts, |
| "markdown_policy": markdown_policy, |
| } |
|
|
| out_dir = _resolve(repo_root, args.out_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
| (out_dir / "audit.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") |
| (out_dir / "metrics.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") |
| (out_dir / "table.tex").write_text(_latex_table(payload) + "\n") |
| (out_dir / "config.yaml").write_text( |
| "\n".join( |
| [ |
| "paper: " + str(paper_path.relative_to(repo_root)), |
| "markdown_policy: consolidated_readme_only", |
| "strict_report_md: false", |
| "allowed_markdown_files:", |
| *[f" - {name}" for name in ALLOWED_MARKDOWN_FILES], |
| ] |
| ) |
| + "\n" |
| ) |
| (out_dir / "command.txt").write_text( |
| "python scripts/audit_ctt_paper_artifacts.py " + " ".join(sys.argv[1:]) + "\n" |
| ) |
| (out_dir / "git_hash.txt").write_text(_git_hash(repo_root) + "\n") |
| (out_dir / "data_hash.txt").write_text(_first_existing_hash(repo_root, "runs/data_accounting/table.json") + "\n") |
| (out_dir / "split_hash.txt").write_text(_split_hash(repo_root) + "\n") |
|
|
| print(json.dumps({"out_dir": str(out_dir), **summary}, indent=2, sort_keys=True)) |
| return 1 if summary["status"] == "fail" else 0 |
|
|
|
|
| def _forbidden_findings(text: str) -> list[dict[str, Any]]: |
| findings: list[dict[str, Any]] = [] |
| for name, pattern, detail in FORBIDDEN_PATTERNS: |
| for match in re.finditer(pattern, text, flags=re.IGNORECASE): |
| findings.append( |
| { |
| "name": name, |
| "status": "fail", |
| "detail": detail, |
| "match": match.group(0), |
| "line": _line_for_offset(text, match.start()), |
| } |
| ) |
| return findings |
|
|
|
|
| def _phrase_checks(text: str) -> list[dict[str, Any]]: |
| checks = [] |
| for name, phrase, detail in REQUIRED_PHRASES: |
| checks.append( |
| { |
| "name": name, |
| "status": "pass" if phrase in text else "fail", |
| "detail": detail, |
| "needle": phrase, |
| } |
| ) |
| return checks |
|
|
|
|
| def _paper_input_checks(repo_root: Path, paper_path: Path, text: str) -> dict[str, Any]: |
| rows = [] |
| run_dirs: list[str] = [] |
| for match in INPUT_RE.finditer(text): |
| raw = match.group(1) |
| resolved = _resolve_tex_input(paper_path.parent, raw) |
| status = "pass" if resolved.exists() else "fail" |
| row = { |
| "raw": raw, |
| "line": _line_for_offset(text, match.start()), |
| "resolved": _display_path(repo_root, resolved), |
| "status": status, |
| } |
| run_dir = _run_dir_for_input(repo_root, resolved) |
| if run_dir is not None: |
| row["run_dir"] = _display_path(repo_root, run_dir) |
| if row["run_dir"] not in run_dirs: |
| run_dirs.append(row["run_dir"]) |
| rows.append(row) |
| return { |
| "num_inputs": len(rows), |
| "num_missing": sum(1 for row in rows if row["status"] != "pass"), |
| "rows": rows, |
| "run_dirs": sorted(run_dirs), |
| } |
|
|
|
|
| def _required_path_checks(repo_root: Path) -> list[dict[str, Any]]: |
| checks = [] |
| for name, rel_path, detail in REQUIRED_PATHS: |
| path = repo_root / rel_path |
| checks.append( |
| { |
| "name": name, |
| "path": rel_path, |
| "status": "pass" if path.exists() else "fail", |
| "detail": detail, |
| } |
| ) |
| ctt_configs = sorted((repo_root / "configs/ctt").glob("*.yaml")) |
| checks.append( |
| { |
| "name": "ctt_configs", |
| "path": "configs/ctt/*.yaml", |
| "status": "pass" if ctt_configs else "fail", |
| "detail": "CTT loss weights and variants are config-driven.", |
| "files": [_display_path(repo_root, path) for path in ctt_configs], |
| } |
| ) |
| return checks |
|
|
|
|
| def _run_artifact_checks(repo_root: Path, run_dirs: list[str]) -> list[dict[str, Any]]: |
| rows = [] |
| for rel_run_dir in sorted(run_dirs): |
| run_dir = repo_root / rel_run_dir |
| required = list(REQUIRED_RUN_FILES) |
| if rel_run_dir == "runs/data_accounting": |
| required = ["table.json", "table.tex"] |
| missing_required = [ |
| name |
| for name in required |
| if not (run_dir / name).exists() |
| and not (rel_run_dir == "runs/paper_ctt_audit" and name == "metrics.json") |
| ] |
| missing_advisor = [name for name in ADVISOR_RUN_FILES if not (run_dir / name).exists()] |
| status = "pass" if not missing_required else "fail" |
| rows.append( |
| { |
| "run_dir": rel_run_dir, |
| "status": status, |
| "missing_required": missing_required, |
| "missing_advisor_contract": missing_advisor, |
| "markdown_report_policy": "consolidated_readme_only", |
| } |
| ) |
| return rows |
|
|
|
|
| def _markdown_policy_checks(repo_root: Path) -> dict[str, Any]: |
| ignored_parts = {".git", ".venv"} |
| rows = [] |
| for path in sorted(repo_root.rglob("*.md")): |
| try: |
| rel = path.relative_to(repo_root) |
| except ValueError: |
| continue |
| if any(part in ignored_parts for part in rel.parts): |
| continue |
| rel_text = rel.as_posix() |
| rows.append( |
| { |
| "path": rel_text, |
| "status": "pass" if rel_text in ALLOWED_MARKDOWN_FILES else "fail", |
| } |
| ) |
| unexpected = [row["path"] for row in rows if row["status"] != "pass"] |
| missing_allowed = [ |
| name for name in ALLOWED_MARKDOWN_FILES if not (repo_root / name).exists() |
| ] |
| return { |
| "policy": "consolidated_readme_only", |
| "allowed": list(ALLOWED_MARKDOWN_FILES), |
| "rows": rows, |
| "unexpected_markdown": unexpected, |
| "missing_allowed": missing_allowed, |
| "num_markdown_files": len(rows), |
| "num_unexpected_markdown": len(unexpected), |
| "status": "pass" if not unexpected and not missing_allowed else "fail", |
| } |
|
|
|
|
| def _summary( |
| forbidden: list[dict[str, Any]], |
| phrase_checks: list[dict[str, Any]], |
| paper_inputs: dict[str, Any], |
| required_paths: list[dict[str, Any]], |
| run_artifacts: list[dict[str, Any]], |
| markdown_policy: dict[str, Any], |
| ) -> dict[str, Any]: |
| failures = ( |
| len(forbidden) |
| + paper_inputs["num_missing"] |
| + sum(1 for item in phrase_checks if item["status"] == "fail") |
| + sum(1 for item in required_paths if item["status"] == "fail") |
| + sum(1 for item in run_artifacts if item["status"] == "fail") |
| + (0 if markdown_policy["status"] == "pass" else 1) |
| ) |
| warnings = sum(len(item.get("missing_advisor_contract", [])) for item in run_artifacts) |
| return { |
| "status": "fail" if failures else "pass", |
| "num_failures": failures, |
| "num_warnings": warnings, |
| "num_forbidden_matches": len(forbidden), |
| "num_unexpected_markdown": markdown_policy["num_unexpected_markdown"], |
| "num_paper_inputs": paper_inputs["num_inputs"], |
| "num_run_dirs_in_paper": len(paper_inputs["run_dirs"]), |
| } |
|
|
|
|
| def _latex_table(payload: dict[str, Any]) -> str: |
| summary = payload["summary"] |
| lines = [ |
| "% Auto-generated by scripts/audit_ctt_paper_artifacts.py", |
| "\\begin{tabular}{lrr}", |
| "\\toprule", |
| "Audit item & Count & Status \\\\", |
| "\\midrule", |
| f"Forbidden language matches & {summary['num_forbidden_matches']} & {_latex_status(summary['num_forbidden_matches'] == 0)} \\\\", |
| f"Paper inputs & {summary['num_paper_inputs']} & {_latex_status(payload['paper_inputs']['num_missing'] == 0)} \\\\", |
| f"Run dirs in paper & {summary['num_run_dirs_in_paper']} & {_latex_status(all(row['status'] == 'pass' for row in payload['run_artifacts']))} \\\\", |
| f"Implementation paths & {len(payload['required_paths'])} & {_latex_status(all(row['status'] == 'pass' for row in payload['required_paths']))} \\\\", |
| f"Unexpected Markdown files & {summary['num_unexpected_markdown']} & {_latex_status(summary['num_unexpected_markdown'] == 0)} \\\\", |
| f"Advisor-contract warnings & {summary['num_warnings']} & {'pass' if summary['num_warnings'] == 0 else 'warn'} \\\\", |
| "\\bottomrule", |
| "\\end{tabular}", |
| ] |
| return "\n".join(lines) |
|
|
|
|
| def _resolve(repo_root: Path, path: Path) -> Path: |
| return path if path.is_absolute() else repo_root / path |
|
|
|
|
| def _resolve_tex_input(base_dir: Path, raw: str) -> Path: |
| candidate = (base_dir / raw).resolve() |
| if candidate.exists(): |
| return candidate |
| if candidate.suffix: |
| return candidate |
| return candidate.with_suffix(".tex") |
|
|
|
|
| def _run_dir_for_input(repo_root: Path, resolved: Path) -> Path | None: |
| try: |
| rel = resolved.relative_to(repo_root / "runs") |
| except ValueError: |
| return None |
| if not rel.parts: |
| return None |
| return repo_root / "runs" / rel.parts[0] |
|
|
|
|
| def _display_path(repo_root: Path, path: Path) -> str: |
| try: |
| return str(path.relative_to(repo_root)) |
| except ValueError: |
| return str(path) |
|
|
|
|
| def _line_for_offset(text: str, offset: int) -> int: |
| return text.count("\n", 0, offset) + 1 |
|
|
|
|
| def _git_hash(repo_root: Path) -> str: |
| try: |
| return subprocess.check_output( |
| ["git", "rev-parse", "HEAD"], |
| cwd=repo_root, |
| text=True, |
| stderr=subprocess.DEVNULL, |
| ).strip() |
| except Exception: |
| return "unknown" |
|
|
|
|
| def _first_existing_hash(repo_root: Path, rel_path: str) -> str: |
| path = repo_root / rel_path |
| if not path.exists(): |
| return "" |
| import hashlib |
|
|
| return hashlib.sha256(path.read_bytes()).hexdigest() |
|
|
|
|
| def _split_hash(repo_root: Path) -> str: |
| path = repo_root / "runs/data_accounting/table.json" |
| if not path.exists(): |
| return "" |
| try: |
| payload = json.loads(path.read_text()) |
| except json.JSONDecodeError: |
| return "" |
| return str(payload.get("split_hash", "")) |
|
|
|
|
| def _latex_status(ok: bool) -> str: |
| return "pass" if ok else "fail" |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|