anhtld commited on
Commit
813de1c
·
verified ·
1 Parent(s): f97ea87

ctt artifacts 2026-07-02 workspace/scripts/audit_cil_charts.py

Browse files
Files changed (1) hide show
  1. workspace/scripts/audit_cil_charts.py +159 -0
workspace/scripts/audit_cil_charts.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ REQUIRED_SPLITS = ("train", "val", "test")
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(description="Audit CIL chart split indexes for leakage.")
16
+ parser.add_argument("--chart-root", type=Path, default=Path("data/cil_charts"))
17
+ parser.add_argument("--run-root", type=Path, default=Path("runs"))
18
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/leakage_audit"))
19
+ args = parser.parse_args(argv)
20
+
21
+ indexes = _load_indexes(args.chart_root)
22
+ violations: list[str] = []
23
+ warnings: list[str] = []
24
+ for split in REQUIRED_SPLITS:
25
+ if split not in indexes:
26
+ violations.append(f"missing index for split {split}: {args.chart_root / split / 'index.json'}")
27
+ if not violations:
28
+ _audit_split_flags(indexes, violations)
29
+ _audit_overlap(indexes, violations)
30
+ _audit_shards(indexes, violations)
31
+ _audit_run_hashes(args.run_root, warnings)
32
+
33
+ report = {
34
+ "status": "pass" if not violations else "fail",
35
+ "chart_root": str(args.chart_root),
36
+ "indexes": {
37
+ split: {
38
+ "path": str(path),
39
+ "num_groups_exported": payload.get("num_groups_exported"),
40
+ "num_rows": payload.get("num_rows"),
41
+ "include_outcomes": payload.get("include_outcomes"),
42
+ "retrieval_index_allowed": payload.get("retrieval_index_allowed"),
43
+ "audience": payload.get("audience"),
44
+ "content_hash": payload.get("content_hash"),
45
+ "split_hash": payload.get("split_hash"),
46
+ }
47
+ for split, (path, payload) in sorted(indexes.items())
48
+ },
49
+ "violations": violations,
50
+ "warnings": warnings,
51
+ }
52
+ out_dir = args.out_dir
53
+ out_dir.mkdir(parents=True, exist_ok=True)
54
+ (out_dir / "report.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
55
+ (out_dir / "report.md").write_text(_markdown(report) + "\n")
56
+ print(json.dumps({"status": report["status"], "violations": len(violations)}, indent=2))
57
+ return 1 if violations else 0
58
+
59
+
60
+ def _load_indexes(chart_root: Path) -> dict[str, tuple[Path, dict[str, Any]]]:
61
+ output: dict[str, tuple[Path, dict[str, Any]]] = {}
62
+ for split in REQUIRED_SPLITS:
63
+ path = chart_root / split / "index.json"
64
+ if path.exists():
65
+ output[split] = (path, json.loads(path.read_text()))
66
+ return output
67
+
68
+
69
+ def _audit_split_flags(indexes: dict[str, tuple[Path, dict[str, Any]]], violations: list[str]) -> None:
70
+ for split, (path, payload) in indexes.items():
71
+ include_outcomes = bool(payload.get("include_outcomes"))
72
+ retrieval = bool(payload.get("retrieval_index_allowed"))
73
+ audience = str(payload.get("audience"))
74
+ if split == "train":
75
+ if not retrieval:
76
+ violations.append(f"{path}: train split is not retrieval_index_allowed")
77
+ if not include_outcomes:
78
+ violations.append(f"{path}: train split should include outcomes for training")
79
+ else:
80
+ if retrieval:
81
+ violations.append(f"{path}: non-train split is retrieval_index_allowed")
82
+ if include_outcomes:
83
+ violations.append(f"{path}: non-train split exposes outcomes in chart DB")
84
+ if audience != "evaluator_only":
85
+ violations.append(f"{path}: non-train split audience must be evaluator_only")
86
+ if split != str(payload.get("split")):
87
+ violations.append(f"{path}: index split field does not match directory")
88
+ if not payload.get("deployment_candidate_excludes_expert", False):
89
+ violations.append(f"{path}: missing deployment_candidate_excludes_expert=true")
90
+
91
+
92
+ def _audit_overlap(indexes: dict[str, tuple[Path, dict[str, Any]]], violations: list[str]) -> None:
93
+ for left in REQUIRED_SPLITS:
94
+ for right in REQUIRED_SPLITS:
95
+ if left >= right or left not in indexes or right not in indexes:
96
+ continue
97
+ left_payload = indexes[left][1]
98
+ right_payload = indexes[right][1]
99
+ group_overlap = set(left_payload.get("group_hashes", [])) & set(
100
+ right_payload.get("group_hashes", [])
101
+ )
102
+ state_overlap = set(left_payload.get("state_hashes", [])) & set(
103
+ right_payload.get("state_hashes", [])
104
+ )
105
+ if group_overlap:
106
+ violations.append(f"{left}/{right}: {len(group_overlap)} overlapping chart_ids")
107
+ if state_overlap:
108
+ violations.append(f"{left}/{right}: {len(state_overlap)} overlapping state_hashes")
109
+
110
+
111
+ def _audit_shards(indexes: dict[str, tuple[Path, dict[str, Any]]], violations: list[str]) -> None:
112
+ for split, (path, payload) in indexes.items():
113
+ for shard in payload.get("shards", []):
114
+ shard_path = path.parent / str(shard.get("path", ""))
115
+ if not shard_path.exists():
116
+ violations.append(f"{path}: missing shard {shard_path}")
117
+
118
+
119
+ def _audit_run_hashes(run_root: Path, warnings: list[str]) -> None:
120
+ metrics_files = sorted(run_root.glob("*/metrics.json"))
121
+ if not metrics_files:
122
+ warnings.append(f"{run_root}: no run metrics.json files found for hash audit")
123
+ return
124
+ for path in metrics_files:
125
+ try:
126
+ payload = json.loads(path.read_text())
127
+ except json.JSONDecodeError:
128
+ warnings.append(f"{path}: invalid JSON; skipped hash audit")
129
+ continue
130
+ if "data_hash" not in payload:
131
+ warnings.append(f"{path}: missing data_hash")
132
+ if "split_hash" not in payload:
133
+ warnings.append(f"{path}: missing split_hash")
134
+
135
+
136
+ def _markdown(report: dict[str, Any]) -> str:
137
+ lines = [
138
+ "# CIL Chart Leakage Audit",
139
+ "",
140
+ f"Status: `{report['status']}`",
141
+ "",
142
+ "| Split | Rows | Charts | Outcomes in DB | Retrieval allowed | Audience |",
143
+ "| --- | ---: | ---: | --- | --- | --- |",
144
+ ]
145
+ for split, payload in sorted(report["indexes"].items()):
146
+ lines.append(
147
+ f"| {split} | {payload.get('num_rows')} | {payload.get('num_groups_exported')} | "
148
+ f"{payload.get('include_outcomes')} | {payload.get('retrieval_index_allowed')} | "
149
+ f"{payload.get('audience')} |"
150
+ )
151
+ lines.extend(["", "## Violations", ""])
152
+ lines.extend(f"- {item}" for item in report["violations"]) if report["violations"] else lines.append("- None")
153
+ lines.extend(["", "## Warnings", ""])
154
+ lines.extend(f"- {item}" for item in report["warnings"]) if report["warnings"] else lines.append("- None")
155
+ return "\n".join(lines)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())