feat(levels): reject one-orientation endgames, and measure the result

Every model that played the D5 set complained about the same moment: two
pieces left, interlocked, and nothing on the board saying which way to turn.
codex-spark took it to the end — it cycled all six orientations on L18 and
gave up on a level that was solvable, with clarity rated 5. Replaying the
position shows exactly one of the six orientations releases anything.

deadTurnAudit now reports endgameMinOpen: across reachable states with three
pieces or fewer left, the fewest orientations that release anything. It is
computed inside the walk that was already happening, so it costs nothing. The
generator requires at least two.

The gate picks out precisely the four levels every model stalled on, with no
false positives — the other fourteen already had two or three. Regenerating
drops the spiral family entirely: its structure produces single-exit endgames
by construction, not by unlucky seed.

Measured on the regenerated set, rule bot first and then grok-4.6 xhigh over
all 18:

  blind rotations   1.17 -> 0.29 per level
  extra rotations   1.22 -> 0.35
  dead taps         2 -> 0
  run time          55.5s -> 34.7s
  stuck rating      1.89 -> 1.41   (this metric repeats at r = 0.85)
  "which way do I turn" complaints   22% -> 12% of levels

The gate is a floor, not a difficulty dial: difficulty also fell, 2.39 -> 2.12,
because the levels it removes are the hard ones. And it treats a symptom — the
t0/t3 interlock is what the four-piece chain in createDeepLock builds by
construction, so the complaint is halved rather than gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 16:03:44 +08:00
co-authored by Claude Opus 5
parent cefb7a5a56
commit 912bec29b3
5 changed files with 12590 additions and 11558 deletions
+26 -1
View File
@@ -179,15 +179,38 @@ export function deepLevelStats(level) {
// sequence). Removal only reduces occupancy and turns are reversible, so no
// reachable state can be unsolvable — the audit proves it rather than assuming
// it — and it measures how many blind turns a player can run into.
// A level is judged on its endgame with this many pieces or fewer left.
const ENDGAME_PIECES = 3;
export function deadTurnAudit(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const keyOf = (o, rem) => o + ':' + [...rem].sort().join(',');
const start = { o: 0, rem: new Set(level.tiles.map(t => t.id)) };
const seen = new Map([[keyOf(0, start.rem), start]]), queue = [start];
let stuckStates = 0, doubleDead = 0, maxDeadTurns = 0, unsolvable = 0;
// The endgame is where every model complained: two pieces left, nothing on
// the board saying which way to turn. Count how many of the six orientations
// release anything at all — one is a 1-in-6 guess with a dead end behind
// every other door, which is what made an agent give up on a solvable level.
const openCache = new Map();
let endgameMinOpen = 6, endgameTightStates = 0, endgameWorst = null;
const openOrientations = rem => {
const k = [...rem].sort().join(',');
if (openCache.has(k)) return openCache.get(k);
const tiles = level.tiles.filter(t => rem.has(t.id));
let n = 0;
for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) n++;
openCache.set(k, n);
return n;
};
while (queue.length && seen.size < maxStates) {
const state = queue.shift();
if (!state.rem.size) continue;
if (state.rem.size <= ENDGAME_PIECES) {
const open = openOrientations(state.rem);
if (open <= 1) endgameTightStates++;
if (open < endgameMinOpen) { endgameMinOpen = open; endgameWorst = [...state.rem].sort().join(','); }
}
const tiles = level.tiles.filter(t => state.rem.has(t.id)), free = available(boards[state.o], tiles);
if (!free.length) {
stuckStates++;
@@ -206,7 +229,9 @@ export function deadTurnAudit(level, maxStates = 20000) {
if (!seen.has(k)) { const next = { o, rem: state.rem }; seen.set(k, next); queue.push(next); }
}
}
return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable, exhausted: seen.size >= maxStates };
return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable,
endgameMinOpen: endgameMinOpen === 6 && !openCache.size ? null : endgameMinOpen,
endgameTightStates, endgameWorst, exhausted: seen.size >= maxStates };
}
export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|');