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>
337 lines
25 KiB
JavaScript
337 lines
25 KiB
JavaScript
#!/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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} 条规则)`);
|