revert(levels): ship round 2's set — contrast selection measurably made it easier
Round 3 tested one hypothesis from round 2's data: levels the agent rated 3 carried 59% more 反差 than the rest, so select for it. With both rounds now at ~100 runs, the answer is in and it is no. The back band went from 2.39 ± 0.10 to 2.19 ± 0.08, roughly two standard errors apart, and the levels averaging 2.5 or better fell from 11 to 6. Contrast is real and it does correlate with being rated harder; selecting on it does not produce harder levels. The low retest reliability does not rescue the change either — r = 0.55 limits ranking one level against another, not the mean of eighteen levels measured a hundred times. So round 2's levels are the ones handed over, rebuilt from its runs via round-set --emitCampaign. select-50 cannot reproduce them: the pool it drew from has been rebuilt since, so the same flags now return different levels. The runs are the record. Rule bot on the shipped set: 50/50 cleared, dead taps 0.00, blind rotations 0.12, worst blind streak 1. 198 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,8 +40,23 @@ 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) })));
|
||||
// The round whose levels are actually being handed over — not necessarily the
|
||||
// last one run, since a round can be an experiment that gets reverted.
|
||||
const shippedKeys = new Set(levels.map(deepLevelKey));
|
||||
const SHIPPED = present.find(id => (sets[id] ?? []).filter(e => shippedKeys.has(deepLevelKey(e.level))).length >= 45) ?? LATEST;
|
||||
const sdOf = a => { const v = a.filter(Number.isFinite); const m = v.reduce((x, y) => x + y, 0) / v.length; return Math.sqrt(v.reduce((s, x) => s + (x - m) ** 2, 0) / v.length); };
|
||||
const perLevelDiff = (id, a, b) => (sets[id] ?? []).slice(a, b)
|
||||
.map(e => mean(e.runs.map(raw => Number(metrics(raw).rating?.difficulty)).filter(Number.isFinite)))
|
||||
.filter(Number.isFinite);
|
||||
const seOf = (id, a, b) => { const v = perLevelDiff(id, a, b); return sdOf(v) / Math.sqrt(v.length); };
|
||||
const hardCount = id => (sets[id] ?? []).filter(e => mean(e.runs.map(raw => Number(metrics(raw).rating?.difficulty)).filter(Number.isFinite)) >= 2.5).length;
|
||||
|
||||
// The headline numbers describe the levels being handed over, not whichever
|
||||
// round ran last — a round can be an experiment that gets reverted.
|
||||
const r1 = PREV ? flat(PREV) : [];
|
||||
const r2 = flat(LATEST);
|
||||
const r2 = flat(SHIPPED);
|
||||
|
||||
|
||||
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];
|
||||
@@ -194,9 +209,9 @@ a{color:var(--rail)}
|
||||
<div class="wrap">
|
||||
<h1>关卡制作流程复盘</h1>
|
||||
<p class="lede">这一轮做 50 关的过程里,最有用的结论是一个<b>否定结论</b>:原计划的「agent 打分 → 拟合权重 → 重选关卡」这个闭环<b>合不上</b>,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。</p>
|
||||
<p class="meta">最新批次 ${esc(LATEST)} · ${R1.n} 局 · 对照批次 ${esc(PREV ?? '—')} ${R1.r1n} 局 · 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
|
||||
<p class="meta">交付批次 ${esc(SHIPPED)} · 实测轮次 ${present.map(esc).join(' / ')} · 生成于 ${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>
|
||||
@@ -237,7 +252,9 @@ ${BANDS.map(([label, a, b]) => `<tr><td>${label}</td>${present.map(id => {
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<p><b>第三轮是一个被自己的数据否掉的假设,已经回退。</b>第二轮显示打 3 分的关比打 2 分的关反差高 59%,于是第三轮改成按反差选关——只动这一个变量,后档反差从 ${f1(bandStats(SHIPPED, 32, 50).contrast)} 提到 ${f1(bandStats(LATEST, 32, 50).contrast)},旋转深度不变。</p>
|
||||
<p>结果是后档<b>更简单了</b>:${f2(bandStats(SHIPPED, 32, 50).difficulty)} ± ${f2(seOf(SHIPPED, 32, 50))} 对 ${f2(bandStats(LATEST, 32, 50).difficulty)} ± ${f2(seOf(LATEST, 32, 50))},约两个标准误;平均分到 2.5 以上的关从 ${hardCount(SHIPPED)} 关掉到 ${hardCount(LATEST)} 关。评分复测只有 ${f2(retest(r => Number(r.m.rating?.difficulty)).r)},但那限制的是<b>逐关排序</b>,不妨碍组间均值——两轮各 ${(sets[SHIPPED] ?? []).reduce((s, e) => s + e.runs.length, 0)} 与 ${(sets[LATEST] ?? []).reduce((s, e) => s + e.runs.length, 0)} 局,这个差距站得住。</p>
|
||||
<p>所以交付的是第二版。反差仍然是策划点名的好玩机制,也确实和「被评得更难」相关,但<b>按它选关并不能造出更难的关</b>——相关不等于可操纵。这条写进规则 4:每个新做法都要能在数据里看到它挡掉/换来了什么,看不到就撤回。</p>
|
||||
|
||||
<h2>哪些信号复测得住</h2>
|
||||
<p>下面的 r 是在<b>这一批关卡</b>上现算的:同一个 agent 把每关玩了两次,${pairs.length} 对。不是引用更早、更小的样本。</p>
|
||||
@@ -316,4 +333,4 @@ ${RULES.map(r => `<div class="rule">
|
||||
</html>`;
|
||||
|
||||
writeFileSync(resolve('docs/level-process.html'), html);
|
||||
console.log(`流程复盘已生成:docs/level-process.html(R1 ${R1.n} 关实测,${RULES.length} 条规则)`);
|
||||
console.log(`流程复盘已生成:docs/level-process.html(交付批次 ${SHIPPED},${R1.n} 局;轮次 ${present.join('/')};${RULES.length} 条规则)`);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { layout } from '../src/geometry.js';
|
||||
import { metrics } from '../src/bench.js';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
|
||||
const AGENT = args.agent ?? 'R3 · grok xhigh';
|
||||
const AGENT = args.agent ?? 'R2 · grok xhigh';
|
||||
const OUT = resolve(args.out ?? 'docs/review-50.html');
|
||||
|
||||
const raw = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8'));
|
||||
|
||||
@@ -34,10 +34,31 @@ export function roundSet(round, dir = resolve('bench/runs')) {
|
||||
return [...byBoard.values()].sort((a, b) => (a.level.campaign?.index ?? 0) - (b.level.campaign?.index ?? 0));
|
||||
}
|
||||
|
||||
// Ship a round that was already played. Re-running select-50 cannot reproduce
|
||||
// one: the pool it drew from has since been rebuilt, so the same flags now
|
||||
// return different levels. The runs are the only exact record.
|
||||
function emitCampaign(levels) {
|
||||
writeFileSync(resolve('levels/campaign-50.json'),
|
||||
JSON.stringify({ generated: `round-set ${ROUND}`, version: 1, count: levels.length, levels }, null, 1));
|
||||
const chapterSize = 10;
|
||||
const chapters = Array.from({ length: Math.ceil(levels.length / chapterSize) },
|
||||
(_, i) => ['起步', '转起来', '看清路', '深一点', '收束'][i] ?? `第 ${i + 1} 章`);
|
||||
writeFileSync(resolve('src/levels-c50.js'),
|
||||
`// Generated by scripts/round-set.mjs --round=${ROUND} --emitCampaign — do not edit by hand.\n`
|
||||
+ `export const C50_CAMPAIGN = ${JSON.stringify({ id: 'c50', name: '五十关调优版', description: `按演出、反差与旋转深度选出的 50 关,难度递增。`, chapters, chapterSize }, null, 1)};\n`
|
||||
+ `export const C50_LEVELS = ${JSON.stringify(levels)};\n`);
|
||||
console.log('已写入 levels/campaign-50.json 与 src/levels-c50.js');
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const rows = roundSet(ROUND);
|
||||
const levels = rows.map(r => r.level);
|
||||
console.log(`${ROUND}:${levels.length} 关,共 ${rows.reduce((s, r) => s + r.runs.length, 0)} 局`);
|
||||
console.log(`旋转深度 ${JSON.stringify(levels.reduce((m, l) => (m[l.campaign?.rotations ?? '?'] = (m[l.campaign?.rotations ?? '?'] || 0) + 1, m), {}))}`);
|
||||
console.log(`家族 ${JSON.stringify(levels.reduce((m, l) => (m[l.campaign?.family ?? '?'] = (m[l.campaign?.family ?? '?'] || 0) + 1, m), {}))}`);
|
||||
if (args.out) { writeFileSync(resolve(args.out), JSON.stringify({ generated: `round-set ${ROUND}`, count: levels.length, levels }, null, 1)); console.log(`已写入 ${args.out}`); }
|
||||
if (args.emitCampaign) {
|
||||
if (levels.length !== 50) { console.error(`只反建出 ${levels.length} 关,不足 50,拒绝写入`); process.exit(1); }
|
||||
emitCampaign(levels);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user