91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
289 lines
10 KiB
Python
Executable File
289 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Calibrate KiCad task difficulty by running each task N times per model.
|
||
|
||
Usage:
|
||
python scripts/calibrate_tasks.py [--attempts N] [--parallel P] [--tasks-dir DIR]
|
||
|
||
Outputs:
|
||
scripts/tasks/calibration.json — per-task pass rates and difficulty tiers
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Config
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SCRIPT_DIR = Path(__file__).parent
|
||
TASKS_DIR = SCRIPT_DIR / "tasks"
|
||
OUTPUT_FILE = TASKS_DIR / "calibration.json"
|
||
|
||
MODELS = [
|
||
("claude", "anthropic/claude-opus-4-6"),
|
||
("openai", "openai/computer-use-preview"),
|
||
]
|
||
|
||
DEFAULT_ATTEMPTS = 5
|
||
DEFAULT_PARALLEL = 6
|
||
DEFAULT_MAX_STEPS = 150
|
||
|
||
# Difficulty tiers (from TBench spec, based on worst-model accuracy)
|
||
# Frontier: best model ≤ 20%
|
||
# Advanced Plus: worst model ≤ 20% (and not Frontier)
|
||
# Advanced: 20% < worst ≤ 60%
|
||
# Core: 60% < worst ≤ 80%
|
||
# Easy: worst > 80%
|
||
|
||
|
||
def _tier(claude_rate: float, openai_rate: float) -> str:
|
||
best = max(claude_rate, openai_rate)
|
||
worst = min(claude_rate, openai_rate)
|
||
if best <= 0.20:
|
||
return "frontier"
|
||
if worst <= 0.20:
|
||
return "advanced_plus"
|
||
if worst <= 0.60:
|
||
return "advanced"
|
||
if worst <= 0.80:
|
||
return "core"
|
||
return "easy"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _load_env() -> dict[str, str]:
|
||
env_file = SCRIPT_DIR.parent / ".env"
|
||
if env_file.exists():
|
||
for line in env_file.read_text().splitlines():
|
||
line = line.strip()
|
||
if line and not line.startswith("#") and "=" in line:
|
||
k, _, v = line.partition("=")
|
||
key, val = k.strip(), v.strip()
|
||
os.environ[key] = val # export into current process too
|
||
return os.environ.copy()
|
||
|
||
|
||
def _cb() -> str:
|
||
return str(SCRIPT_DIR.parent / ".venv" / "bin" / "cb")
|
||
|
||
|
||
def _run_dataset(model_name: str, model_id: str, parallel: int,
|
||
max_steps: int, tasks_dir: Path,
|
||
task_ids: list[str], attempts: int) -> str:
|
||
"""Build a temp dir with `attempts` copies of each task, run as one dataset."""
|
||
import tempfile, shutil
|
||
tmp = Path(tempfile.mkdtemp(prefix="cb_calib_"))
|
||
try:
|
||
for task_id in task_ids:
|
||
for i in range(attempts):
|
||
dst = tmp / f"{task_id}_{i}"
|
||
shutil.copytree(tasks_dir / task_id, dst)
|
||
cmd = [
|
||
_cb(), "run", "dataset", str(tmp),
|
||
"--agent", "cua-agent",
|
||
"--model", model_id,
|
||
"--max-parallel", str(parallel),
|
||
"--max-steps", str(max_steps),
|
||
]
|
||
print(f" [{model_name}] Starting {len(task_ids)} tasks × {attempts} attempts "
|
||
f"(parallel={parallel}): {' '.join(cmd)}")
|
||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(SCRIPT_DIR.parent))
|
||
for line in result.stdout.splitlines():
|
||
if "Run ID:" in line:
|
||
return line.split("Run ID:")[-1].strip().split()[0]
|
||
raise RuntimeError(f"Could not parse run ID:\n{result.stdout}\n{result.stderr}")
|
||
finally:
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def _wait_for_run(run_id: str, poll_interval: int = 30) -> None:
|
||
"""Poll cb run list until all sessions for this run are in a terminal state."""
|
||
cb = _cb()
|
||
print(f" Waiting for run {run_id}...", end="", flush=True)
|
||
while True:
|
||
result = subprocess.run(
|
||
[cb, "run", "list"],
|
||
capture_output=True, text=True,
|
||
cwd=str(SCRIPT_DIR.parent),
|
||
)
|
||
lines = [l for l in result.stdout.splitlines() if run_id[:8] in l]
|
||
if lines:
|
||
terminal = [l for l in lines if any(
|
||
s in l for s in ("completed", "failed", "error", "done", "0.", "1.")
|
||
)]
|
||
if len(terminal) == len(lines):
|
||
print(" done.")
|
||
return
|
||
print(".", end="", flush=True)
|
||
time.sleep(poll_interval)
|
||
|
||
|
||
def _get_run_output_dir(run_id: str) -> Path | None:
|
||
"""Find the output directory for a run from cb run info."""
|
||
cb = _cb()
|
||
result = subprocess.run(
|
||
[cb, "run", "info", run_id],
|
||
capture_output=True, text=True,
|
||
cwd=str(SCRIPT_DIR.parent),
|
||
)
|
||
for line in result.stdout.splitlines():
|
||
if "Output:" in line or "output" in line.lower():
|
||
parts = line.split(":", 1)
|
||
if len(parts) == 2:
|
||
p = Path(parts[1].strip())
|
||
if p.exists():
|
||
return p
|
||
# Fallback: search default location
|
||
default = Path.home() / ".local" / "share" / "cua-bench" / "runs" / run_id
|
||
if default.exists():
|
||
return default
|
||
return None
|
||
|
||
|
||
def _extract_scores(run_output_dir: Path) -> dict[str, float]:
|
||
"""Extract per-task scores from a run output directory.
|
||
|
||
Returns {task_id: score} where score is 0.0–1.0.
|
||
"""
|
||
scores: dict[str, float] = {}
|
||
if not run_output_dir or not run_output_dir.exists():
|
||
return scores
|
||
|
||
try:
|
||
from datasets import load_from_disk
|
||
except ImportError:
|
||
# Fallback: parse run.log for "Evaluation result:"
|
||
for log in run_output_dir.rglob("run.log"):
|
||
task_id = log.parent.name.split("_")[0]
|
||
for line in log.read_text().splitlines():
|
||
if "Evaluation result:" in line:
|
||
try:
|
||
scores[task_id] = float(line.split(":")[-1].strip())
|
||
except ValueError:
|
||
pass
|
||
return scores
|
||
|
||
for trace_dir in run_output_dir.rglob("task_*_trace"):
|
||
session_dir = trace_dir.parent
|
||
task_id = session_dir.name.split("_")[0]
|
||
try:
|
||
ds = load_from_disk(str(trace_dir))
|
||
for row in ds:
|
||
if row.get("event_name") == "evaluate":
|
||
data = json.loads(row["data_json"])
|
||
scores[task_id] = float(data.get("result", 0.0))
|
||
break
|
||
except Exception:
|
||
# Fallback to run.log
|
||
log = session_dir / "run.log"
|
||
if log.exists():
|
||
for line in log.read_text().splitlines():
|
||
if "Evaluation result:" in line:
|
||
try:
|
||
scores[task_id] = float(line.split(":")[-1].strip())
|
||
except ValueError:
|
||
pass
|
||
return scores
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Calibrate KiCad task difficulty")
|
||
parser.add_argument("--attempts", type=int, default=DEFAULT_ATTEMPTS,
|
||
help=f"Number of attempts per model (default: {DEFAULT_ATTEMPTS})")
|
||
parser.add_argument("--parallel", type=int, default=DEFAULT_PARALLEL,
|
||
help=f"Max parallel tasks per run (default: {DEFAULT_PARALLEL})")
|
||
parser.add_argument("--max-steps", type=int, default=DEFAULT_MAX_STEPS,
|
||
help=f"Max steps per task (default: {DEFAULT_MAX_STEPS})")
|
||
parser.add_argument("--tasks-dir", type=Path, default=TASKS_DIR)
|
||
parser.add_argument("--output", type=Path, default=OUTPUT_FILE)
|
||
parser.add_argument("--task-filter", type=str, default=None,
|
||
help="Comma-separated task IDs to run (e.g. '55f2eefb,d1c655da')")
|
||
args = parser.parse_args()
|
||
|
||
# Load .env into os.environ so cb and any subprocesses pick it up
|
||
_load_env()
|
||
|
||
# Discover task IDs (optionally filtered)
|
||
all_task_ids = sorted(
|
||
p.name for p in args.tasks_dir.iterdir()
|
||
if p.is_dir() and (p / "main.py").exists()
|
||
)
|
||
if args.task_filter:
|
||
patterns = [p.strip() for p in args.task_filter.split(",")]
|
||
task_ids = [t for t in all_task_ids if any(t.startswith(p) or t == p for p in patterns)]
|
||
else:
|
||
task_ids = all_task_ids
|
||
print(f"Found {len(task_ids)} tasks: {task_ids}")
|
||
|
||
# Accumulate scores: {task_id: {model_name: [score, ...]}}
|
||
all_scores: dict[str, dict[str, list[float]]] = {
|
||
tid: {m: [] for m, _ in MODELS} for tid in task_ids
|
||
}
|
||
|
||
for model_name, model_id in MODELS:
|
||
print(f"\n=== Model: {model_name} ({model_id}) ===")
|
||
for attempt in range(args.attempts):
|
||
run_id = _run_dataset(
|
||
model_name, model_id, attempt,
|
||
args.parallel, args.max_steps,
|
||
args.tasks_dir, task_ids,
|
||
)
|
||
_wait_for_run(run_id)
|
||
out_dir = _get_run_output_dir(run_id)
|
||
scores = _extract_scores(out_dir)
|
||
print(f" Attempt {attempt+1} scores: {scores}")
|
||
for task_id in task_ids:
|
||
score = scores.get(task_id, 0.0)
|
||
all_scores[task_id][model_name].append(score)
|
||
|
||
# Compute pass rates and tiers
|
||
results: dict[str, dict] = {}
|
||
for task_id in task_ids:
|
||
task_scores = all_scores[task_id]
|
||
rates = {
|
||
m: (sum(task_scores[m]) / len(task_scores[m]) if task_scores[m] else 0.0)
|
||
for m, _ in MODELS
|
||
}
|
||
tier = _tier(rates["claude"], rates["openai"])
|
||
results[task_id] = {
|
||
"pass_rates": rates,
|
||
"attempts": args.attempts,
|
||
"difficulty": tier,
|
||
"raw_scores": task_scores,
|
||
}
|
||
print(f" {task_id}: claude={rates['claude']:.2f} openai={rates['openai']:.2f} → {tier}")
|
||
|
||
# Summary
|
||
tier_counts: dict[str, int] = {}
|
||
for r in results.values():
|
||
tier_counts[r["difficulty"]] = tier_counts.get(r["difficulty"], 0) + 1
|
||
print(f"\nDifficulty distribution: {tier_counts}")
|
||
|
||
# Write manifest
|
||
manifest = {
|
||
"version": "1.0",
|
||
"models": {m: mid for m, mid in MODELS},
|
||
"attempts_per_model": args.attempts,
|
||
"tasks": results,
|
||
}
|
||
args.output.write_text(json.dumps(manifest, indent=2))
|
||
print(f"\nWrote {args.output}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|