/**
* Statistical analysis for the pi-plans A/B benchmark (I-003).
*
* Pre-registered rules (PLAN_v3 D-012) — do not adjust after seeing data:
* - Analysis unit: task (paired across arms).
* - PRIMARY: seed-1 full-set McNemar exact test on resolve outcomes +
* paired bootstrap CIs (10k resamples) for cost and turns.
* - SECONDARY (sensitivity): discordant-pair reruns aggregated by per-task
* majority vote across seeds; reported separately, never pooled into the
* primary test.
* - Cost includes parent + subagent usage (F-001/C-F001).
*
* Input: a results directory containing harbor trial outputs
* (a result.json per trial with task id + reward + agent context tokens/cost), or an
* explicit trials JSON for testing (see --input).
*
* Usage:
* node --experimental-strip-types scripts/bench/analyze.ts --results-dir
* node --experimental-strip-types scripts/bench/analyze.ts --input trials.json --out
*/
import * as fs from "node:fs";
import * as path from "node:path";
interface TrialRecord {
taskId: string;
arm: "baseline" | "treatment";
seed: number;
resolved: boolean;
costUsd: number;
inputTokens: number;
outputTokens: number;
turns: number;
wallTimeSec: number;
subagentCostUsd?: number;
subagentInputTokens?: number;
}
interface Pair {
taskId: string;
baseline: TrialRecord;
treatment: TrialRecord;
}
function binomialLowerTail(k: number, n: number): number {
// P(X <= k) under Bin(n, 0.5).
if (k < 0) return 0;
if (k >= n) return 1;
const p = 0.5;
let sum = 0;
const coeff = (a: number): number => {
let r = 1;
for (let i = 1; i <= a; i++) r = (r * (n - i + 1)) / i;
return r;
};
for (let i = 0; i <= k; i++) sum += coeff(i) * Math.pow(p, i) * Math.pow(1 - p, n - i);
return Math.min(1, sum);
}
/** Exact McNemar on paired binary outcomes: two-sided p = 2 * P(X <= min(b,c)).
* (b=3,c=0 -> 2*0.125 = 0.25; b=0,c=0 -> 1 by definition of no discordance.) */
export function mcnemarExact(b: number, c: number): { statistic: number; pValue: number } {
const n = b + c;
if (n === 0) return { statistic: 0, pValue: 1 };
const tail = binomialLowerTail(Math.min(b, c), n);
return { statistic: b - c, pValue: Math.min(1, 2 * tail) };
}
// F-008: deterministic bootstrap — fixed-seed PRNG so published CIs are
// byte-reproducible from the same input.
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function bootstrapCI(values: number[], iterations = 10_000, alpha = 0.05): {
mean: number;
ciLow: number;
ciHigh: number;
} {
const n = values.length;
if (n === 0) return { mean: NaN, ciLow: NaN, ciHigh: NaN };
let mean = 0;
for (const v of values) mean += v;
mean /= n;
const rand = mulberry32(20260912);
const means: number[] = [];
for (let it = 0; it < iterations; it++) {
let acc = 0;
for (let i = 0; i < n; i++) acc += values[(rand() * n) | 0];
means.push(acc / n);
}
means.sort((a, b2) => a - b2);
const lo = means[Math.floor((alpha / 2) * iterations)];
const hi = means[Math.ceil((1 - alpha / 2) * iterations) - 1];
return { mean, ciLow: lo, ciHigh: hi };
}
export function collectTrials(resultsDir: string): TrialRecord[] {
// Dedup: a repaired rerun appends a NEWER job dir for the same
// (arm, task, seed); the newest result.json wins (older attempts stay on
// disk for audit, analysis reflects the latest attempt).
const byKey = new Map();
// Arm detection by RELATIVE path SEGMENT under resultsDir — never a regex
// over the absolute path (the repo itself is .../Research/pi-plans/...,
// which would classify every trial as treatment). Archived attempts
// (non-authoritative) are excluded from the walk entirely.
const walk = (dir: string, rel: string): void => {
if (!fs.existsSync(dir)) return;
const segs = rel.split("/");
// Excluded subtrees: archived attempts (non-authoritative) and the
// sensitivity reruns (separate analysis — their records must never
// leak into the seed-1 primary via mtime dedup).
if (segs.includes("archive") || segs.includes("sensitivity")) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
const childRel = rel ? rel + "/" + entry.name : entry.name;
if (entry.isDirectory()) walk(p, childRel);
else if (entry.name === "result.json") {
try {
const mtime = fs.statSync(p).mtimeMs;
const data = JSON.parse(fs.readFileSync(p, "utf8"));
// harbor result shapes: agent_result carries the AgentContext;
// verifier rewards are nested (rewards.reward).
const ctx = data?.agent_result ?? data?.agent_context ?? {};
const startedAt = Date.parse(data?.agent_execution?.started_at ?? "");
const finishedAt = Date.parse(data?.agent_execution?.finished_at ?? "");
const wall = Number.isFinite(startedAt) && Number.isFinite(finishedAt) ? (finishedAt - startedAt) / 1000 : 0;
const segments = childRel.split("/");
const arm: "baseline" | "treatment" = segments.includes("treatment") ? "treatment" : "baseline";
const rec: TrialRecord = {
taskId: String(data?.task_name ?? data?.taskName ?? path.basename(path.dirname(p))),
arm,
seed: Number(data?.seed ?? 1),
resolved: Number(data?.verifier_result?.rewards?.reward ?? data?.verifier_result?.reward ?? 0) > 0,
costUsd: Number(ctx?.cost_usd ?? 0),
inputTokens: Number(ctx?.n_input_tokens ?? 0),
outputTokens: Number(ctx?.n_output_tokens ?? 0),
turns: Number(ctx?.metadata?.pi_plans_driver?.turns ?? 0),
wallTimeSec: wall,
subagentCostUsd: Number(ctx?.metadata?.pi_plans_subagent_usage?.totals?.cost ?? 0) || undefined,
subagentInputTokens: Number(ctx?.metadata?.pi_plans_subagent_usage?.totals?.input ?? 0) || undefined,
};
const key = rec.arm + ":" + rec.taskId + ":" + rec.seed;
const prev = byKey.get(key);
if (!prev || prev.mtime < mtime) byKey.set(key, { rec, mtime });
} catch { /* skip unreadable */ }
}
}
};
walk(resultsDir, "");
return [...byKey.values()].map((v) => v.rec);
}
export function pairTrials(trials: TrialRecord[]): Pair[] {
const byKey = new Map();
for (const t of trials) byKey.set(t.arm + ":" + t.taskId + ":" + t.seed, t);
const tasks = new Set(trials.filter((t) => t.arm === "baseline").map((t) => t.taskId));
const pairs: Pair[] = [];
for (const taskId of tasks) {
const baseline = byKey.get("baseline:" + taskId + ":1");
const treatment = byKey.get("treatment:" + taskId + ":1");
if (baseline && treatment) pairs.push({ taskId, baseline, treatment });
}
return pairs;
}
export function analyze(pairs: Pair[]): Record {
const b = pairs.filter((p) => p.baseline.resolved && !p.treatment.resolved).length;
const c = pairs.filter((p) => !p.baseline.resolved && p.treatment.resolved).length;
// F-002: count both-resolved directly (pairs.length - b - c is the
// concordant count, which also includes both-failed pairs).
const both = pairs.filter((p) => p.baseline.resolved && p.treatment.resolved).length;
const bothFailed = pairs.filter((p) => !p.baseline.resolved && !p.treatment.resolved).length;
const mc = mcnemarExact(b, c);
const baselineResolve = pairs.filter((p) => p.baseline.resolved).length;
const treatmentResolve = pairs.filter((p) => p.treatment.resolved).length;
const costDelta = pairs.map((p) => p.treatment.costUsd - p.baseline.costUsd);
const costCi = bootstrapCI(costDelta);
const turnsCi = bootstrapCI(pairs.map((p) => p.treatment.turns - p.baseline.turns));
// Cost proxy: when the provider price table is zero (zai coding plan),
// reported $ is 0 — token deltas are the meaningful cost dimension (C-004 note).
const tokensPerTask = (t: (typeof pairs)[number]["baseline"]): number => t.inputTokens + t.outputTokens;
const tokenDelta = pairs.map((p) => tokensPerTask(p.treatment) - tokensPerTask(p.baseline));
const tokenCi = bootstrapCI(tokenDelta);
const perResolvedCostTreatment = treatmentResolve
? pairs.reduce((acc, p) => acc + p.treatment.costUsd, 0) / treatmentResolve
: 0;
const perResolvedCostBaseline = baselineResolve
? pairs.reduce((acc, p) => acc + p.baseline.costUsd, 0) / baselineResolve
: 0;
const perResolvedTokensTreatment = treatmentResolve
? pairs.reduce((acc, p) => acc + tokensPerTask(p.treatment), 0) / treatmentResolve
: 0;
const perResolvedTokensBaseline = baselineResolve
? pairs.reduce((acc, p) => acc + tokensPerTask(p.baseline), 0) / baselineResolve
: 0;
return {
n_pairs: pairs.length,
resolve: {
baseline: baselineResolve,
treatment: treatmentResolve,
baseline_rate: pairs.length ? baselineResolve / pairs.length : NaN,
treatment_rate: pairs.length ? treatmentResolve / pairs.length : NaN,
},
mcnemar: { discordant_baseline_only: b, discordant_treatment_only: c, both_resolved: both, both_failed: bothFailed, p_value: mc.pValue },
cost_delta_usd: costCi,
token_delta_per_task: tokenCi,
turns_delta: turnsCi,
cost_per_resolved_task: { baseline: perResolvedCostBaseline, treatment: perResolvedCostTreatment },
tokens_per_resolved_task: { baseline: perResolvedTokensBaseline, treatment: perResolvedTokensTreatment },
};
}
function td0(v: number): string {
return v.toLocaleString("en-US", { maximumFractionDigits: 0 });
}
function fmtInt(v: number): string {
return Number.isFinite(v) ? v.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "n/a";
}
function renderMarkdown(report: Record): string {
const r = report.resolve;
const m = report.mcnemar;
const cd = report.cost_delta_usd;
const tk = report.token_delta_per_task;
const td = report.turns_delta;
const cpr = report.cost_per_resolved_task;
return [
"# pi-plans A/B — primary analysis (seed-1, paired, D-012)",
"",
`- Pairs (tasks): **${report.n_pairs}**`,
`- Resolve: baseline **${r.baseline}/${report.n_pairs}** (${(100 * r.baseline_rate).toFixed(1)}%) vs treatment **${r.treatment}/${report.n_pairs}** (${(100 * r.treatment_rate).toFixed(1)}%)`,
`- McNemar exact: discordant b=${m.discordant_baseline_only} (baseline-only), c=${m.discordant_treatment_only} (treatment-only), both=${m.both_resolved}; **p = ${m.p_value.toFixed(4)}**`,
`- Δcost per task (treatment−baseline): mean **$${cd.mean.toFixed(4)}**, 95% CI [$${cd.ciLow.toFixed(4)}, $${cd.ciHigh.toFixed(4)}] (bootstrap 10k, includes parent+subagent)`,
`- Δturns per task: mean **${td.mean.toFixed(2)}**, 95% CI [${td.ciLow.toFixed(2)}, ${td.ciHigh.toFixed(2)}]`,
`- Δtokens per task (in+out, treatment−baseline): mean **${td0(tk.mean)}**, 95% CI [${td0(tk.ciLow)}, ${td0(tk.ciHigh)}] — the $ columns are 0 when the provider price table is zero (zai coding plan); tokens are the cost dimension`,
`- per resolved task: baseline **$${cpr.baseline.toFixed(4)} / ${fmtInt(report.tokens_per_resolved_task.baseline)} tok** vs treatment **$${cpr.treatment.toFixed(4)} / ${fmtInt(report.tokens_per_resolved_task.treatment)} tok**`,
"",
"> Exploratory: pi-plans (forced-plan-big variant) on Terminal-Bench 2.0 / GLM-5.3-Flash / single seed (D-018). No generalization beyond this configuration.",
].join("\n");
}
/** D-012 sensitivity analysis: per-task majority vote across ALL seeds present
* under /sensitivity/ (plus the seed-1 outcome as one
* vote). Reported separately from the primary test — never pooled into it. */
export interface SensitivityTrial extends TrialRecord {
relSeed: number;
}
/** Sensitivity trials live under /sensitivity/seed-N// — the
* seed comes from the PATH (harbor result.json has no seed field). Each trial
* dir is analyzed in isolation via collectTrials (safe: that subtree contains
* no sensitivity/archive segments, so no contamination). */
export function collectSensitivityTrials(resultsDir: string): SensitivityTrial[] {
const out: SensitivityTrial[] = [];
const root = path.join(resultsDir, "sensitivity");
const walk = (dir: string, rel: string): void => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
const childRel = rel ? rel + "/" + entry.name : entry.name;
if (entry.isDirectory()) walk(p, childRel);
else if (entry.name === "result.json") {
try {
const seedMatch = childRel.match(/seed-(\d+)/);
if (!seedMatch) continue;
const recs = collectTrials(path.dirname(p));
if (recs.length > 0) out.push({ ...recs[recs.length - 1], relSeed: Number(seedMatch[1]) });
} catch { /* skip unreadable */ }
}
}
};
walk(root, "");
return out;
}
export function sensitivityVotes(
primaryPairs: Array<{ taskId: string; baseline: TrialRecord; treatment: TrialRecord }>,
rerun: SensitivityTrial[],
): Array<{ taskId: string; baselineVotes: number; treatmentVotes: number; winner: string }> {
// Votes: seed-1 primary outcome + each rerun seed, per arm. Tasks enter the
// table only if they are discordant at seed-1 (the rerun targets them).
const rows: Array<{ taskId: string; baselineVotes: number; treatmentVotes: number; winner: string }> = [];
for (const pair of primaryPairs) {
const b1 = pair.baseline.resolved;
const t1 = pair.treatment.resolved;
if (b1 === t1) continue;
let bv = (b1 ? 1 : 0);
let tv = (t1 ? 1 : 0);
for (const t of rerun) {
if (t.taskId !== pair.taskId) continue;
if (t.arm === "baseline") bv += t.resolved ? 1 : 0;
else tv += t.resolved ? 1 : 0;
}
const winner = bv > tv ? "baseline" : tv > bv ? "treatment" : "tie";
rows.push({ taskId: pair.taskId, baselineVotes: bv, treatmentVotes: tv, winner });
}
return rows;
}
function renderSensitivity(rows: Array<{ taskId: string; baselineVotes: number; treatmentVotes: number; winner: string }>): string {
const treatment = rows.filter((r) => r.winner === "treatment").length;
const baseline = rows.filter((r) => r.winner === "baseline").length;
const ties = rows.filter((r) => r.winner === "tie").length;
return [
"# Sensitivity analysis — discordant-pair reruns (D-012, NOT the primary test)",
"",
`- Tasks with rerun votes: ${rows.length} (each task: seed-1 + up to 2 rerun seeds, majority vote)`,
`- Majority winner: treatment **${treatment}** | baseline **${baseline}** | tie **${ties}**`,
"",
"| task | baseline votes | treatment votes | winner |",
"|---|---|---|---|",
...rows.map((r) => `| ${r.taskId} | ${r.baselineVotes} | ${r.treatmentVotes} | ${r.winner} |`),
"",
"> Sensitivity only: this table never replaces or augments the seed-1 McNemar primary result.",
].join("\n");
}
function main(): void {
const args: Record = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
const resultsDir = args["results-dir"] ?? path.join(import.meta.dirname ?? ".", "results");
const pairs = args["input"]
? pairTrials(JSON.parse(fs.readFileSync(args["input"], "utf8")))
: pairTrials(collectTrials(resultsDir));
if (pairs.length === 0) {
console.error("No paired trials found (need baseline:treatment at seed 1 per task).");
process.exit(1);
}
const report = analyze(pairs);
const outDir = args["out"] ?? path.dirname(args["input"] ?? ".");
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, "analysis.md"), renderMarkdown(report));
fs.writeFileSync(path.join(outDir, "analysis.json"), JSON.stringify(report, null, 2));
const csv = [
"task_id,baseline_resolved,treatment_resolved,baseline_cost_usd,treatment_cost_usd,baseline_turns,treatment_turns",
...pairs.map((p) =>
[p.taskId, +p.baseline.resolved, +p.treatment.resolved, p.baseline.costUsd, p.treatment.costUsd, p.baseline.turns, p.treatment.turns].join(","),
),
].join("\n");
fs.writeFileSync(path.join(outDir, "pairs.csv"), csv);
// Sensitivity (only when rerun data exists alongside the primary results)
if (!args["input"]) {
const rerun = collectSensitivityTrials(resultsDir);
const rows = sensitivityVotes(pairs, rerun);
if (rows.length > 0) {
fs.writeFileSync(path.join(outDir, "sensitivity.md"), renderSensitivity(rows));
fs.writeFileSync(
path.join(outDir, "sensitivity.csv"),
["task_id,baseline_votes,treatment_votes,winner", ...rows.map((r) => [r.taskId, r.baselineVotes, r.treatmentVotes, r.winner].join(","))].join("\n"),
);
console.log(`[sensitivity] ${rows.length} task(s) with rerun votes -> sensitivity.md`);
}
}
console.log(renderMarkdown(report));
}
if (process.argv[1] && process.argv[1].endsWith("analyze.ts")) main();