vla / workspace /scripts /build_ctt_outcome_gate.py
anhtld's picture
manual-outcome-gate-sync 2026-07-04T04:15:33Z workspace (part 4)
5f5a8f5 verified
Raw
History Blame Contribute Delete
14 kB
#!/usr/bin/env python
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())