#!/usr/bin/env python3 """Compare two paired v1 eval runs, conservatively counting scheduled episodes.""" from __future__ import annotations import argparse import json import math import tomllib from collections import Counter from pathlib import Path def wilson(successes: int, total: int, z: float = 1.959963984540054) -> list[float] | None: if total == 0: return None p = successes / total d = 1 + z * z / total c = (p + z * z / (2 * total)) / d r = z * math.sqrt(p * (1 - p) / total + z * z / (4 * total * total)) / d return [max(0.0, c - r), min(1.0, c + r)] def read_traces(path: Path) -> list[dict]: path = path / "traces.jsonl" if path.is_dir() else path by_id: dict[str, dict] = {} for line in path.read_text().splitlines(): record = json.loads(line) for trace in record.get("traces", []): by_id[trace["id"]] = trace return list(by_id.values()) def task_key(trace: dict) -> str: data = (trace.get("task") or {}).get("data") or {} return str(data.get("name") or data.get("instance_id") or data.get("idx") or trace["id"]) def clean(trace: dict) -> bool: return bool(trace.get("ok") and trace.get("is_completed") and not trace.get("errors")) def solved(trace: dict) -> bool: return float(((trace.get("rewards") or {}).get("solved") or {}).get("score", 0)) > 0 def has_reward(trace: dict) -> bool: return isinstance((trace.get("rewards") or {}).get("solved"), dict) def exact_two_sided_sign_p(a_only: int, b_only: int) -> float | None: """Exact paired sign-test p-value over discordant outcomes. This is deliberately dependency-free and conditions only on tasks where the two harnesses/checkpoints disagree. It is diagnostic rather than a reason to hide either run's conservative Wilson interval. """ discordant = a_only + b_only if discordant == 0: return None tail = sum(math.comb(discordant, k) for k in range(min(a_only, b_only) + 1)) return min(1.0, 2.0 * tail / (2**discordant)) def expected_episodes(path: Path, observed: int) -> int: run_dir = path if path.is_dir() else path.parent config_path = run_dir / "config.toml" if not config_path.is_file(): return observed config = tomllib.loads(config_path.read_text()) num_tasks = config.get("num_tasks") num_rollouts = config.get("num_rollouts", 1) if not isinstance(num_tasks, int) or not isinstance(num_rollouts, int): return observed return max(observed, num_tasks * num_rollouts) def summary(traces: list[dict], path: Path) -> dict: clean_traces = [trace for trace in traces if clean(trace)] scored = [trace for trace in traces if has_reward(trace)] successes = sum(solved(trace) for trace in traces) calls = [call for trace in traces for call in trace.get("calls", [])] prompt_tokens = sum(int((call.get("usage") or {}).get("prompt_tokens", 0)) for call in calls) completion_tokens = sum( int((call.get("usage") or {}).get("completion_tokens", 0)) for call in calls ) categories = Counter( str(((trace.get("task") or {}).get("data") or {}).get("category") or "unknown") for trace in traces if solved(trace) ) scheduled = expected_episodes(path, len(traces)) return { "scheduled_episodes": scheduled, "observed_trace_episodes": len(traces), "missing_trace_episodes": scheduled - len(traces), "clean_completed": len(clean_traces), "reward_bearing": len(scored), "errored_or_unscored": scheduled - len(scored), "successes": successes, "score_scheduled": successes / scheduled if scheduled else None, "ci95_wilson_scheduled": wilson(successes, scheduled), "diagnostic_score_reward_bearing": successes / len(scored) if scored else None, "diagnostic_ci95_reward_bearing": wilson(successes, len(scored)), "stop_conditions": dict(Counter(trace.get("stop_condition", "unknown") for trace in traces)), "model_calls": len(calls), "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "success_categories": dict(sorted(categories.items())), } def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("a", type=Path) parser.add_argument("b", type=Path) parser.add_argument("--label-a", default="a") parser.add_argument("--label-b", default="b") args = parser.parse_args() a_traces = read_traces(args.a) b_traces = read_traces(args.b) a = {task_key(trace): trace for trace in a_traces} b = {task_key(trace): trace for trace in b_traces} common = sorted(a.keys() & b.keys()) paired = Counter() for key in common: sa, sb = solved(a[key]), solved(b[key]) paired[ "both" if sa and sb else "a_only" if sa else "b_only" if sb else "neither" ] += 1 paired_delta = ( (paired["b_only"] - paired["a_only"]) / len(common) if common else None ) print( json.dumps( { args.label_a: summary(a_traces, args.a), args.label_b: summary(b_traces, args.b), "paired": { "common_scheduled_tasks": len(common), "both_solve": paired["both"], f"{args.label_a}_only": paired["a_only"], f"{args.label_b}_only": paired["b_only"], "neither": paired["neither"], f"score_delta_{args.label_b}_minus_{args.label_a}": paired_delta, "discordant_tasks": paired["a_only"] + paired["b_only"], "exact_two_sided_sign_test_p": exact_two_sided_sign_p( paired["a_only"], paired["b_only"] ), f"missing_{args.label_a}": len(b.keys() - a.keys()), f"missing_{args.label_b}": len(a.keys() - b.keys()), "both_clean_completed": sum(clean(a[key]) and clean(b[key]) for key in common), }, }, indent=2, ) ) if __name__ == "__main__": main()