fix(levels): stop rejecting crowded boards for an endgame nobody measured

No level in this project is more than a third pieces — 13% to 28% across the
shipping fifty, and the thousand-level set that preceded it peaks at 32% with
exactly one level above 30%. Clearing a full board is the satisfying part of a
clearing game, and it has never been on offer.

It was not a design choice. deadTurnAudit walks the reachable state space with a
20000-state budget, and the endgame gate read its answer. Past about eleven
pieces that budget is gone long before the walk reaches any state with three
pieces left, so the audit returned `endgameMinOpen: null` — and the gate,
written `endgameMinOpen >= 2`, compared null and got false. Every denser level
was thrown out for a bad endgame that was never looked at. `exhausted` was right
there in the audit's return value and nothing consulted it.

Endgames are now enumerated outright rather than walked to: C(26,3) is 2600
subsets, so it finishes on any board this game will ever have. The statistic is
the SHARE of endgames that are tight, not the minimum over them, because a
minimum over more subsets falls for arithmetic alone — the same confound that
made doubleDead look eight times worse for deep levels when the rate was 2%
against 5%. By share, crowded boards are the better ones: worst 0.33% against
1.73% for sparse.

With the gate measuring something, density inverts. At eight pieces per
orientation: 44% density, 18 of 23 candidates passing the whole chain, and not
one failing the dead-turn gate. At two: 19% density, 9 of 23, with eleven
failing on dead turns. perTurn becomes a swept dial, and the hard piece ceiling
that truncated the placement schedule regardless of it is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 19:04:46 +08:00
co-authored by Claude Opus 5
parent f846a342d1
commit 4c4c250511
4 changed files with 82 additions and 22 deletions
+6 -3
View File
@@ -161,12 +161,15 @@ async function renderCampaign() {
const levels = (await res.json()).levels;
$('campaign').innerHTML = levels.map((l, i) =>
card(String(i + 1).padStart(2, '0'), l.campaign?.family ?? '', l,
`<span>棋子 / 格子</span><b>${l.tiles.length} / ${l.cells.length}</b>`)).join('');
`<span>棋子密度</span><b class="${l.tiles.length / l.cells.length >= .35 ? 'ok' : l.tiles.length / l.cells.length < .22 ? 'hot' : ''}">${l.tiles.length} / ${l.cells.length} = ${Math.round(100 * l.tiles.length / l.cells.length)}%</b>`)).join('');
[...$('campaign').querySelectorAll('canvas')].forEach((cv, i) => paint(cv, levels[i]));
const s = levels.map(shape);
const avg = k => s.reduce((n, x) => n + x[k], 0) / s.length;
$('meta').textContent = `当前 50 关:直线占比均值 ${Math.round(avg('straight') * 100)}%、交叉每格 ${avg('cross').toFixed(2)}`
+ ` · 超过 60% 直线的有 ${s.filter(x => x.straight > .6).length} 关`;
const dens = levels.map(l => l.tiles.length / l.cells.length);
const mean = a => a.reduce((n, x) => n + x, 0) / a.length;
$('meta').textContent = `当前 50 关:直线占比均值 ${Math.round(avg('straight') * 100)}%(超过 60% 的 ${s.filter(x => x.straight > .6).length} 关)`
+ ` · 交叉每格 ${avg('cross').toFixed(2)}`
+ ` · 棋子密度均值 ${Math.round(mean(dens) * 100)}%,最高 ${Math.round(Math.max(...dens) * 100)}%,35% 以上 ${dens.filter(x => x >= .35).length} 关`;
} catch (e) {
$('campaign').innerHTML = `<p class="hint">读不到 levels/campaign-50.json:${e.message}</p>`;
}
+5 -5
View File
@@ -3,7 +3,7 @@
// that only a wide sweep can afford.
//
// node scripts/build-pool.mjs --in=<dir> [--out=bench/pool.json]
// [--endgameMinOpen=3] [--maxDeadTurns=1]
// [--maxTightShare=0.005] [--maxDeadTurns=1]
//
// prototype-d5 gates what a single sweep can pay for. Two more are left to this
// step because together they cost nearly 60% of the yield.
@@ -27,7 +27,7 @@ import { deadTurnAudit, deepLevelKey } from '../src/deep-lock.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 OUT = resolve(args.out ?? 'bench/pool.json');
const MIN_OPEN = Number(args.endgameMinOpen ?? 3);
const MAX_TIGHT = Number(args.maxTightShare ?? 0.005);
const MAX_DEAD = Number(args.maxDeadTurns ?? 1);
// Shards from a sweep, or level files listed directly.
@@ -59,18 +59,18 @@ for (const level of unique) {
audited++;
}
const open = unique.filter(l => (l.prototype.audit.endgameMinOpen ?? 0) >= MIN_OPEN);
const open = unique.filter(l => (l.prototype.audit.endgameTightShare ?? 1) <= MAX_TIGHT);
const kept = open.filter(l => (l.prototype.audit.maxDeadTurns ?? Infinity) <= MAX_DEAD);
const tally = (rows, f) => rows.reduce((m, l) => { const k = f(l); m[k] = (m[k] || 0) + 1; return m; }, {});
const rot = l => l.prototype?.stats?.rotations ?? '?';
const fam = l => l.prototype?.family ?? (String(l.id).startsWith('trail-') ? 'v05' : '?');
console.log(`${files.length} 个来源合计 ${all.length} → 去重 ${unique.length}${audited ? `(其中 ${audited} 关现场补算了 audit)` : ''}`);
console.log(` 终局可放行朝向 ≥${MIN_OPEN} → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
console.log(` 终局紧局 ≤${(MAX_TIGHT*100).toFixed(1)}% → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
console.log(` 连续空转 ≤${MAX_DEAD} → ${kept.length}(${Math.round(100 * kept.length / unique.length)}%)`);
console.log(`旋转深度 ${JSON.stringify(tally(kept, rot))}`);
console.log(`家族 ${JSON.stringify(tally(kept, fam))}`);
writeFileSync(OUT, JSON.stringify({ generated: 'build-pool', sources: files.length, swept: all.length, count: kept.length,
gates: { endgameMinOpen: MIN_OPEN, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
gates: { maxTightShare: MAX_TIGHT, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
console.log(`已写入 ${OUT}`);
+25 -12
View File
@@ -17,7 +17,12 @@ const OUT = resolve(args.out ?? 'levels/prototypes/d5.json');
const RADII = String(args.radii ?? '3,4').split(',').map(Number);
const MIN_PIECES = Number(args.minPieces ?? 8);
const DEPTHS = String(args.depths ?? '3,4,5,6').split(',').map(Number);
const PER_TURN = Number(args.perTurn ?? 2);
// How many pieces each orientation may hold. It is the density dial: the
// schedule is depth × perTurn placements, so perTurn 2 could never build a board
// more than a fifth pieces no matter what `target` said. Swept, so the pool
// carries both sparse and crowded boards to choose between.
const PER_TURNS = String(args.perTurns ?? args.perTurn ?? '2,5,8').split(',').map(Number);
const CAP = args.cap === undefined ? null : Number(args.cap);
const SHOWCASE = Number(args.showcase ?? 2);
// How leftover ports are paired. 'straight' was the only behaviour and it is
// what made boards read as a grid of long lines crossing everywhere.
@@ -53,8 +58,11 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
yieldTable[cellKey].locks++;
// Sweep depth: each extra orientation populated is one more turn the
// player must take, and each of those turns has pieces waiting.
for (const depth of DEPTHS) {
const cap = { 3: 11, 4: 16, 5: 18 }[radius];
for (const depth of DEPTHS) for (const PER_TURN of PER_TURNS) {
// A hard piece ceiling, from when crowded boards could not pass the endgame
// gate anyway. It truncated the placement schedule, so raising perTurn did
// nothing while it stood. Density is now a dial (perTurn), not a limit.
const cap = CAP;
const grown = growDeepLevel(base, lock, { seed, depth, perTurn: PER_TURN, target: cap, showcase: SHOWCASE });
const level = grown.level;
if (grown.solution.rotations < 3) continue;
@@ -68,8 +76,8 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
const show = showStats(level);
if (!show) continue;
const apt = monkeyActionsPerPiece(level);
level.prototype = { family, radius, seed, depth, cap, lock: { hero: lock.hero.id, heroBends: lock.heroRoute.bends, search: lock.search }, placements: grown.placements, rejected: grown.rejected, stats, audit, contrast, show, monkeyActionsPerPiece: apt };
level.id = `proto-d5-${family}-r${radius}-s${seed}-d${depth}`;
level.prototype = { family, radius, seed, depth, perTurn: PER_TURN, cap, lock: { hero: lock.hero.id, heroBends: lock.heroRoute.bends, search: lock.search }, placements: grown.placements, rejected: grown.rejected, stats, audit, contrast, show, monkeyActionsPerPiece: apt };
level.id = `proto-d5-${family}-r${radius}-s${seed}-d${depth}-p${PER_TURN}`;
rows.push({ level, stats, audit, contrast, show, apt, family, radius, seed, depth });
}
}
@@ -81,10 +89,15 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
// where neither adjacent orientation opens anything.
const med = arr => { const s = [...arr].sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
const grownEnough = rows.filter(r => r.stats.pieces >= MIN_PIECES);
// The endgame gate: with three pieces or fewer left, at least two of the six
// orientations must release something. One is a 1-in-6 guess with a dead end
// behind every other door — solvable, legible, and still the thing that made
// an agent give up. It rejects exactly the levels every model stalled on.
// The endgame gate. It used to demand a minimum over reachable endgames, read
// off the same breadth search that counts dead turns — and on a crowded board
// that search never reaches an endgame, so it answered null and the comparison
// rejected the level for an endgame nobody had measured. Now the endgames are
// enumerated outright and the gate is the SHARE that are tight (one orientation
// or none). A minimum over more subsets falls for arithmetic reasons; the share
// does not, and by share the crowded boards are the better ones — worst 0.33%
// against 1.73% for the sparse.
const MAX_TIGHT_SHARE = Number(args.maxTightShare ?? 0.005);
const MIN_RELEASE = Number(args.minRelease ?? 2);
const MIN_CONTRAST = Number(args.minContrast ?? 12);
// Expressed at the speed it was calibrated at (120ms per unit) and scaled to
@@ -98,7 +111,7 @@ const MIN_CRUISES = Number(args.minCruises ?? 3);
// that no turn is a punishment (endgameMinOpen, firstTurnDirections), and that
// pieces actually swing between hopeless and trivial (contrast).
const playable = grownEnough.filter(r => r.audit.unsolvable === 0 && r.stats.firstTurnDirections === 2
&& r.stats.lockedShare <= .9 && r.audit.endgameMinOpen >= 2
&& r.stats.lockedShare <= .9 && r.audit.endgameTightShare <= MAX_TIGHT_SHARE
&& r.stats.minRelease >= MIN_RELEASE && r.contrast.contrast >= MIN_CONTRAST
// The show is the payout. An opening that offers two rim pieces and a 0.3s
// hop gives a casual player nothing to enjoy before the thinking starts.
@@ -116,9 +129,9 @@ playable.sort((a, b) => (b.show.openingBestMs - a.show.openingBestMs)
const g1 = grownEnough.filter(r => r.stats.firstTurnDirections === 2);
const g2 = g1.filter(r => r.stats.lockedShare <= .9);
const g3 = g2.filter(r => r.audit.unsolvable === 0);
const g4 = g3.filter(r => r.audit.endgameMinOpen >= 2);
const g4 = g3.filter(r => r.audit.endgameTightShare <= MAX_TIGHT_SHARE);
const g5 = g4.filter(r => r.stats.minRelease >= MIN_RELEASE);
console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.length} → 锁住≤90% ${g2.length} → 无不可解态 ${g3.length} → 终局朝向≥2 ${g4.length} → 每转释放≥${MIN_RELEASE} ${g5.length} → 反差≥${MIN_CONTRAST} ${playable.length}`);
console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.length} → 锁住≤90% ${g2.length} → 无不可解态 ${g3.length} → 终局紧局≤${(MAX_TIGHT_SHARE*100).toFixed(1)}% ${g4.length} → 每转释放≥${MIN_RELEASE} ${g5.length} → 反差≥${MIN_CONTRAST} ${playable.length}`);
const g6 = g5.filter(r => r.contrast.contrast >= MIN_CONTRAST);
const g7 = g6.filter(r => r.show.openingChoices >= MIN_OPENING_CHOICES);
const g8 = g7.filter(r => r.show.openingBestMs >= MIN_OPENING_SHOW);
+46 -2
View File
@@ -344,6 +344,46 @@ export function deepLevelStats(level) {
// A level is judged on its endgame with this many pieces or fewer left.
const ENDGAME_PIECES = 3;
// The endgame question — with a handful of pieces left, how many of the six
// orientations release anything — used to be answered from the same breadth
// search that counts dead turns. On a dense board that search hits its state
// budget long before it reaches any endgame, so it reported `null`, and the
// gate `endgameMinOpen >= 3` read null as a failure. Every level past about
// eleven pieces was rejected for an endgame nobody had measured. That is why
// no level in this project has ever been more than a third pieces.
//
// Enumerating the endgames directly costs nothing by comparison — C(26,3) is
// 2600 — and it finishes, so a dense board gets a real answer. Reachability is
// not checked: the enumeration is a superset of the endgames a player can
// arrive at, which errs toward caution rather than toward a silent pass.
//
// The statistic is the SHARE of endgames that are tight, not the minimum over
// them. A minimum taken over more subsets falls for arithmetic reasons alone —
// the same confound that made `doubleDead` look eight times worse for deep
// levels when the rate was 2% against 5%. By share, 26-piece boards come in at
// 0.0% against 0.1% for 11-piece ones: they are not worse, they were unmeasured.
export function endgameScan(level, k = ENDGAME_PIECES) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const ids = level.tiles.map(t => t.id);
const counts = {};
let subsets = 0, minOpen = 6, worst = null;
const visit = (from, pick) => {
if (pick.length) {
const tiles = level.tiles.filter(t => pick.includes(t.id));
let open = 0;
for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) open++;
counts[open] = (counts[open] || 0) + 1;
subsets++;
if (open < minOpen) { minOpen = open; worst = [...pick].sort().join(','); }
}
if (pick.length === k) return;
for (let i = from; i < ids.length; i++) visit(i + 1, [...pick, ids[i]]);
};
visit(0, []);
const tight = (counts[0] || 0) + (counts[1] || 0);
return { subsets, minOpen, worst, tightStates: tight, tightShare: subsets ? tight / subsets : 0 };
}
export function deadTurnAudit(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const keyOf = (o, rem) => o + ':' + [...rem].sort().join(',');
@@ -391,9 +431,13 @@ export function deadTurnAudit(level, maxStates = 20000) {
if (!seen.has(k)) { const next = { o, rem: state.rem }; seen.set(k, next); queue.push(next); }
}
}
const endgame = endgameScan(level);
return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable,
endgameMinOpen: endgameMinOpen === 6 && !openCache.size ? null : endgameMinOpen,
endgameTightStates, endgameWorst, exhausted: seen.size >= maxStates };
// Endgame numbers come from the complete enumeration, never from this
// search — it may not have reached an endgame at all.
endgameMinOpen: endgame.minOpen, endgameTightStates: endgame.tightStates,
endgameTightShare: endgame.tightShare, endgameSubsets: endgame.subsets,
endgameWorst: endgame.worst, exhausted: seen.size >= maxStates };
}
export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|');