/** * A/B benchmark orchestrator for pi-plans (I-002). * * PLAN_v3 run 20260909T103956Z-pi-plans-bench. * * Protocol: Terminal-Bench 2.0 (89 tasks) x 2 arms (baseline pi vs pi+pi-plans * forced-plan-big) x 1 seed, executed through the harbor framework with the * custom agent in `pi-adapter/pi_plans_bench.py`. * * Budget (D-016): main experiment $150 hard cap (incl. retries and the * discordant-pair sensitivity rerun); SWE-bench second benchmark is a separate * +$50 budget and degrades per R-009 if exceeded. Degradation order is * pre-registered: (1) reduce refine concurrency (3 -> 1 reviewer, skip * criticizer), (2) fall back to a stratified 40-task sample. The runner never * degrades silently: it prints the step and requires an explicit --degrade ack. * * Usage: * node --experimental-strip-types scripts/bench/run-ab.ts --prepare * node --experimental-strip-types scripts/bench/run-ab.ts --dry-run * node --experimental-strip-types scripts/bench/run-ab.ts --arm both --smoke * node --experimental-strip-types scripts/bench/run-ab.ts --arm both --full * node --experimental-strip-types scripts/bench/run-ab.ts --rerun-discordant --results-dir */ import * as fs from "node:fs"; import * as path from "node:path"; import { execSync, spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const REPO_ROOT = path.resolve(import.meta.dirname ?? ".", "../.."); const BENCH_DIR = path.join(REPO_ROOT, "scripts", "bench"); const ADAPTER_DIR = path.join(BENCH_DIR, "pi-adapter"); const VENDOR_HARBOR = path.join(BENCH_DIR, "vendor", "harbor"); const DEFAULT_RESULTS = path.join(BENCH_DIR, "results"); const MODEL = process.env.BENCH_MODEL ?? "zai/glm-5.3-flash:high"; const DATASET = process.env.BENCH_DATASET ?? "terminal-bench@2.0"; const DATASET_REF = process.env.BENCH_DATASET_REF ?? "2.0"; // task-set lock (Risk-007) const MAIN_BUDGET_USD = Number(process.env.BENCH_MAIN_BUDGET_USD ?? 150); const SECOND_BUDGET_USD = Number(process.env.BENCH_SECOND_BUDGET_USD ?? 50); const TB_TASK_COUNT = 89; // Terminal-Bench 2.0 task count (tbench.ai) interface ArmPlan { arm: "baseline" | "treatment"; jobDir: string; env: Record; } function parseArgs(argv: string[]): Record { const out: Record = {}; for (let i = 2; i < argv.length; i++) { const token = argv[i]; if (token.startsWith("--")) { const key = token.replace(/^--/, ""); const next = argv[i + 1]; if (next !== undefined && !next.startsWith("--")) { out[key] = next; i++; } else out[key] = true; } } return out; } function harborBin(): string { const resolved = spawnSync("bash", ["-lc", "command -v harbor"], { encoding: "utf8" }); const bin = resolved.stdout.trim(); if (!bin) { console.error( "harbor CLI not found. Install once: `uv tool install git+https://github.com/laude-institute/harbor` (or pip install from scripts/bench/vendor/harbor).", ); process.exit(1); } return bin; } /** Build the pi-plans bundle uploaded into the container by the treatment arm. */ function prepare(): void { const rev = execSync("git rev-parse HEAD", { cwd: REPO_ROOT, encoding: "utf8" }).trim(); const bundle = path.join(ADAPTER_DIR, "pi-plans-bundle.tar.gz"); // Bundle the WORKING TREE (not HEAD) so freshly implemented, not-yet-committed // code is what gets evaluated; record the base rev for provenance. exSyncTar(bundle); const size = fs.statSync(bundle).size; console.log(`[prepare] pi-plans bundle (working tree, base rev ${rev}) -> ${bundle} (${(size / 1024).toFixed(0)} KB)`); } function exSyncTar(bundle: string): void { const excludes = [ "--exclude=./.git", "--exclude=./node_modules", "--exclude=./scripts/bench/vendor", "--exclude=./scripts/bench/results", "--exclude=./scripts/bench/pi-adapter/pi-plans-bundle.tar.gz", "--exclude=.DS_Store", "--exclude=./docs/benchmarks", ]; execSync(`COPYFILE_DISABLE=1 tar -czf "${bundle}" ${excludes.join(" ")} .`, { cwd: REPO_ROOT }); } function armEnv(arm: "baseline" | "treatment"): Record { // D-019: benchmark env vars are injected into the harbor process here and // must never be exported into the host shell (checked by analyze/V-004). const env: Record = { PI_PLANS_BENCH_ARM: arm }; if (arm === "treatment") env.PI_PLANS_AUTO_APPROVE = "1"; return env; } function harborJobYaml(arm: "baseline" | "treatment", taskCount: number | null): string { // Task-set lock: dataset ref pins the registry version; the resolved // commit is recorded by harbor in the job lock (lock.json). const taskBlock = `tasks:\n - name: terminal-bench\n ref: "${DATASET_REF}"`; const agentBlock = [ "agents:", " - import_path: pi_plans_bench:PiPlansBench", ` model_name: "${MODEL}"`, ].join("\n"); return [ "# generated by scripts/bench/run-ab.ts -- do not edit", `# arm: ${arm}; dataset: ${DATASET}; model: ${MODEL}`, taskBlock, agentBlock, taskCount === null ? "" : `# task filter applied via --task-ids at invocation time`, "n_concurrent_trials: 4", "" , ].filter(Boolean).join("\n"); } function writeJobConfigs(resultsDir: string, arms: Array<"baseline" | "treatment">): void { for (const arm of arms) { const jobDir = path.join(resultsDir, arm); fs.mkdirSync(jobDir, { recursive: true }); fs.writeFileSync(path.join(jobDir, "job.yaml"), harborJobYaml(arm, null)); console.log(`[plan] ${arm} job config -> ${path.join(jobDir, "job.yaml")}`); } } function dryRun(resultsDir: string): void { const runs = TB_TASK_COUNT * 2; console.log("=".repeat(64)); console.log(`A/B dry-run plan (zero model calls made)`); console.log(` dataset : ${DATASET} (locked ref ${DATASET_REF})`); console.log(` model : ${MODEL}`); console.log(` arms : baseline (stock pi) vs treatment (pi-plans /plan-big + auto-approve)`); console.log(` protocol : ${TB_TASK_COUNT} tasks x 2 arms x 1 seed = ${runs} runs`); console.log(` budget caps : main $${MAIN_BUDGET_USD} + second benchmark $${SECOND_BUDGET_USD} (D-016)`); console.log(` degrade order : (1) refine 3->1 reviewer, skip criticizer; (2) stratified 40-task sample`); console.log(` metrics : resolve rate (oracle), $ (parent+subagent), turns, wall-time, plan rubric (treatment, descriptive)`); console.log(` stats (D-012) : seed-1 McNemar exact + paired bootstrap CI; rerun = sensitivity (majority vote)`); console.log("=".repeat(64)); writeJobConfigs(resultsDir, ["baseline", "treatment"]); console.log("[dry-run] no harbor/model invocation performed."); } function runArm(arm: "baseline" | "treatment", resultsDir: string, extra: string[]): void { const bin = harborBin(); const jobDir = path.join(resultsDir, arm); fs.mkdirSync(jobDir, { recursive: true }); const env = { ...process.env, ...armEnv(arm), PYTHONPATH: `${ADAPTER_DIR}${process.env.PYTHONPATH ? ":" + process.env.PYTHONPATH : ""}` }; const args = [ "run", "--dataset", DATASET, "--agent", "pi_plans_bench:PiPlansBench", "--model", MODEL, "--jobs-dir", jobDir, ...extra, ]; console.log(`[run] arm=${arm}\n cwd=${VENDOR_HARBOR}\n harbor ${args.join(" ")}`); const res = spawnSync(bin, args, { cwd: VENDOR_HARBOR, env, stdio: "inherit" }); if (res.status !== 0) { console.error(`[run] harbor exited with ${res.status} for arm ${arm}`); process.exit(res.status ?? 1); } } function sumCost(resultsDir: string): number { let total = 0; const walk = (dir: string): void => { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) walk(p); else if (entry.name === "result.json") { try { const data = JSON.parse(fs.readFileSync(p, "utf8")); total += Number(data?.agent_result?.cost_usd ?? data?.agent_context?.cost_usd ?? 0); } catch { /* skip unreadable trial */ } } } }; walk(resultsDir); return total; } function sumTokens(resultsDir: string): number { let total = 0; const walk = (dir: string): void => { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) walk(p); else if (entry.name === "result.json") { try { const data = JSON.parse(fs.readFileSync(p, "utf8")); const ctx = data?.agent_result ?? data?.agent_context ?? {}; total += Number(ctx?.n_input_tokens ?? 0) + Number(ctx?.n_output_tokens ?? 0); } catch { /* skip */ } } } }; walk(resultsDir); return total; } function budgetGate(resultsDir: string, isSecondBenchmark: boolean): void { const spent = sumCost(resultsDir); const cap = isSecondBenchmark ? SECOND_BUDGET_USD : MAIN_BUDGET_USD; // F-007: zai coding-plan pricing reports $0 — add a token-based gate so // the budget control is operative on zero-$ providers. const TOKEN_CAP = Number(process.env.BENCH_TOKEN_CAP ?? (isSecondBenchmark ? 25_000_000 : 75_000_000)); const tokens = sumTokens(resultsDir); console.log(`[budget] tokens so far: ${tokens.toLocaleString()} / ${TOKEN_CAP.toLocaleString()} cap`); if (tokens > TOKEN_CAP) { console.error(`[budget] TOKEN CAP EXCEEDED — pre-registered degradation applies (--degrade to acknowledge)`); if (!process.argv.includes("--degrade")) process.exit(2); } console.log(`[budget] spent so far: $${spent.toFixed(2)} / $${cap.toFixed(2)} (${isSecondBenchmark ? "second" : "main"} budget)`); if (spent > cap) { console.error( `[budget] CAP EXCEEDED. Pre-registered degradation (D-016): ` + (isSecondBenchmark ? "second benchmark exceeds its budget -> execute R-009 downgrade clause (record in tech note appendix)." : "step 1: reduce refine concurrency (3->1 reviewer, skip criticizer); step 2: stratified 40-task sample. Re-run with --degrade to acknowledge."), ); if (!process.argv.includes("--degrade")) process.exit(2); } } /** I-005: discordant-pair sensitivity rerun (D-012). Re-runs the seed-1 * discordant tasks at 2 additional seeds, both arms, into * /sensitivity//. Analysis aggregates by per-task * majority vote and reports SEPARATELY from the primary test. */ async function rerunDiscordant(resultsDir: string): Promise { // import lazily to keep the runner dependency-light const { collectTrials, pairTrials } = (await import(pathToFileURL(path.join(BENCH_DIR, "analyze.ts")).href)) as { collectTrials: (dir: string) => Array<{ taskId: string; arm: string; seed: number; resolved: boolean }>; pairTrials: (trials: any[]) => Array<{ taskId: string; baseline: { resolved: boolean }; treatment: { resolved: boolean } }>; }; const trials = collectTrials(resultsDir); const pairs = pairTrials(trials); const discordant = pairs .filter((p) => p.baseline.resolved !== p.treatment.resolved) .map((p) => p.taskId); if (discordant.length === 0) { console.log("[sensitivity] no discordant pairs at seed 1 — nothing to rerun."); return; } console.log(`[sensitivity] ${discordant.length} discordant pair(s): ${discordant.join(", ")}`); const bin = harborBin(); for (const seed of [2, 3]) { for (const arm of ["baseline", "treatment"] as const) { const outDir = path.join(resultsDir, "sensitivity", `seed-${seed}`, arm); fs.mkdirSync(outDir, { recursive: true }); const env = { ...process.env, ...armEnv(arm), PYTHONPATH: `${ADAPTER_DIR}${process.env.PYTHONPATH ? ":" + process.env.PYTHONPATH : ""}` }; const args = ["run", "--dataset", DATASET, "--agent", "pi_plans_bench:PiPlansBench", "--model", MODEL, "--jobs-dir", outDir, "--agent-timeout-multiplier", "3"]; for (const taskId of discordant) args.push("--include-task-name", taskId); console.log(`[sensitivity] seed ${seed} arm ${arm} -> harbor ${args.slice(0, 8).join(" ")} ... (${discordant.length} tasks)`); const res = spawnSync(bin, args, { cwd: VENDOR_HARBOR, env, stdio: "inherit" }); if (res.status !== 0) { console.error(`[sensitivity] harbor exited ${res.status} (seed ${seed}, ${arm})`); process.exit(res.status ?? 1); } } } console.log("[sensitivity] reruns complete; analyze.ts emits the majority-vote table."); } function main(): void { const args = parseArgs(process.argv); const resultsDir = (args["results-dir"] as string) ?? path.join(DEFAULT_RESULTS, new Date().toISOString().slice(0, 10)); fs.mkdirSync(resultsDir, { recursive: true }); if (args["prepare"]) { prepare(); return; } if (args["rerun-discordant"]) { void rerunDiscordant(resultsDir).catch((error) => { console.error(String(error)); process.exit(1); }); return; } if (args["dry-run"]) { dryRun(resultsDir); return; } const armArg = (args["arm"] as string) ?? "both"; const arms: Array<"baseline" | "treatment"> = armArg === "both" ? ["baseline", "treatment"] : [armArg as "baseline" | "treatment"]; const extra: string[] = []; if (args["smoke"]) { // V-003 smoke set: hello-world (+ second task when supported). extra.push("--include-task-name", process.env.BENCH_SMOKE_TASK ?? "bn-fit-modify"); } else if (args["task"]) { extra.push("--task", String(args["task"])); } else if (args["include-task-name"]) { for (const name of String(args["include-task-name"]).split(",")) extra.push("--include-task-name", name.trim()); } else if (args["sample-file"]) { // D-016 degrade step 2: stratified 40-task sample (one task per line). const names = fs.readFileSync(String(args["sample-file"]), "utf8").split("\n").map((l) => l.trim()).filter(Boolean); for (const name of names) extra.push("--include-task-name", name); console.log(`[plan] sample-file: ${names.length} task(s)`); } if (args["n-concurrent"]) extra.push("--n-concurrent", String(args["n-concurrent"])); // Forced-plan-big lifecycle (planning + criticizer + reviewer subagents + // execution + refinement) routinely exceeds harbor's default 1h agent // budget on flash-class models; 3x keeps the measurement honest instead of // censoring the treatment at its own overhead. extra.push("--agent-timeout-multiplier", String(args["agent-timeout-multiplier"] ?? "3")); // Degraded-network headroom: nvm/npm/pi installs can exceed harbor's 360s // setup cap when the VPN is slow (2026-09-11: 29 setup timeouts). extra.push("--agent-setup-timeout-multiplier", String(args["agent-setup-timeout-multiplier"] ?? "4")); prepare(); for (const arm of arms) runArm(arm, resultsDir, extra); budgetGate(resultsDir, args["second-benchmark"] === true); console.log(`[done] results in ${resultsDir}; analyze with scripts/bench/analyze.ts`); } main();