| |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import math |
| import subprocess |
| import sys |
| from pathlib import Path |
| from typing import Any |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| description=( |
| "Build a measured CTT outcome acceptance gate from rollout and " |
| "train-clean selector artifacts." |
| ) |
| ) |
| parser.add_argument( |
| "--rollout-metrics", |
| type=Path, |
| default=Path("runs/ctt_base_context_obs_test_envclip_k16_rollout_comparison/metrics.json"), |
| ) |
| parser.add_argument( |
| "--selector-metrics", |
| type=Path, |
| default=Path( |
| "runs/ctt_base_context_obs_learned_dominance_chartcompat_obs_utility_task_envclip_k16_train_to_test/metrics.json" |
| ), |
| ) |
| parser.add_argument( |
| "--out-dir", |
| type=Path, |
| default=Path("runs/ctt_outcome_acceptance_gate"), |
| ) |
| parser.add_argument("--selected-min", type=float, default=0.4745) |
| parser.add_argument("--target-selected-min", type=float, default=0.50) |
| parser.add_argument("--proposal-oracle-min", type=float, default=0.50) |
| parser.add_argument("--success-support-gap-max", type=float, default=0.07) |
| parser.add_argument("--success-selector-gap-max", type=float, default=0.03) |
| parser.add_argument("--v0-outcome-ptr-floor", type=float, default=0.4435) |
| parser.add_argument("--unsafe-slack", type=float, default=0.0) |
| parser.add_argument("--min-train-seeds", type=int, default=3) |
| args = parser.parse_args(argv) |
|
|
| rollout = _load_json(args.rollout_metrics) |
| selector = _load_json(args.selector_metrics) |
| values = _observed_values(rollout, selector) |
| gates = _gates(values, args) |
| out_dir = args.out_dir |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| payload = { |
| "report_type": "ctt_outcome_acceptance_gate", |
| "schema_version": 1, |
| "overall_pass": all(bool(gate["pass"]) for gate in gates), |
| "status": "method_success" if all(bool(gate["pass"]) for gate in gates) else "diagnostic_not_method_success", |
| "rollout_metrics": str(args.rollout_metrics), |
| "selector_metrics": str(args.selector_metrics), |
| "thresholds": { |
| "selected_min": args.selected_min, |
| "target_selected_min": args.target_selected_min, |
| "proposal_oracle_min": args.proposal_oracle_min, |
| "success_support_gap_max": args.success_support_gap_max, |
| "success_selector_gap_max": args.success_selector_gap_max, |
| "v0_outcome_ptr_floor": args.v0_outcome_ptr_floor, |
| "unsafe_slack": args.unsafe_slack, |
| "min_train_seeds": args.min_train_seeds, |
| }, |
| "observed": values, |
| "gates": gates, |
| "data_hash": rollout.get("data_hash") or rollout.get("target_content_hash"), |
| "split_hash": rollout.get("split_hash") or rollout.get("target_split_hash"), |
| "selector_data_hash": selector.get("data_hash") or selector.get("eval_target_content_hash"), |
| "selector_split_hash": selector.get("split_hash") or selector.get("eval_target_split_hash"), |
| } |
| (out_dir / "metrics.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") |
| (out_dir / "metrics_by_task.json").write_text( |
| json.dumps( |
| _group_file_payload(args.selector_metrics, args.rollout_metrics, "metrics_by_task.json"), |
| indent=2, |
| sort_keys=True, |
| ) |
| + "\n" |
| ) |
| (out_dir / "metrics_by_seed.json").write_text( |
| json.dumps( |
| _group_file_payload(args.selector_metrics, args.rollout_metrics, "metrics_by_seed.json"), |
| indent=2, |
| sort_keys=True, |
| ) |
| + "\n" |
| ) |
| (out_dir / "table.tex").write_text(_table(gates) + "\n") |
| (out_dir / "config.yaml").write_text(_config(args) + "\n") |
| (out_dir / "command.txt").write_text( |
| "python scripts/build_ctt_outcome_gate.py " + " ".join(sys.argv[1:]) + "\n" |
| ) |
| (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n") |
| (out_dir / "data_hash.txt").write_text(str(payload.get("data_hash") or "") + "\n") |
| (out_dir / "split_hash.txt").write_text(str(payload.get("split_hash") or "") + "\n") |
| (out_dir / "train.log").write_text("acceptance gate artifact; no training\n") |
| (out_dir / "eval.log").write_text( |
| "\n".join( |
| [ |
| f"rollout_metrics={args.rollout_metrics}", |
| f"selector_metrics={args.selector_metrics}", |
| f"overall_pass={payload['overall_pass']}", |
| f"status={payload['status']}", |
| ] |
| ) |
| + "\n" |
| ) |
| print(json.dumps({"out_dir": str(out_dir), "overall_pass": payload["overall_pass"]}, indent=2)) |
| return 0 |
|
|
|
|
| def _observed_values(rollout: dict[str, Any], selector: dict[str, Any]) -> dict[str, Any]: |
| rollout_success = rollout.get("success_summary", {}) |
| rollout_summary = rollout.get("summary", {}) |
| selector_summary = selector.get("eval_summary", {}) |
| selected_success = _number(selector_summary.get("selected_success")) |
| proposal_oracle = _number(selector_summary.get("proposal_oracle_success")) |
| success_support_gap = _number(selector_summary.get("success_support_gap")) |
| success_selector_gap = _number(selector_summary.get("success_selector_gap")) |
| outcome_ptr = _summary_mean(rollout_summary, "outcome_ptr_at_16") |
| generated_unsafe = _summary_mean(rollout_summary, "generated_unsafe_rate_known_at_16") |
| selected_unsafe = _summary_mean(rollout_summary, "selected_unsafe_known_at_16") |
| base_unsafe = _summary_mean(rollout_summary, "base_unsafe_known") |
| if generated_unsafe is None: |
| generated_unsafe = _number(rollout_success.get("generated_unsafe_rate_known")) |
| if selected_unsafe is None: |
| selected_unsafe = _number(rollout_success.get("selected_unsafe_rate_known")) |
| if base_unsafe is None: |
| base_unsafe = _number(rollout_success.get("base_unsafe_rate_known")) |
| return { |
| "base_success": _number(selector_summary.get("base_success")), |
| "selected_success": selected_success, |
| "selected_success_ci": _summary_ci(selector.get("summary", {}), "selected_success"), |
| "target_selected_success": selected_success, |
| "proposal_oracle_success": proposal_oracle, |
| "proposal_oracle_success_ci": _summary_ci(selector.get("summary", {}), "proposal_oracle_success"), |
| "hidden_chart_oracle_success": _number(selector_summary.get("hidden_chart_oracle_success")), |
| "success_support_gap": success_support_gap, |
| "success_support_gap_ci": _summary_ci(rollout_summary, "success_support_gap_at_16"), |
| "success_selector_gap": success_selector_gap, |
| "success_selector_gap_ci": _summary_ci(selector.get("summary", {}), "success_selector_gap"), |
| "outcome_ptr": outcome_ptr, |
| "outcome_ptr_ci": _summary_ci(rollout_summary, "outcome_ptr_at_16"), |
| "v0_outcome_ptr_reference": 0.4435, |
| "generated_unsafe_rate_known": generated_unsafe, |
| "selected_unsafe_rate_known": selected_unsafe, |
| "base_unsafe_rate_known": base_unsafe, |
| "pairwise_causal_calibration_ece": _number(selector_summary.get("pairwise_causal_calibration_ece")), |
| "train_seeds": _train_seed_count(rollout, selector), |
| "bootstrap_ci_reported": _has_ci(selector.get("summary", {}), "selected_success") |
| and _has_ci(rollout_summary, "outcome_ptr_at_16") |
| and _has_ci(rollout_summary, "proposal_oracle_success_at_16"), |
| } |
|
|
|
|
| def _gates(values: dict[str, Any], args: argparse.Namespace) -> list[dict[str, Any]]: |
| selected = _number(values.get("selected_success")) |
| proposal = _number(values.get("proposal_oracle_success")) |
| support_gap = _number(values.get("success_support_gap")) |
| selector_gap = _number(values.get("success_selector_gap")) |
| outcome_ptr = _number(values.get("outcome_ptr")) |
| generated_unsafe = _number(values.get("generated_unsafe_rate_known")) |
| selected_unsafe = _number(values.get("selected_unsafe_rate_known")) |
| base_unsafe = _number(values.get("base_unsafe_rate_known")) |
| unsafe_limit = (base_unsafe if base_unsafe is not None else 0.0) + float(args.unsafe_slack) |
| return [ |
| _gate("selected clean success", selected, f">= {_fmt(args.selected_min)}", selected is not None and selected >= args.selected_min), |
| _gate( |
| "target selected clean success", |
| selected, |
| f">= {_fmt(args.target_selected_min)}", |
| selected is not None and selected >= args.target_selected_min, |
| ), |
| _gate( |
| "proposal oracle success", |
| proposal, |
| f">= {_fmt(args.proposal_oracle_min)}", |
| proposal is not None and proposal >= args.proposal_oracle_min, |
| ), |
| _gate( |
| "success support gap", |
| support_gap, |
| f"<= {_fmt(args.success_support_gap_max)}", |
| support_gap is not None and support_gap <= args.success_support_gap_max, |
| ), |
| _gate( |
| "success selector gap", |
| selector_gap, |
| f"<= {_fmt(args.success_selector_gap_max)}", |
| selector_gap is not None and selector_gap <= args.success_selector_gap_max, |
| ), |
| _gate( |
| "true OutcomePTR@16 vs V0", |
| outcome_ptr, |
| f"> {_fmt(args.v0_outcome_ptr_floor)}", |
| outcome_ptr is not None and outcome_ptr > args.v0_outcome_ptr_floor, |
| ), |
| _gate( |
| "generated unsafe rate", |
| generated_unsafe, |
| f"<= base+slack ({_fmt(unsafe_limit)})", |
| generated_unsafe is not None and generated_unsafe <= unsafe_limit, |
| ), |
| _gate( |
| "selected unsafe rate", |
| selected_unsafe, |
| f"<= base+slack ({_fmt(unsafe_limit)})", |
| selected_unsafe is not None and selected_unsafe <= unsafe_limit, |
| ), |
| _gate( |
| "three train seeds", |
| values.get("train_seeds"), |
| f">= {args.min_train_seeds}", |
| int(values.get("train_seeds") or 0) >= int(args.min_train_seeds), |
| ), |
| _gate( |
| "bootstrap confidence intervals", |
| float(bool(values.get("bootstrap_ci_reported"))), |
| "reported", |
| bool(values.get("bootstrap_ci_reported")), |
| ), |
| ] |
|
|
|
|
| def _gate(name: str, observed: Any, required: str, passed: bool) -> dict[str, Any]: |
| return { |
| "name": name, |
| "observed": observed, |
| "required": required, |
| "pass": bool(passed), |
| } |
|
|
|
|
| def _group_file_payload( |
| selector_metrics: Path, |
| rollout_metrics: Path, |
| filename: str, |
| ) -> dict[str, Any]: |
| return { |
| "selector_summary": _load_optional_json(selector_metrics.parent / filename), |
| "rollout_summary": _load_optional_json(rollout_metrics.parent / filename), |
| } |
|
|
|
|
| def _summary_mean(summary: dict[str, Any], key: str) -> float | None: |
| return _number(summary.get(key, {}).get("micro", {}).get("mean")) |
|
|
|
|
| def _summary_ci(summary: dict[str, Any], key: str) -> dict[str, float] | None: |
| micro = summary.get(key, {}).get("micro", {}) |
| low = _number(micro.get("low")) |
| high = _number(micro.get("high")) |
| mean = _number(micro.get("mean")) |
| if low is None or high is None or mean is None: |
| return None |
| return {"mean": mean, "low": low, "high": high} |
|
|
|
|
| def _has_ci(summary: dict[str, Any], key: str) -> bool: |
| return _summary_ci(summary, key) is not None |
|
|
|
|
| def _train_seed_count(rollout: dict[str, Any], selector: dict[str, Any]) -> int: |
| rollout_seeds = {str(seed) for seed in rollout.get("train_seeds", [])} |
| selector_seeds = { |
| str(row.get("train_seed")) |
| for row in selector.get("rows", []) |
| if row.get("train_seed") not in {None, "unknown"} |
| } |
| seeds = rollout_seeds | selector_seeds |
| return len(seeds) |
|
|
|
|
| def _table(gates: list[dict[str, Any]]) -> str: |
| lines = [ |
| "% Auto-generated by scripts/build_ctt_outcome_gate.py", |
| "\\begin{tabular}{lllc}", |
| "\\toprule", |
| "Gate & Observed & Required & Status \\\\", |
| "\\midrule", |
| ] |
| for gate in gates: |
| status = "pass" if gate["pass"] else "fail" |
| lines.append( |
| f"{_latex_escape(str(gate['name']))} & {_latex_escape(_fmt(gate['observed']))} & " |
| f"{_latex_required(str(gate['required']))} & {status} \\\\" |
| ) |
| lines.extend(["\\bottomrule", "\\end{tabular}"]) |
| return "\n".join(lines) |
|
|
|
|
| def _config(args: argparse.Namespace) -> str: |
| return "\n".join(f"{key}: {value}" for key, value in sorted(vars(args).items())) |
|
|
|
|
| def _load_json(path: Path) -> dict[str, Any]: |
| return json.loads(path.read_text()) |
|
|
|
|
| def _load_optional_json(path: Path) -> dict[str, Any]: |
| if not path.exists(): |
| return {} |
| return json.loads(path.read_text()) |
|
|
|
|
| def _number(value: Any) -> float | None: |
| if isinstance(value, (int, float)) and math.isfinite(float(value)): |
| return float(value) |
| return None |
|
|
|
|
| def _fmt(value: Any) -> str: |
| number = _number(value) |
| if number is None: |
| return "n/a" |
| return f"{number:.4f}" |
|
|
|
|
| def _latex_escape(value: str) -> str: |
| return ( |
| value.replace("\\", "\\textbackslash{}") |
| .replace("_", "\\_") |
| .replace("%", "\\%") |
| .replace("&", "\\&") |
| .replace("<", "$<$") |
| .replace(">", "$>$") |
| ) |
|
|
|
|
| def _latex_required(value: str) -> str: |
| return ( |
| _latex_escape(value) |
| .replace("$>$=", "$\\ge$") |
| .replace("$<$=", "$\\le$") |
| ) |
|
|
|
|
| def _run(command: list[str]) -> str: |
| try: |
| return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip() |
| except (subprocess.CalledProcessError, FileNotFoundError): |
| return "" |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|