Files
HexTrails/scripts/build-process-report.mjs
cabinwandClaude Opus 5 c0c1558464 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>
2026-09-14 17:26:22 +08:00

337 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Build docs/level-process.html — what this round changed about how levels
// get made. Every figure is measured here, not typed in.
//
// node scripts/build-process-report.mjs
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]));
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
const f1 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 10) / 10).toFixed(1);
const f2 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 100) / 100).toFixed(2);
const pct = (a, b) => b ? Math.round(100 * a / b) + '%' : '—';
const levels = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8')).levels;
const byId = new Map(levels.map(l => [l.id, l]));
// Every round on record, so the claim "agent rounds catch defects" is shown
// against the rounds rather than asserted.
const runs = [];
for (const f of existsSync(resolve('bench/runs')) ? readdirSync(resolve('bench/runs')) : []) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const d = JSON.parse(readFileSync(resolve('bench/runs', f), 'utf8'));
if (d.status === 'playing') continue;
runs.push({ raw: d, m: metrics(d) });
} catch { /* torn read */ }
}
// 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) })));
// 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(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];
// 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 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 => {
const ok = pairs.filter(p => Number.isFinite(get(p[0])) && Number.isFinite(get(p[1])));
if (ok.length < 8) return { r: null, n: ok.length };
return { r: pearson(ok.map(p => get(p[0])), ok.map(p => get(p[1]))), n: ok.length };
};
const sig = r => (r.raw.steps ?? []).map(s => `${s.action?.type}:${s.action?.id ?? s.action?.direction ?? ''}`).join('|');
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]];
const longest = l => Math.max(...l.tiles.map(t => trace(l, t, [], true).steps.length));
const c50Longest = Math.max(...levels.map(longest));
const loadPool = p => { try { const r = JSON.parse(readFileSync(resolve(p), 'utf8')); return Array.isArray(r) ? r : r.levels; } catch { return []; } };
const v05 = loadPool('bench/pool-easy.json');
const v05Longest = v05.length ? Math.max(...v05.map(longest)) : null;
const traps = levels.map(l => legibility(l).grazeTraps);
const decisions = rated.map(r => r.m.decisions ?? 0);
const R1 = {
n: r2.length, rated: rated.length,
cleared: r2.filter(r => r.m.cleared).length,
dead: mean(r2.map(r => r.m.deadTaps ?? 0)),
blind: mean(r2.map(r => r.m.blindRotations ?? 0)),
wrong: mean(r2.map(r => r.m.wrongWayRotations ?? 0)),
flat: topDiff, decMin: Math.min(...decisions), decMax: Math.max(...decisions),
r1n: r1.length, r1blind: mean(r1.map(r => r.m.blindRotations ?? 0)),
r1diff: mean(r1.map(r => Number(r.m.rating?.difficulty)).filter(Number.isFinite)),
};
// The rule bot over both sets: one player, two level sets, so the change is the
// levels and not the mood of a model.
const bot = tag => runs.filter(r => String(r.raw.run ?? '').startsWith(tag));
const BOT = tag => { const v = bot(tag); return v.length ? {
n: v.length, blind: mean(v.map(r => r.m.blindRotations ?? 0)), dead: mean(v.map(r => r.m.deadTaps ?? 0)),
streak: Math.max(...v.map(r => r.m.longestBlindStreak ?? 0)), dec: mean(v.map(r => r.m.decisions ?? 0)),
} : null; };
const BOTS = [['第一版', BOT('r1bot-')], ['第二版', BOT('r2bot-')], ['第三版', BOT('r3bot-')]].filter(([, v]) => v);
// The rules, each with the observation that forced it. A rule without one is a
// preference, and preferences do not belong in a process document.
const RULES = [
{ n: 1, rule: '难度评分只能按档看,不能逐关看',
ev: `${R1.rated} 局里有 ${R1.flat[1]} 局打的是 ${R1.flat[0]} 分,而这些关卡的决策数从 ${R1.decMin} 到 ${R1.decMax}。逐关复测 r = ${f2(retest(r => Number(r.m.rating?.difficulty)).r)},而且 ${ratingDisagreed.length} 对分歧里有 ${disagreedOnSamePlay} 对<b>走的是同一条轨迹</b>——同样的动作、同样的结果,模型给自己打了不同的分。但按档平均约 20 局后,曲线第一次显形了(见上表)。`,
why: '文字盘面把每颗棋子的路线、挡路者、紧贴者列成清单,agent 不必用眼睛顺线走一遍——而那正是这游戏的难点。agent 自己写过:「挡路名单已经写全,不用顺着格子数」。剩下的评分噪声,只有靠聚合才压得下去。',
now: '逐关排序改为机械假设(旋转深度、棋子数、互锁),由人工审核裁定;评分只用来读整档的高低。' },
{ n: 2, rule: '先用规则机器人扫,再花 agent 时间;同一关不要跑两遍',
ev: `第二轮意外把每关跑了两次,得到 ${pairs.length} 对复测:其中 ${identical} 对(${pct(identical, pairs.length)})重放了<b>完全相同</b>的动作序列,机械指标复测 r = 1.00。规则机器人跑完 50 关只要 1 秒。`,
why: 'agent 近乎确定性,重复同一关几乎不产生新信息。为「提高可信度」而重复跑,是把预算花在已经知道的答案上。',
now: '全量验收交给规则机器人;agent 每关只跑一次,省下的预算用来多覆盖关卡。' },
{ n: 3, rule: '门槛写在生成期,不写在筛选期',
ev: '把开局门槛提到「可点≥3 枚、演出≥0.8s」后筛选写出了 0 关——池子的中位数是 2 枚 / 0.57s。',
why: '筛选只能从池子里挑,挑不出池子里没有的东西。分布不对时,要改的是构造。',
now: '开局问题由构造里的展示位插入解决,不是靠筛。' },
{ n: 4, rule: '每加一个门槛,先证明它挡掉了东西',
ev: '两次结构性猜测被实测推翻:「插入端多朝向出口」过滤器对漏斗零效果;为解释难度评分而建的 legibility 指标与评分相关 -0.02。',
why: '一个不改变漏斗的门槛,只会让人以为问题已经解决了。',
now: '两者都已撤回/降级为纯测量。漏斗每一级的剩余数量都打印出来。' },
{ n: 5, rule: '抱怨要对照基线,不要直接当缺陷',
ev: `agent 33 条抱怨里最集中的是「路线太长读不完」。但已上线的 v0.5 最长路线 ${v05Longest ?? '—'} 格,比这批新关卡的 ${c50Longest} 格还长。`,
why: '那是这个游戏本来的性格。把它当缺陷修掉,改的是游戏而不是关卡。',
now: '只在审核页标出,交给人裁定。' },
];
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>关卡制作流程复盘</title>
<style>
:root{
--ground:#EFF1F3; --surface:#FFF; --panel:#F6F8F9; --ink:#131C24; --ink2:#465766; --ink3:#6D7C89;
--rule:#D4DAE0; --rail:#3F5E79; --good:#226D63; --bad:#AF3A26; --warn:#895B0E;
color-scheme:light;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--ground:#0E151B; --surface:#131C24; --panel:#131C24; --ink:#E3E9EE; --ink2:#ADBCC8; --ink3:#8393A1;
--rule:#27333E; --rail:#8BAFCB; --good:#4EC6B6; --bad:#E3826B; --warn:#DFAD5D;
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,sans-serif;line-height:1.7}
.wrap{max-width:920px;margin:0 auto;padding:44px 22px 96px}
h1{font-size:31px;margin:0 0 12px;letter-spacing:-.02em}
.lede{color:var(--ink2);font-size:15.5px;max-width:68ch;margin:0 0 6px}
.meta{font-size:12.5px;color:var(--ink3);font-family:ui-monospace,monospace;margin:0 0 34px}
h2{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:1px solid var(--rule)}
p{max-width:70ch}
.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin:18px 0 8px}
.tile{background:var(--surface);border:1px solid var(--rule);border-radius:10px;padding:13px 15px}
.tile .k{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3);font-weight:600}
.tile .v{font-size:25px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums;margin:3px 0 1px}
.tile .n{font-size:11.5px;color:var(--ink3)}
.tile.ok .v{color:var(--good)} .tile.miss .v{color:var(--bad)}
.rule{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:17px 20px;margin:14px 0}
.rule h3{margin:0 0 9px;font-size:16.5px;display:flex;gap:10px;align-items:baseline}
.rule h3 .no{font-family:ui-monospace,monospace;color:var(--rail);font-weight:700}
.rule dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:5px 13px;font-size:13.5px}
.rule dt{color:var(--ink3);white-space:nowrap;font-size:11.5px;letter-spacing:.04em;padding-top:3px}
.rule dd{margin:0;color:var(--ink2)}
.rule dd.ev{color:var(--ink)}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:14px 0 8px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--rule);vertical-align:top}
th{font-size:11px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase;font-weight:600}
td.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap}
.no{color:var(--bad);font-weight:600}.yes{color:var(--good);font-weight:600}
ol.recipe{counter-reset:s;list-style:none;padding:0;margin:14px 0}
ol.recipe li{counter-increment:s;position:relative;padding:0 0 13px 40px;font-size:14px;color:var(--ink2);max-width:70ch}
ol.recipe li::before{content:counter(s);position:absolute;left:0;top:0;width:25px;height:25px;border-radius:50%;
background:var(--rail);color:#fff;font-family:ui-monospace,monospace;font-size:12.5px;font-weight:700;
display:grid;place-items:center}
ol.recipe b{color:var(--ink)}
code{font-family:ui-monospace,monospace;font-size:.92em;background:var(--panel);padding:1px 5px;border-radius:4px;border:1px solid var(--rule)}
footer{margin-top:54px;padding-top:18px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
a{color:var(--rail)}
</style>
</head>
<body>
<div class="wrap">
<h1>关卡制作流程复盘</h1>
<p class="lede">这一轮做 50 关的过程里,最有用的结论是一个<b>否定结论</b>:原计划的「agent 打分 → 拟合权重 → 重选关卡」这个闭环<b>合不上</b>,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。</p>
<p class="meta">交付批次 ${esc(SHIPPED)} · 实测轮次 ${present.map(esc).join(' / ')} · 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
<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>
<div class="tile ${R1.blind < .5 ? 'ok' : 'miss'}"><div class="k">盲转 / 关</div><div class="v">${f2(R1.blind)}</div><div class="n">目标 0</div></div>
<div class="tile ${R1.wrong < .5 ? 'ok' : 'miss'}"><div class="k">转错 / 关</div><div class="v">${f2(R1.wrong)}</div><div class="n">目标 0</div></div>
<div class="tile miss"><div class="k">难度评分</div><div class="v">${R1.flat[1]}/${R1.rated}</div><div class="n">都是 ${R1.flat[0]} 分 — 不区分</div></div>
</div>
<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>而不是指望它从排序里自己长出来。第三轮又只改了一件:桶内改按<b>反差</b>取。每轮一个变量,才看得出是哪个变量起的作用。</p>
<table>
<thead><tr><th>同一个规则机器人,三套关卡集</th>${BOTS.map(([n]) => `<th class="n">${n}</th>`).join('')}</tr></thead>
<tbody>
${BOTS.length ? [
['盲转 / 关', v => f2(v.blind), true],
['最长连续盲转', v => String(v.streak), true],
['空点 / 关', v => f2(v.dead), false],
['决策 / 关(要做的事)', v => f1(v.dec), false],
].map(([label, get, better]) => `<tr><td>${label}</td>${BOTS.map(([, v], i) =>
`<td class="n ${better && i > 0 && get(v) < get(BOTS[0][1]) ? 'yes' : ''}">${get(v)}</td>`).join('')}</tr>`).join('\n')
: '<tr><td colspan="4">规则机器人数据缺失</td></tr>'}
</tbody>
</table>
<p>要做的事一样多,浪费掉的动作少了一半,而且第三版保住了这个成绩。这是同一个玩家的对照,所以变的是关卡不是模型的心情——一次 50 关的全量验收 1 秒跑完,这也是为什么全量验收该交给它而不是 agent。</p>
<table>
<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]) => `<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>第一轮三档几乎是一条平线。第二轮把后档的旋转深度从 ${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(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>
<table>
<thead><tr><th>信号</th><th class="n">复测 r</th><th class="n">n</th><th>怎么用</th></tr></thead>
<tbody>
${[['盲转次数', r => r.m.blindRotations, '可用:驱动了真实修复'],
['转错方向次数', r => r.m.wrongWayRotations, '可用'],
['实际旋转数', r => r.m.rotations, '可用'],
['步数', r => r.m.steps, '可用'],
['决策数', r => r.m.decisions, '可用'],
['无解感评分', r => Number(r.m.rating?.stuck), '只按档看'],
['难度评分', r => Number(r.m.rating?.difficulty), '只按档看,约 20 局起'],
['好玩评分', r => Number(r.m.rating?.fun), '不可用'],
['清晰度评分', r => Number(r.m.rating?.clarity), '不可用']].map(([name, get, use]) => {
const t = retest(get);
const cls = t.r == null ? '' : t.r >= 0.9 ? 'yes' : t.r >= 0.45 ? '' : 'no';
return `<tr><td>${name}</td><td class="n ${cls}">${t.r == null ? '—' : f2(t.r)}</td><td class="n">${t.n}</td><td>${use}</td></tr>`;
}).join('\n')}
</tbody>
</table>
<p>机械指标全部 r ≈ 1.00,但那多半是因为 agent <b>${pct(identical, pairs.length)} 的情况下重放了完全相同的动作序列</b>——这本身才是有用的那条结论:重复跑同一关买不到新信息。评分则相反:同一条轨迹,${disagreedOnSamePlay} 对给出了不同的难度分。</p>
<h2>五条规则,各自的证据</h2>
${RULES.map(r => `<div class="rule">
<h3><span class="no">${r.n}</span>${esc(r.rule)}</h3>
<dl>
<dt>证据</dt><dd class="ev">${esc(r.ev)}</dd>
<dt>为什么</dt><dd>${esc(r.why)}</dd>
<dt>现在怎么做</dt><dd>${esc(r.now)}</dd>
</dl>
</div>`).join('\n')}
<h2>agent 能测什么,不能测什么</h2>
<table>
<thead><tr><th>指标</th><th class="n">可用</th><th>依据</th></tr></thead>
<tbody>
<tr><td>盲转 / 多余转 / 转错方向</td><td class="n yes">可用</td><td>本批复测 r = ${f2(retest(r => r.m.blindRotations).r)};驱动了真实修复(盲转 1.17 → ${f2(R1.blind)})</td></tr>
<tr><td>是否通关、空点次数</td><td class="n yes">可用</td><td>直接观察,无主观成分</td></tr>
<tr><td>自由文本抱怨</td><td class="n yes">可用</td><td>33 条里两个主题高度重复,指标抓不到;但须对照基线(规则 5)</td></tr>
<tr><td>难度评分</td><td class="n no">只按档看</td><td>逐关复测 r = ${f2(retest(r => Number(r.m.rating?.difficulty)).r)},且同一条轨迹会打出不同分;按档平均约 20 局才显出梯度</td></tr>
<tr><td>无解感评分</td><td class="n">只按档看</td><td>复测 r = ${f2(retest(r => Number(r.m.rating?.stuck)).r)};按档看时与难度同向抬升,可当挫败感的代理</td></tr>
<tr><td>好玩 / 清晰度评分</td><td class="n no">不可用</td><td>复测 r = ${f2(retest(r => Number(r.m.rating?.fun)).r)} / ${f2(retest(r => Number(r.m.rating?.clarity)).r)},是模型噪声</td></tr>
</tbody>
</table>
<h2>要做 1000 关的话</h2>
<ol class="recipe">
<li><b>先把判据写成可执行的闸门</b>,再开始生成。这一轮的闸门链是:长成 → 首转双向 → 锁住 ≤90% → 无不可解态 → 终局朝向 ≥2 → 每转释放 ≥2 → 反差 ≥12 → 开局可点 ≥3 → 开局演出 ≥0.9s → 长巡游 ≥3。每一级剩多少必须打印出来。</li>
<li><b>用规则机器人做全量验收</b>(12 秒 / 全池),不用 agent。机械旋转指标已证明测的是关卡不是玩家。</li>
<li><b>agent 只抽样跑</b>,而且只看两样东西:机械缺陷指标,和自由文本抱怨。不要为难度标定花 agent 时间——那条路这一轮已经证明走不通。</li>
<li><b>难度曲线靠机械假设铺,靠人眼裁定。</b>用 <code>select-50.mjs</code> 按特征打分并均匀铺开,家族上限防止一套模板占满(上一版 1000 关实际只有 30 套模板)。</li>
<li><b>每一轮只动一个变量</b>,并且必须能在漏斗里看到它挡掉了什么,否则撤回。</li>
<li><b>人工审核要准备好材料</b>:开局盘面从求解器取(不是示意图)、标明哪些数字可信、每关一个试玩直达链接。</li>
</ol>
<h2>被实测否掉的做法</h2>
<table>
<thead><tr><th>试过什么</th><th>结果</th></tr></thead>
<tbody>
<tr><td>拟合 difficulty ~ 机械特征,用来重选关卡</td><td>响应几乎无方差(${R1.flat[1]}/${R1.rated} 同分),回归的是常数</td></tr>
<tr><td>legibility(一眼看穿的成本)解释难度评分</td><td>相关 -0.02,无效;降级为纯测量</td></tr>
<tr><td>路线交叠度当难度轴</td><td>中位已达 89%,没有上升空间</td></tr>
<tr><td>换开局朝向来提高判断成本</td><td>127 个候选里只有 7 个变好</td></tr>
<tr><td>「插入端多朝向出口」过滤器</td><td>对漏斗零效果,已删除</td></tr>
<tr><td>把锁住比例当难度主轴排序</td><td>方向反了:它奖励「开局什么都做不了」</td></tr>
</tbody>
</table>
<footer>
<p class="mono">生成:node scripts/build-process-report.mjs · 页面所有数字由脚本从 levels/campaign-50.json 与 bench/runs 直接测得</p>
<p>相关:<a href="review-50.html">五十关人工审核页</a> · <a href="level-board.html">任务看板</a> · <a href="bench-reliability.html">bench 复测可信度</a></p>
</footer>
</div>
</body>
</html>`;
writeFileSync(resolve('docs/level-process.html'), html);
console.log(`流程复盘已生成:docs/level-process.html(交付批次 ${SHIPPED},${R1.n} 局;轮次 ${present.join('/')};${RULES.length} 条规则)`);