fix(bench): rebuild each round's level set from its runs, not from a snapshot

The round comparison was about to be wrong. levels/campaign-50-r2.json was
copied from levels/campaign-50.json to preserve "the set round 2 played", but
select-50 writes that path in place and several selection experiments ran
between the round and the copy. The file kept 28 of the 50 boards round 2 had
actually played and 46 of round 3's — so the comparison was scoring one round
against another round's levels, and it read as round 3 regressing.

Runs store the whole board and its campaign index, so a round is its own record
and cannot drift. round-set.mjs rebuilds it, the report uses that, and the
snapshot file is gone rather than left lying around looking authoritative.

With the rounds correctly matched, round 3 is not a regression and not an
improvement: back band 2.17 against round 2's 2.39, on a rating that retests at
0.55, which is not enough to separate seventeen levels. The report says that
rather than picking whichever reading flatters the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 17:22:31 +08:00
co-authored by Claude Opus 5
parent dcdd4a0ada
commit 4aa0c28cef
9 changed files with 223558 additions and 85313 deletions
+49 -23
View File
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { trace } from '../src/core.js';
import { metrics } from '../src/bench.js';
import { roundSet } from './round-set.mjs';
import { deepLevelKey, legibility } from '../src/deep-lock.js';
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
@@ -29,23 +30,30 @@ for (const f of existsSync(resolve('bench/runs')) ? readdirSync(resolve('bench/r
runs.push({ raw: d, m: metrics(d) });
} catch { /* torn read */ }
}
// Rounds are matched to their own level set by board, not by id: the reselect
// between them reissued ids from the ordinal.
const inSet = new Set(levels.map(deepLevelKey));
const oldLevels = (() => { try { return JSON.parse(readFileSync(resolve('levels/campaign-50-r1.json'), 'utf8')).levels; } catch { return []; } })();
const inOld = new Set(oldLevels.map(deepLevelKey));
const round = (agent, set) => runs.filter(r => r.raw.agent === agent && r.raw.level?.tiles && set.has(deepLevelKey(r.raw.level)));
const r1 = round('R1 · grok xhigh', inOld);
const r2 = round('R2 · grok xhigh', inSet);
// Each round is rebuilt from its own runs. A disk snapshot of "the set round N
// played" cannot be trusted — select-50 writes campaign-50.json in place, so a
// selection experiment between the round and the copy replaces it silently, and
// one such copy kept only 28 of its round's 50 boards.
const ROUND_IDS = ['R1', 'R2', 'R3', 'R4'];
const sets = Object.fromEntries(ROUND_IDS.map(id => [id, roundSet(id)]).filter(([, rows]) => rows.length));
const present = Object.keys(sets);
const LATEST = present[present.length - 1] ?? 'R1';
const PREV = present[present.length - 2] ?? null;
const flat = id => (sets[id] ?? []).flatMap(e => e.runs.map(raw => ({ raw, m: metrics(raw) })));
const r1 = PREV ? flat(PREV) : [];
const r2 = flat(LATEST);
const rated = r2.filter(r => r.m.rating && Number.isFinite(Number(r.m.rating.difficulty)));
const byDiff = rated.reduce((m, r) => (m[r.m.rating.difficulty] = (m[r.m.rating.difficulty] || 0) + 1, m), {});
const topDiff = Object.entries(byDiff).sort((a, b) => b[1] - a[1])[0] ?? ['—', 0];
// Retest: the same agent played each level twice, so reliability is measurable
// on this very set instead of quoted from an older, smaller one.
// Retest pairs, wherever a round happened to play a level twice. Round 2 did it
// across the board by accident, which is where the reliability figures come from.
const pairsBy = new Map();
for (const r of r2) { const k = deepLevelKey(r.raw.level); if (!pairsBy.has(k)) pairsBy.set(k, []); pairsBy.get(k).push(r); }
const pairs = [...pairsBy.values()].filter(v => v.length >= 2).map(v => [v[0], v[1]]);
for (const id of present) for (const e of sets[id]) {
const rows = e.runs.map(raw => ({ raw, m: metrics(raw) }));
if (rows.length >= 2) pairsBy.set(`${id}:${deepLevelKey(e.level)}`, rows);
}
const pairs = [...pairsBy.values()].map(v => [v[0], v[1]]);
const sd = a => { const m = mean(a); return Math.sqrt(mean(a.map(v => (v - m) ** 2))); };
const pearson = (A, B) => { const sa = sd(A), sb = sd(B); if (!sa || !sb) return null; const ma = mean(A), mb = mean(B); return mean(A.map((v, i) => (v - ma) * (B[i] - mb))) / (sa * sb); };
const retest = get => {
@@ -58,6 +66,20 @@ const identical = pairs.filter(([a, b]) => sig(a) === sig(b)).length;
const ratingDisagreed = pairs.filter(([a, b]) => a.m.rating?.difficulty != null && b.m.rating?.difficulty != null && a.m.rating.difficulty !== b.m.rating.difficulty);
const disagreedOnSamePlay = ratingDisagreed.filter(([a, b]) => sig(a) === sig(b)).length;
// A band is a slice of the round's own ordering, and each level counts once no
// matter how many times it was played.
const bandStats = (id, a, b) => {
const slice = (sets[id] ?? []).slice(a, b);
const per = get => slice.map(e => mean(e.runs.map(raw => get(metrics(raw))).filter(Number.isFinite))).filter(Number.isFinite);
return {
n: slice.length,
difficulty: mean(per(m => Number(m.rating?.difficulty))),
stuck: mean(per(m => Number(m.rating?.stuck))),
blind: mean(per(m => m.blindRotations ?? 0)),
contrast: mean(slice.map(e => e.level.campaign?.contrast ?? 0)),
rotations: mean(slice.map(e => e.level.campaign?.rotations ?? 0)),
};
};
const bandOf = (rows, a, b) => rows.filter(r => { const i = levels.findIndex(l => deepLevelKey(l) === deepLevelKey(r.raw.level)); return i >= a && i < b; });
const BANDS = [['前档 L1–L12', 0, 12], ['中档 L13–L32', 12, 32], ['后档 L33–L50', 32, 50]];
@@ -172,9 +194,9 @@ a{color:var(--rail)}
<div class="wrap">
<h1>关卡制作流程复盘</h1>
<p class="lede">这一轮做 50 关的过程里,最有用的结论是一个<b>否定结论</b>:原计划的「agent 打分 → 拟合权重 → 重选关卡」这个闭环<b>合不上</b>,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。</p>
<p class="meta">实测批次 R2 · grok-4.6 xhigh · ${R1.n} 局(50 关,多数跑了两次)· 对照批次 R1 ${R1.r1n} 局 · 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
<p class="meta">最新批次 ${esc(LATEST)} · ${R1.n} 局 · 对照批次 ${esc(PREV ?? '—')} ${R1.r1n} 局 · 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
<h2>第二轮实测到了什么</h2>
<h2>最新一轮实测到了什么</h2>
<div class="tiles">
<div class="tile ${R1.cleared === R1.n ? 'ok' : 'miss'}"><div class="k">通关</div><div class="v">${R1.cleared}/${R1.n}</div><div class="n">无死局</div></div>
<div class="tile ${R1.dead === 0 ? 'ok' : 'miss'}"><div class="k">空点 / 关</div><div class="v">${f2(R1.dead)}</div><div class="n">目标 0</div></div>
@@ -185,7 +207,7 @@ a{color:var(--rail)}
<p>机械面干净,说明生成期的闸门是有效的:「动了很多次没结果」这类结构缺陷已经被挡在关卡集之外。<b>难度评分那一格是这两轮真正的发现</b>:机械上差着三倍的关卡,它一律给 ${R1.flat[0]} 分。更早的记录里这项的复测可信度是 0.93,那是个更小的样本——在这一批 ${pairs.length} 对复测上实测是 ${f2(retest(r => Number(r.m.rating?.difficulty)).r)},所以它既不区分、也不像以为的那么稳。</p>
<h2>两轮之间改了什么,效果如何</h2>
<p>第一轮唯一可用的读数是「整批偏易」,加上抱怨里反复出现的「转了没反应」。据此只改了两件事:给<b>连续空转</b>设闸门(最多一次转动没反应,此前有 11 关能连着两次),以及把难度曲线<b>写成旋转深度的配额</b>而不是指望它从排序里自己长出来。</p>
<p>第一轮唯一可用的读数是「整批偏易」,加上抱怨里反复出现的「转了没反应」。据此只改了两件事:给<b>连续空转</b>设闸门(最多一次转动没反应,此前有 11 关能连着两次),以及把难度曲线<b>写成旋转深度的配额</b>而不是指望它从排序里自己长出来。第三轮又只改了一件:桶内改按<b>反差</b>取。每轮一个变量,才看得出是哪个变量起的作用。</p>
<table>
<thead><tr><th>同一个规则机器人,两套关卡集</th><th class="n">第一版</th><th class="n">第二版</th></tr></thead>
<tbody>
@@ -199,17 +221,21 @@ ${botOld && botNew ? `
</table>
<p>要做的事一样多,浪费掉的动作少了一半。这是同一个玩家的对照,所以变的是关卡不是模型的心情。</p>
<table>
<thead><tr><th>grok 实测,按档</th><th class="n">难度</th><th class="n">无解感</th><th class="n">盲转/关</th><th class="n">旋转深度</th></tr></thead>
<thead><tr><th>grok 实测,按档(每关计一次,与它自己玩的那一版对齐)</th>${present.map(id => `<th class="n" colspan="2">${id}</th>`).join('')}</tr>
<tr><th></th>${present.map(() => '<th class="n">难度</th><th class="n">无解感</th>').join('')}</tr></thead>
<tbody>
${BANDS.map(([label, a, b]) => {
const rows = bandOf(r2, a, b);
const g = k => mean(rows.map(r => Number(r.m.rating?.[k])).filter(Number.isFinite));
const rot = mean(levels.slice(a, b).map(l => l.campaign?.rotations ?? 0));
return `<tr><td>${label}</td><td class="n">${f2(g('difficulty'))}</td><td class="n">${f2(g('stuck'))}</td><td class="n">${f2(mean(rows.map(r => r.m.blindRotations ?? 0)))}</td><td class="n">${f1(rot)}</td></tr>`;
}).join('\n')}
${BANDS.map(([label, a, b]) => `<tr><td>${label}</td>${present.map(id => {
const st = bandStats(id, a, b);
return `<td class="n">${f2(st.difficulty)}</td><td class="n">${f2(st.stuck)}</td>`;
}).join('')}</tr>`).join('\n')}
<tr><td>后档的关卡参数</td>${present.map(id => {
const st = bandStats(id, 32, 50);
return `<td class="n">${f1(st.rotations)} 转</td><td class="n">反差 ${f1(st.contrast)}</td>`;
}).join('')}</tr>
</tbody>
</table>
<p>第一轮三档的难度评分是一条平线。第二轮后档升到了 ${f2(mean(bandOf(r2, 32, 50).map(r => Number(r.m.rating?.difficulty)).filter(Number.isFinite)))},无解感也跟着抬起来——<b>这是评分第一次表现出梯度</b>,但只在按档平均之后。</p>
<p>第一轮三档几乎是一条平线。第二轮把后档的旋转深度从 ${f1(bandStats(present[0], 32, 50).rotations)} 提到 ${f1(bandStats(present[1] ?? present[0], 32, 50).rotations)} 转,后档评分随之升到 ${f2(bandStats(present[1] ?? present[0], 32, 50).difficulty)}——<b>评分第一次表现出梯度</b>,但只在按档平均之后。</p>
<p><b>第三轮是一次没被证实的假设。</b>第二轮的数据显示打 3 分的关比打 2 分的关反差高 59%,于是第三轮按反差选关,把后档反差从 ${f1(bandStats(present[1] ?? present[0], 32, 50).contrast)} 提到 ${f1(bandStats(LATEST, 32, 50).contrast)}、旋转深度不变。后档评分没有升,反而是 ${f2(bandStats(LATEST, 32, 50).difficulty)}。以这项评分 r = ${f2(retest(r => Number(r.m.rating?.difficulty)).r)} 的复测可信度,十几关上的这点差距读不出胜负——<b>该说的是「两版分不出来」,不是「反差有用」,也不是「反差没用」</b>。第三版留下来的理由是设计上的:反差是策划点名的好玩机制,而且它的家族分布更均衡(${present.length >= 2 ? '两家族 30/50 → 26/50' : ''}),这两条要靠人工审核裁定。</p>
<h2>哪些信号复测得住</h2>
<p>下面的 r 是在<b>这一批关卡</b>上现算的:同一个 agent 把每关玩了两次,${pairs.length} 对。不是引用更早、更小的样本。</p>