#!/usr/bin/env python from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Audit CIL chart indexes for train/eval outcome and same-state leakage." ) parser.add_argument( "paths", nargs="+", type=Path, help="Index JSON files or roots containing */index.json.", ) parser.add_argument("--out", type=Path, default=None) parser.add_argument( "--allow-evaluator-outcomes", action="store_true", help="Allow non-train indexes with outcomes only when audience=evaluator_only.", ) args = parser.parse_args(argv) indexes = _load_indexes(args.paths) if not indexes: raise SystemExit("no index.json files found") violations: list[str] = [] warnings: list[str] = [] by_split: dict[str, list[dict[str, Any]]] = {} for item in indexes: split = str(item["payload"].get("split", "unknown")) by_split.setdefault(split, []).append(item) _audit_single_index(item, violations, warnings, allow_evaluator=args.allow_evaluator_outcomes) _audit_cross_split_overlap(by_split, violations) report = { "status": "pass" if not violations else "fail", "num_indexes": len(indexes), "indexes": [ { "path": str(item["path"]), "split": item["payload"].get("split"), "num_groups": item["payload"].get("num_groups_exported"), "num_rows": item["payload"].get("num_rows"), "include_outcomes": item["payload"].get("include_outcomes"), "retrieval_index_allowed": item["payload"].get("retrieval_index_allowed"), "audience": item["payload"].get("audience"), } for item in indexes ], "violations": violations, "warnings": warnings, } text = json.dumps(report, indent=2, sort_keys=True) + "\n" if args.out: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(text) print(text, end="") return 1 if violations else 0 def _load_indexes(paths: list[Path]) -> list[dict[str, Any]]: indexes: list[dict[str, Any]] = [] for path in paths: if path.is_dir(): candidates = sorted(path.glob("**/index.json")) else: candidates = [path] for candidate in candidates: if candidate.exists() and candidate.name == "index.json": indexes.append({"path": candidate, "payload": json.loads(candidate.read_text())}) return indexes def _audit_single_index( item: dict[str, Any], violations: list[str], warnings: list[str], *, allow_evaluator: bool, ) -> None: path = item["path"] payload = item["payload"] split = str(payload.get("split", "unknown")) include_outcomes = bool(payload.get("include_outcomes", False)) retrieval_allowed = bool(payload.get("retrieval_index_allowed", False)) audience = str(payload.get("audience", "unknown")) if split != "train" and retrieval_allowed: violations.append(f"{path}: non-train split is marked retrieval_index_allowed") if split == "train" and not retrieval_allowed: warnings.append(f"{path}: train split is not marked retrieval_index_allowed") if split != "train" and include_outcomes: if not (allow_evaluator and audience == "evaluator_only"): violations.append( f"{path}: non-train outcomes are included outside explicit evaluator-only audit mode" ) if retrieval_allowed and include_outcomes and split != "train": violations.append(f"{path}: retrieval index includes non-train outcomes") if not payload.get("content_hash"): warnings.append(f"{path}: missing content_hash") for shard in payload.get("shards", []): shard_path = path.parent / str(shard.get("path", "")) if not shard_path.exists(): violations.append(f"{path}: missing shard {shard_path}") def _audit_cross_split_overlap( by_split: dict[str, list[dict[str, Any]]], violations: list[str], ) -> None: train_group_hashes = _hashes_for_split(by_split.get("train", []), "group_hashes") train_state_hashes = _hashes_for_split(by_split.get("train", []), "state_hashes") for split, items in sorted(by_split.items()): if split == "train": continue group_overlap = train_group_hashes & _hashes_for_split(items, "group_hashes") state_overlap = train_state_hashes & _hashes_for_split(items, "state_hashes") if group_overlap: violations.append( f"train/{split}: {len(group_overlap)} overlapping group hashes" ) if state_overlap: violations.append( f"train/{split}: {len(state_overlap)} overlapping simulator-state hashes" ) def _hashes_for_split(items: list[dict[str, Any]], key: str) -> set[str]: output: set[str] = set() for item in items: output.update(str(value) for value in item["payload"].get(key, [])) return output if __name__ == "__main__": raise SystemExit(main())