#!/usr/bin/env node
// Build docs/bench-reliability.html — what the agent bench can and cannot tell
// us about level quality, computed from bench/runs.
//
// node scripts/build-bench-report.mjs [--out=docs/bench-reliability.html]
//
// Every number in the page comes from the run files; nothing is hand-written.
// Batches are identified by the `agent` field, so a run's provenance (which
// model, which board format) is never guessed.
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { metrics } from '../src/bench.js';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const out = resolve(flags.out || 'docs/bench-reliability.html');
const RUNS = resolve('bench/runs');
// ---------- data ----------
const set = JSON.parse(readFileSync(resolve('levels/prototypes/d5.json'), 'utf8'));
const levels = Array.isArray(set) ? set : set.levels;
const meta = Object.fromEntries(levels.map((l, i) => [l.id, {
n: i + 1,
family: l.id.replace('proto-d5-', '').replace(/-r\d+-s\d+$/, ''),
lock: l.prototype ? Math.round(l.prototype.stats.lockedShare * 100) : null,
minRot: l.prototype ? l.prototype.stats.rotations : null,
cells: l.cells.length, tiles: l.tiles.length,
}]));
const BATCHES = [
{ tag: 'Grok Build CLI · xhigh', label: 'grok-4.6 · 旧盘面', short: 'grok旧', format: 'old' },
{ tag: 'pi · Grok 4.6 xhigh', label: 'grok-4.6 · 新盘面 · 第 1 次', short: 'grok新①', format: 'new' },
{ tag: 'pi · Grok 4.6 xhigh · 复测', label: 'grok-4.6 · 新盘面 · 第 2 次', short: 'grok新②', format: 'new' },
{ tag: 'Codex CLI · xhigh', label: 'codex-spark · 旧盘面', short: 'spark旧', format: 'old' },
{ tag: 'Codex CLI · gpt-6-astra low', label: 'gpt-6-astra · low · 新盘面', short: 'astra', format: 'new' },
{ tag: 'Codex CLI · spark xhigh', label: 'codex-spark · xhigh · 新盘面', short: 'spark新', format: 'new' },
{ tag: 'Kimi CLI · k3 high', label: 'kimi-k3 · high · 新盘面', short: 'kimi', format: 'new' },
];
// Batches run under the fixed board format, i.e. everything that can be
// compared across models without the format change confounding it.
const NEW_TAGS = ['pi · Grok 4.6 xhigh', 'pi · Grok 4.6 xhigh · 复测', 'Kimi CLI · k3 high',
'Codex CLI · gpt-6-astra low', 'Codex CLI · spark xhigh'];
const runs = readdirSync(RUNS).filter(f => f.endsWith('.json') && f !== 'index.json')
.map(f => { try { return JSON.parse(readFileSync(resolve(RUNS, f), 'utf8')); } catch { return null; } })
.filter(Boolean).filter(d => meta[d.level?.id]);
const byTag = {};
for (const b of BATCHES) {
const rows = {};
for (const d of runs) if (d.agent === b.tag) rows[meta[d.level.id].n] = { m: metrics(d), lv: meta[d.level.id], run: d.run };
byTag[b.tag] = rows;
}
const present = BATCHES.filter(b => Object.keys(byTag[b.tag]).length > 0);
// ---------- stats ----------
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
const sd = a => { const m = mean(a); return a.length ? Math.sqrt(mean(a.map(x => (x - m) ** 2))) : null; };
const pearson = (u, v) => {
const n = u.length; if (n < 3) return null;
const mu = mean(u), mv = mean(v);
let su = 0, sv = 0, suv = 0;
for (let i = 0; i < n; i++) { const a = u[i] - mu, c = v[i] - mv; su += a * a; sv += c * c; suv += a * c; }
return (su && sv) ? suv / Math.sqrt(su * sv) : null;
};
const METRICS = [
{ key: 'extraRot', label: '多转', kind: 'mech', get: m => m.rotations - m.minimumRotations, hint: '清盘旋转数 − 求解器最少旋转数' },
{ key: 'blindRotations', label: '盲转', kind: 'mech', get: m => m.blindRotations, hint: '转了一次,什么出口都没打开' },
{ key: 'wrongWayRotations', label: '转错方向', kind: 'mech', get: m => m.wrongWayRotations, hint: '另一边本来能打开' },
{ key: 'steps', label: '步数', kind: 'mech', get: m => m.steps },
{ key: 'decisions', label: '决策数', kind: 'mech', get: m => m.decisions, hint: '看了几次盘面' },
{ key: 'deadTaps', label: '空点', kind: 'mech', get: m => m.deadTaps, hint: '点了没动' },
{ key: 'actionsPerPiece', label: '动作÷棋子', kind: 'mech', get: m => m.actionsPerPiece },
{ key: 'r.stuck', label: '评分:无解感', kind: 'rating', get: m => Number(m.rating?.stuck) },
{ key: 'r.difficulty', label: '评分:难度', kind: 'rating', get: m => Number(m.rating?.difficulty) },
{ key: 'r.rotation', label: '评分:旋转意义', kind: 'rating', get: m => Number(m.rating?.rotation) },
{ key: 'r.clarity', label: '评分:清晰度', kind: 'rating', get: m => Number(m.rating?.clarity) },
{ key: 'r.fun', label: '评分:好玩', kind: 'rating', get: m => Number(m.rating?.fun) },
];
function pair(tagA, tagB) {
const A = byTag[tagA] || {}, B = byTag[tagB] || {};
const shared = Object.keys(A).filter(k => B[k]).map(Number).sort((a, b) => a - b);
return { shared, A, B };
}
function reliability(tagA, tagB) {
const { shared, A, B } = pair(tagA, tagB);
if (shared.length < 3) return null;
return METRICS.map(def => {
// Drop only the levels a metric is missing on, not the whole metric: a run
// still in progress has no rating yet, and losing every rating column to
// one unfinished level would hide exactly what this table is testing.
const ok = shared.filter(L => {
const a = def.get(A[L].m), b = def.get(B[L].m);
return a != null && b != null && !Number.isNaN(a) && !Number.isNaN(b);
});
if (ok.length < 3) return null;
const u = ok.map(L => def.get(A[L].m)), v = ok.map(L => def.get(B[L].m));
const r = pearson(u, v);
const spread = sd(u);
const same = u.filter((x, i) => x === v[i]).length;
const n = ok.length;
// A metric is usable for ranking levels only if it both separates levels
// and survives a repeat run.
const verdict = spread < 0.35 ? 'flat' : (r == null || r < 0.5) ? 'noise' : r >= 0.9 ? 'solid' : 'ok';
return { ...def, r, spread, same, n, verdict, u, v };
}).filter(Boolean);
}
// ---------- html helpers ----------
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const f2 = x => x == null ? '—' : (Math.round(x * 100) / 100).toFixed(2);
const f1 = x => x == null ? '—' : (Math.round(x * 10) / 10).toFixed(1);
const secs = ms => ms == null ? '—' : ms >= 10000 ? `${Math.round(ms / 1000)} s` : `${(ms / 1000).toFixed(1)} s`;
const VERDICT = {
solid: ['可用 · 高', 'good'], ok: ['可用', 'good'],
noise: ['噪声', 'signal'], flat: ['无区分度', 'warn'],
};
// Scatter of one metric across two runs of the same conditions.
function scatter(m, size = 168) {
const pad = 26, span = size - pad - 10;
const all = [...m.u, ...m.v];
const lo = Math.min(...all), hi = Math.max(...all);
const range = (hi - lo) || 1;
const X = x => pad + ((x - lo) / range) * span;
const Y = y => size - pad - ((y - lo) / range) * span;
const dots = m.u.map((x, i) => `
所有批次通关率都是 100%。这本身就是一条结论:通关率在这批内容上毫无区分度,任何流程都不该建立在通过/失败上。
' : `已完成的对局通关率全部是 100%${anyRunning ? '(个别批次仍有对局在进行中,未计入)' : ''}。通关率在这批内容上毫无区分度,任何流程都不该建立在通过/失败上。
`; // Agreement on the rotation triple across every batch that saw the fixed board. const liveTags = NEW_TAGS.filter(t => Object.keys(byTag[t] || {}).length); const triple = m => [m.rotations - m.minimumRotations, m.blindRotations, m.wrongWayRotations].join('/'); const crossRows = []; for (let L = 1; L <= levels.length; L++) { const cells = liveTags.map(t => byTag[t][L] ? triple(byTag[t][L].m) : null); if (cells.some(c => c == null)) continue; const lv = Object.values(meta).find(x => x.n === L); crossRows.push({ L, family: lv.family, cells, agree: new Set(cells).size === 1 }); } const agreeN = crossRows.filter(r => r.agree).length; const crossModel = crossRows.length < 3 ? '同盘面批次不足,跨模型比较尚未成立。
' : `下表是同一套盘面下所有批次的 多转 / 盲转 / 转错方向。不同厂商的模型、不同 harness、各自独立跑——如果这些数字真的由关卡决定,它们就该一样。
| 关 | 家族 | ${liveTags.map(t => `${esc(BATCHES.find(b => b.tag === t).short)} | `).join('')}一致 |
|---|---|---|---|
| L${r.L} | ${esc(r.family)} | ${r.cells.map(c => `${c} | `).join('')}${r.agree ? '✔' : '✘'} |
这是全份报告里最硬的一条结论。${agreeN}/${crossRows.length} 关上,独立运行的不同模型给出了完全相同的旋转三元组。这意味着规则机器人 12 秒扫出来的这三个数,等价于大模型跑几小时的结果——这个维度完全不需要 agent。
下面这些关在不同运行之间给出了不同的数字。排除模型差异后,剩下的解释是同一关的体验取决于起手方向——两个玩家玩同一关,一个觉得还行,一个觉得这关有毛病。这比"稳定地难"更值得处理。
${crossRows.filter(r => !r.agree).map(r => `同一批关卡、同一个模型、同一套盘面,跑两遍。能重现的指标才配进流程——这份报告把每个指标逐个检验了一遍,结论和直觉相反。
每一批用 agent 字段标识,不靠文件名猜来源。「旧盘面」指擦碰只报第一个阻挡者的版本,「新盘面」指修复后全部列出的版本。
模型名以 CLI 启动时打印的解析结果为准,而不是提示词里让 agent 自报的名字——有一批 18 局曾被标成 codex-spark,实际是配置在运行前被改掉、跑成了 gpt-6-astra/low,存档已按日志头更正。
| 批次 | 关数 | 通关 | 空点 | 擦碰空点 | 盲转 | 转错 | 多转 | 单局均 | 每决策 |
|---|---|---|---|---|---|---|---|---|---|
| ${esc(s.label)} | ${s.n} | ${s.cleared}/${s.n} | ${s.dead} | ${s.graze} | ${s.blind} | ${s.wrong} | ${s.extra} | ${secs(s.total)} | ${secs(s.dec)} |
同模型(grok-4.6 xhigh)、同 harness(pi)、同盘面(新)、同 ${clean[0].n} 关,只是又跑了一遍。两次之间唯一的变量就是运行本身,所以下面的相关系数就是这个指标的复测信度。
判定规则:关间标准差 < 0.35 说明这个指标在不同关卡上几乎一样,再稳也没用(无法排序);复测 r < 0.5 说明换一次运行结果就变,不能作为依据。两条都过才算可用。
| 指标 | 关间标准差 | 复测相关 r | 逐关完全一致 | 判定 |
|---|---|---|---|---|
| ${esc(m.label)}${m.hint ? ` ${esc(m.hint)}` : ''} | ${f2(m.spread)} | ${f2(m.r)} | ${m.same}/${m.n} | ${VERDICT[m.verdict][0]} |
「关间标准差」衡量这个指标能不能把不同关卡区分开——标准差接近 0 的指标就算完全可复现也没用,因为每关都一样。两个条件要同时满足:既能区分关卡,又能跑两遍不变。
虚线是「两次完全相同」。点贴着虚线 = 可复现;点散开 = 这个数字换一次运行就变。
` : '复测批次尚未完成。
'} ${confounded ? `上一节控制了所有变量。作为对照,这里是旧盘面 vs 新盘面的同 18 关配对——它多了一个变量(盘面变了),所以相关系数是信度的下限而非真值。两张表一起看,能分清「指标本身不稳」和「盘面变化导致的差异」。
| 指标 | 控制变量后 r | 换盘面后 r | 差异说明 |
|---|---|---|---|
| ${esc(m.label)} | ${f2(m.r)} | ${f2(c.r)} | ${note} |
把「多转」按路线家族平均。这是关卡结构问题,不是玩家问题——两批 grok、以及 spark,在这一点上给出一致的结果。
${present.filter(b => Object.keys(byTag[b.tag]).length >= 6).map(b => `orbits 和 spiral 是问题家族。它们稳定地需要 7 次旋转(最少 3 次)外加 4 次盲转——等于把 6 个朝向挨个试一遍。而 petals 和 crossing 是干净的 0。锁住率解释不了这个差异(L16 也是 orbits、锁 78%,却是 3/3)。
评分是 1–5 的数字,worst 是它自己写的一句话。下面按反复出现的主题归类——价值在重复模式,不在单条内容。
核心判断:机械计数器当主力,agent 做抽检。免费的规则机器人能扫全量并稳定给出盲转/多转/转错;agent 贵但能提供两样机器给不了的东西——难度的相对排序,和用自然语言点出的系统性缺陷。关键是只收集经过信度检验的那几项。
用已有求解器,在生成时就卡掉:最少旋转 ≥ 2、开局锁住率 ≤ 90%、首转两个方向都能推进、无死局状态。不合格的根本不进候选池。
产出盲转 / 多转 / 转错方向。按 盲转 ≥ 2 或 多转 ≥ 2 打标。这条规则在 D5 上正好命中 orbits+spiral 那几关,和两个大模型跑两小时得到的结论一致。
不是 1000 关全跑。收集三样东西:difficulty(r=0.93,可用来给 1000 关排序)、stuck(r=0.85,体感无解的直接信号)、以及 worst 文字(重复模式能挖出系统性缺陷)。丢掉 rotation / clarity / fun——复测 r 只有 0.32 / 0.19 / 0.56,是模型噪声。按实测 ${summaries.length ? secs(mean(summaries.filter(s => s.format === 'new').map(s => s.total))) : '—'}/关、4 路并行,150 关约半小时。
Agent 的难度排序可信(复测 r=0.93),但绝对值可能整体偏移——它觉得「刚好」的关,真人可能觉得太简单。所以真人只需要玩少数几关当锚点,把 agent 的相对排序映射到真实难度刻度上,不必逐关评。这比原本设想的省很多人力。
最该做的一件事:把它变成回归测试,而不是评审流程。改完生成器,12 秒跑一遍规则机器人,比较盲转分布。从 22% 降到 5% 就是客观改善,升上去就是刚引入了退化。12 秒一轮可以进 CI;几小时一轮且分数不可复现的人工评审,不可能成为迭代循环。
写清楚哪里不够硬,比多列几个数字重要。