anhtld commited on
Commit
ecb8ba4
·
verified ·
1 Parent(s): 5f5c316

Add CTT paper artifact audit script

Browse files
workspace/scripts/audit_ctt_paper_artifacts.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ FORBIDDEN_PATTERNS: tuple[tuple[str, str, str], ...] = (
14
+ (
15
+ "submission_status_language",
16
+ r"\bA\*|\bQ1\b|Best Paper",
17
+ "Submission draft must not contain internal venue/readiness language.",
18
+ ),
19
+ (
20
+ "future_work_should",
21
+ r"future work should",
22
+ "Main method text must not defer the method to future work.",
23
+ ),
24
+ (
25
+ "generic_q_phi_generator",
26
+ r"q_phi|q_\s*\\phi|q_\s*\{\\phi\}|q\^\s*\\phi",
27
+ "The main generator must be CTT transport, not a generic q_phi noise model.",
28
+ ),
29
+ (
30
+ "distance_proxy_called_ptr",
31
+ r"(PTR[^.\n]{0,80}(distance|proxy))|((distance|proxy)[^.\n]{0,80}PTR)",
32
+ "Distance-only support diagnostics must be called PPTC, not PTR.",
33
+ ),
34
+ )
35
+
36
+
37
+ REQUIRED_PHRASES: tuple[tuple[str, str, str], ...] = (
38
+ (
39
+ "title_ctt",
40
+ "Causal Tangent Transport",
41
+ "Paper title/introduction names CTT as the central method.",
42
+ ),
43
+ (
44
+ "transport_operator",
45
+ "T_{\\phi}(z_s,z_t,\\xi_s^+)",
46
+ "Paper defines train-positive source-to-target transport.",
47
+ ),
48
+ (
49
+ "outcome_ptr",
50
+ "OutcomePTR",
51
+ "Paper names measured rollout positive-tangent recall separately.",
52
+ ),
53
+ (
54
+ "pptc",
55
+ "PPTC",
56
+ "Paper names distance-only Proxy Positive Tangent Coverage.",
57
+ ),
58
+ (
59
+ "support_gap",
60
+ "SupportGap",
61
+ "Paper exposes the support part of CAR.",
62
+ ),
63
+ (
64
+ "selector_gap",
65
+ "SelectorGap",
66
+ "Paper exposes the selector part of CAR.",
67
+ ),
68
+ )
69
+
70
+
71
+ REQUIRED_PATHS: tuple[tuple[str, str, str], ...] = (
72
+ ("metrics_module", "cil/metrics.py", "Canonical measured/proxy metrics."),
73
+ ("metrics_eval", "scripts/eval_metrics.py", "JSON/TeX metric export and proxy guards."),
74
+ ("metrics_tests", "tests/test_metrics.py", "Regression tests for metric separation."),
75
+ ("chart_export", "scripts/export_cil_charts.py", "Chart database export."),
76
+ ("chart_audit", "scripts/audit_cil_charts.py", "Chart leakage audit."),
77
+ ("data_accounting", "runs/data_accounting/table.json", "Scripted data accounting."),
78
+ ("data_accounting_table", "runs/data_accounting/table.tex", "Data accounting table."),
79
+ ("leakage_audit", "runs/leakage_audit/report.json", "Leakage audit artifact."),
80
+ ("ctt_model", "cil/models/ctt.py", "Causal Tangent Transport module."),
81
+ ("tangent_encoder", "cil/models/tangent_encoder.py", "Tangent-code encoder/decoder helpers."),
82
+ ("chart_encoder", "cil/models/chart_encoder.py", "Chart encoder."),
83
+ ("utility_energy", "cil/models/utility_energy.py", "Utility energy scorer."),
84
+ ("ctt_train", "scripts/train_ctt.py", "CTT training script."),
85
+ ("ctt_proxy_eval", "scripts/eval_ctt_proxy.py", "Proxy support evaluation."),
86
+ ("ctt_rollout_eval", "scripts/eval_ctt_rollout.py", "Measured rollout evaluation."),
87
+ ("utility_train", "scripts/train_utility_energy.py", "Utility energy training."),
88
+ ("dominance_calibration", "scripts/calibrate_dominance.py", "Calibrated dominance rule."),
89
+ ("theory_tex", "paper/sections/theory.tex", "Theory section included by paper."),
90
+ ("paper_pdf", "latex/main.pdf", "Compiled paper PDF."),
91
+ )
92
+
93
+
94
+ REQUIRED_RUN_FILES: tuple[str, ...] = (
95
+ "table.tex",
96
+ "metrics.json",
97
+ "command.txt",
98
+ "git_hash.txt",
99
+ "data_hash.txt",
100
+ "split_hash.txt",
101
+ )
102
+
103
+
104
+ ADVISOR_RUN_FILES: tuple[str, ...] = (
105
+ "config.yaml",
106
+ "train.log",
107
+ "eval.log",
108
+ "metrics_by_task.json",
109
+ "metrics_by_seed.json",
110
+ "report.md",
111
+ )
112
+
113
+
114
+ INPUT_RE = re.compile(r"\\input\{([^}]+)\}")
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int:
118
+ parser = argparse.ArgumentParser(
119
+ description=(
120
+ "Audit the CTT paper against the claim-to-artifact contract. "
121
+ "Outputs JSON and TeX so the repo can keep README.md as the only Markdown file."
122
+ )
123
+ )
124
+ parser.add_argument("--repo-root", type=Path, default=Path.cwd())
125
+ parser.add_argument("--paper", type=Path, default=Path("latex/main.tex"))
126
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/paper_ctt_audit"))
127
+ parser.add_argument(
128
+ "--skip-implementation-checks",
129
+ action="store_true",
130
+ help="Only scan paper text and input artifacts; useful for isolated unit tests.",
131
+ )
132
+ args = parser.parse_args(argv)
133
+
134
+ repo_root = args.repo_root.resolve()
135
+ paper_path = _resolve(repo_root, args.paper)
136
+ if not paper_path.exists():
137
+ raise SystemExit(f"paper not found: {paper_path}")
138
+ paper_text = paper_path.read_text()
139
+
140
+ forbidden = _forbidden_findings(paper_text)
141
+ phrase_checks = _phrase_checks(paper_text)
142
+ paper_inputs = _paper_input_checks(repo_root, paper_path, paper_text)
143
+ required_paths = [] if args.skip_implementation_checks else _required_path_checks(repo_root)
144
+ run_artifacts = _run_artifact_checks(repo_root, paper_inputs["run_dirs"])
145
+ summary = _summary(forbidden, phrase_checks, paper_inputs, required_paths, run_artifacts)
146
+
147
+ payload = {
148
+ "schema_version": 1,
149
+ "audit_policy": {
150
+ "markdown_policy": "consolidated_readme_only",
151
+ "markdown_note": (
152
+ "Advisor checklist asks for report.md files, but the current workspace "
153
+ "policy keeps README.md as the only Markdown document. Missing report.md "
154
+ "is recorded as a warning, not regenerated here."
155
+ ),
156
+ },
157
+ "paper": str(paper_path.relative_to(repo_root)),
158
+ "summary": summary,
159
+ "forbidden_patterns": forbidden,
160
+ "required_phrases": phrase_checks,
161
+ "paper_inputs": paper_inputs,
162
+ "required_paths": required_paths,
163
+ "run_artifacts": run_artifacts,
164
+ }
165
+
166
+ out_dir = _resolve(repo_root, args.out_dir)
167
+ out_dir.mkdir(parents=True, exist_ok=True)
168
+ (out_dir / "audit.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
169
+ (out_dir / "metrics.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
170
+ (out_dir / "table.tex").write_text(_latex_table(payload) + "\n")
171
+ (out_dir / "config.yaml").write_text(
172
+ "\n".join(
173
+ [
174
+ "paper: " + str(paper_path.relative_to(repo_root)),
175
+ "markdown_policy: consolidated_readme_only",
176
+ "strict_report_md: false",
177
+ ]
178
+ )
179
+ + "\n"
180
+ )
181
+ (out_dir / "command.txt").write_text(
182
+ "python scripts/audit_ctt_paper_artifacts.py " + " ".join(sys.argv[1:]) + "\n"
183
+ )
184
+ (out_dir / "git_hash.txt").write_text(_git_hash(repo_root) + "\n")
185
+ (out_dir / "data_hash.txt").write_text(_first_existing_hash(repo_root, "runs/data_accounting/table.json") + "\n")
186
+ (out_dir / "split_hash.txt").write_text(_split_hash(repo_root) + "\n")
187
+
188
+ print(json.dumps({"out_dir": str(out_dir), **summary}, indent=2, sort_keys=True))
189
+ return 1 if summary["status"] == "fail" else 0
190
+
191
+
192
+ def _forbidden_findings(text: str) -> list[dict[str, Any]]:
193
+ findings: list[dict[str, Any]] = []
194
+ for name, pattern, detail in FORBIDDEN_PATTERNS:
195
+ for match in re.finditer(pattern, text, flags=re.IGNORECASE):
196
+ findings.append(
197
+ {
198
+ "name": name,
199
+ "status": "fail",
200
+ "detail": detail,
201
+ "match": match.group(0),
202
+ "line": _line_for_offset(text, match.start()),
203
+ }
204
+ )
205
+ return findings
206
+
207
+
208
+ def _phrase_checks(text: str) -> list[dict[str, Any]]:
209
+ checks = []
210
+ for name, phrase, detail in REQUIRED_PHRASES:
211
+ checks.append(
212
+ {
213
+ "name": name,
214
+ "status": "pass" if phrase in text else "fail",
215
+ "detail": detail,
216
+ "needle": phrase,
217
+ }
218
+ )
219
+ return checks
220
+
221
+
222
+ def _paper_input_checks(repo_root: Path, paper_path: Path, text: str) -> dict[str, Any]:
223
+ rows = []
224
+ run_dirs: list[str] = []
225
+ for match in INPUT_RE.finditer(text):
226
+ raw = match.group(1)
227
+ resolved = _resolve_tex_input(paper_path.parent, raw)
228
+ status = "pass" if resolved.exists() else "fail"
229
+ row = {
230
+ "raw": raw,
231
+ "line": _line_for_offset(text, match.start()),
232
+ "resolved": _display_path(repo_root, resolved),
233
+ "status": status,
234
+ }
235
+ run_dir = _run_dir_for_input(repo_root, resolved)
236
+ if run_dir is not None:
237
+ row["run_dir"] = _display_path(repo_root, run_dir)
238
+ if row["run_dir"] not in run_dirs:
239
+ run_dirs.append(row["run_dir"])
240
+ rows.append(row)
241
+ return {
242
+ "num_inputs": len(rows),
243
+ "num_missing": sum(1 for row in rows if row["status"] != "pass"),
244
+ "rows": rows,
245
+ "run_dirs": sorted(run_dirs),
246
+ }
247
+
248
+
249
+ def _required_path_checks(repo_root: Path) -> list[dict[str, Any]]:
250
+ checks = []
251
+ for name, rel_path, detail in REQUIRED_PATHS:
252
+ path = repo_root / rel_path
253
+ checks.append(
254
+ {
255
+ "name": name,
256
+ "path": rel_path,
257
+ "status": "pass" if path.exists() else "fail",
258
+ "detail": detail,
259
+ }
260
+ )
261
+ ctt_configs = sorted((repo_root / "configs/ctt").glob("*.yaml"))
262
+ checks.append(
263
+ {
264
+ "name": "ctt_configs",
265
+ "path": "configs/ctt/*.yaml",
266
+ "status": "pass" if ctt_configs else "fail",
267
+ "detail": "CTT loss weights and variants are config-driven.",
268
+ "files": [_display_path(repo_root, path) for path in ctt_configs],
269
+ }
270
+ )
271
+ return checks
272
+
273
+
274
+ def _run_artifact_checks(repo_root: Path, run_dirs: list[str]) -> list[dict[str, Any]]:
275
+ rows = []
276
+ for rel_run_dir in sorted(run_dirs):
277
+ run_dir = repo_root / rel_run_dir
278
+ required = list(REQUIRED_RUN_FILES)
279
+ if rel_run_dir == "runs/data_accounting":
280
+ required = ["table.json", "table.tex"]
281
+ missing_required = [
282
+ name
283
+ for name in required
284
+ if not (run_dir / name).exists()
285
+ and not (rel_run_dir == "runs/paper_ctt_audit" and name == "metrics.json")
286
+ ]
287
+ missing_advisor = [name for name in ADVISOR_RUN_FILES if not (run_dir / name).exists()]
288
+ status = "pass" if not missing_required else "fail"
289
+ rows.append(
290
+ {
291
+ "run_dir": rel_run_dir,
292
+ "status": status,
293
+ "missing_required": missing_required,
294
+ "missing_advisor_contract": missing_advisor,
295
+ "markdown_report_policy": (
296
+ "warning_only_consolidated_readme"
297
+ if "report.md" in missing_advisor
298
+ else "present"
299
+ ),
300
+ }
301
+ )
302
+ return rows
303
+
304
+
305
+ def _summary(
306
+ forbidden: list[dict[str, Any]],
307
+ phrase_checks: list[dict[str, Any]],
308
+ paper_inputs: dict[str, Any],
309
+ required_paths: list[dict[str, Any]],
310
+ run_artifacts: list[dict[str, Any]],
311
+ ) -> dict[str, Any]:
312
+ failures = (
313
+ len(forbidden)
314
+ + paper_inputs["num_missing"]
315
+ + sum(1 for item in phrase_checks if item["status"] == "fail")
316
+ + sum(1 for item in required_paths if item["status"] == "fail")
317
+ + sum(1 for item in run_artifacts if item["status"] == "fail")
318
+ )
319
+ warnings = sum(len(item.get("missing_advisor_contract", [])) for item in run_artifacts)
320
+ return {
321
+ "status": "fail" if failures else "pass",
322
+ "num_failures": failures,
323
+ "num_warnings": warnings,
324
+ "num_forbidden_matches": len(forbidden),
325
+ "num_paper_inputs": paper_inputs["num_inputs"],
326
+ "num_run_dirs_in_paper": len(paper_inputs["run_dirs"]),
327
+ }
328
+
329
+
330
+ def _latex_table(payload: dict[str, Any]) -> str:
331
+ summary = payload["summary"]
332
+ lines = [
333
+ "% Auto-generated by scripts/audit_ctt_paper_artifacts.py",
334
+ "\\begin{tabular}{lrr}",
335
+ "\\toprule",
336
+ "Audit item & Count & Status \\\\",
337
+ "\\midrule",
338
+ f"Forbidden language matches & {summary['num_forbidden_matches']} & {_latex_status(summary['num_forbidden_matches'] == 0)} \\\\",
339
+ f"Paper inputs & {summary['num_paper_inputs']} & {_latex_status(payload['paper_inputs']['num_missing'] == 0)} \\\\",
340
+ f"Run dirs in paper & {summary['num_run_dirs_in_paper']} & {_latex_status(all(row['status'] == 'pass' for row in payload['run_artifacts']))} \\\\",
341
+ f"Implementation paths & {len(payload['required_paths'])} & {_latex_status(all(row['status'] == 'pass' for row in payload['required_paths']))} \\\\",
342
+ f"Advisor-contract warnings & {summary['num_warnings']} & warn \\\\",
343
+ "\\bottomrule",
344
+ "\\end{tabular}",
345
+ ]
346
+ return "\n".join(lines)
347
+
348
+
349
+ def _resolve(repo_root: Path, path: Path) -> Path:
350
+ return path if path.is_absolute() else repo_root / path
351
+
352
+
353
+ def _resolve_tex_input(base_dir: Path, raw: str) -> Path:
354
+ candidate = (base_dir / raw).resolve()
355
+ if candidate.exists():
356
+ return candidate
357
+ if candidate.suffix:
358
+ return candidate
359
+ return candidate.with_suffix(".tex")
360
+
361
+
362
+ def _run_dir_for_input(repo_root: Path, resolved: Path) -> Path | None:
363
+ try:
364
+ rel = resolved.relative_to(repo_root / "runs")
365
+ except ValueError:
366
+ return None
367
+ if not rel.parts:
368
+ return None
369
+ return repo_root / "runs" / rel.parts[0]
370
+
371
+
372
+ def _display_path(repo_root: Path, path: Path) -> str:
373
+ try:
374
+ return str(path.relative_to(repo_root))
375
+ except ValueError:
376
+ return str(path)
377
+
378
+
379
+ def _line_for_offset(text: str, offset: int) -> int:
380
+ return text.count("\n", 0, offset) + 1
381
+
382
+
383
+ def _git_hash(repo_root: Path) -> str:
384
+ try:
385
+ return subprocess.check_output(
386
+ ["git", "rev-parse", "HEAD"],
387
+ cwd=repo_root,
388
+ text=True,
389
+ stderr=subprocess.DEVNULL,
390
+ ).strip()
391
+ except Exception:
392
+ return "unknown"
393
+
394
+
395
+ def _first_existing_hash(repo_root: Path, rel_path: str) -> str:
396
+ path = repo_root / rel_path
397
+ if not path.exists():
398
+ return ""
399
+ import hashlib
400
+
401
+ return hashlib.sha256(path.read_bytes()).hexdigest()
402
+
403
+
404
+ def _split_hash(repo_root: Path) -> str:
405
+ path = repo_root / "runs/data_accounting/table.json"
406
+ if not path.exists():
407
+ return ""
408
+ try:
409
+ payload = json.loads(path.read_text())
410
+ except json.JSONDecodeError:
411
+ return ""
412
+ return str(payload.get("split_hash", ""))
413
+
414
+
415
+ def _latex_status(ok: bool) -> str:
416
+ return "pass" if ok else "fail"
417
+
418
+
419
+ if __name__ == "__main__":
420
+ raise SystemExit(main())