// Research-only audit. Uses the project's actual boards and solvers; does not edit levels. // Run from the repository root: node docs/research/audit-level-structure.mjs // Requires Node.js 24 with native TypeScript stripping and registerHooks. import { registerHooks } from 'node:module'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; const root = new URL('../../', import.meta.url); registerHooks({ resolve(specifier, context, nextResolve) { if (specifier.startsWith('.') && context.parentURL?.endsWith('.ts') && !/\.[a-z]+$/i.test(specifier)) { const candidate = new URL(specifier + '.ts', context.parentURL); if (existsSync(candidate)) return nextResolve(candidate.href, context); } return nextResolve(specifier, context); }, }); function bipartite(adj) { const colors = Array(adj.length).fill(-1); for (let i = 0; i < adj.length; i++) { if (colors[i] !== -1) continue; colors[i] = 0; const queue = [i]; for (let k = 0; k < queue.length; k++) { const u = queue[k]; for (const v of adj[u]) { if (colors[v] === colors[u]) return false; if (colors[v] === -1) { colors[v] = 1 - colors[u]; queue.push(v); } } } } return true; } const levels = []; for (const mode of ['01-number-stroke', '03-hex-walls']) { const numeric = mode === '01-number-stroke'; const loader = await import(new URL(`modes/${mode}/src/levels.ts`, root)); const solver = await import(new URL(`modes/${mode}/src/solver.ts`, root)); const folder = new URL(`modes/${mode}/levels/`, root); for (const file of readdirSync(folder).filter(f => f.endsWith('.json')).sort()) { const pack = loader.parsePack(JSON.parse(readFileSync(new URL(file, folder), 'utf8'))); for (const { def, board: b } of loader.loadPack(pack)) { const solutions = solver.solve(b, { limit: 2 }); const path = solutions.length === 1 ? solutions[0] : null; const clues = b.cells.filter(c => c.clue !== null).map(c => c.clue).sort((a,z) => a-z); const gaps = clues.slice(1).map((c,i) => c-clues[i]); if (clues.length) gaps.push(b.n-clues.at(-1)); const branches = []; if (path) { const visited = new Set(); for (let t = 1; t < path.length; t++) { visited.add(path[t-1]); const adj = numeric ? b.adj[path[t-1]] : b.adjAt(path[t-1],t); const physical = adj.filter(v => !visited.has(v)); const immediate = physical.filter(v => { const c = b.cells[v], nextStep=t+1; if (b.clueCellOfStep.has(nextStep) && b.clueCellOfStep.get(nextStep) !== v) return false; if (c.clue !== null && c.clue !== nextStep) return false; if (numeric && c.parity && (nextStep%2===1 ? 'odd' : 'even') !== c.parity) return false; return true; }); if (immediate.length > 1) branches.push({step:t, choices:immediate.map(v=>b.cells[v].id)}); } } const geometry = numeric ? b.cells.map(c=>c.units.map(([x,y])=>`${x},${y}`).sort().join(';')).sort() : (()=> { const minQ=Math.min(...b.cells.map(c=>c.q)), minR=Math.min(...b.cells.map(c=>c.r)); return b.cells.map(c=>`${c.q-minQ},${c.r-minR}`).sort(); })(); levels.push({ mode, pack:pack.pack, title:pack.title, id:def.id, cells:b.n, clues, maxClueGap:gaps.length ? Math.max(...gaps) : null, hasStart:clues.includes(1), hasEnd:clues.includes(b.n), parityCount:numeric ? b.cells.filter(c=>c.parity).length : 0, bigCells:numeric ? b.cells.filter(c=>c.units.length>1).length : 0, shapeFields:numeric ? def.cells.filter(c=>c.shape).length : 0, staticWalls:numeric ? 0 : b.walls.length, spinners:numeric ? 0 : b.spinners.length, graphBipartite:bipartite(b.adj), solutionCountCappedAtTwo:solutions.length, prefixBranchPoints:branches.length, maxImmediateChoices:Math.max(1,...branches.map(b=>b.choices.length)), branchDetails:branches, solution:path?.map(v=>b.cells[v].id) ?? null, geometryKey:createHash('sha256').update(JSON.stringify(geometry)).digest('hex').slice(0,16), }); } } } const range=(items,key)=>[Math.min(...items.map(x=>x[key])),Math.max(...items.map(x=>x[key]))]; const groups=[]; for (const mode of ['01-number-stroke', '03-hex-walls']) { for (const pack of [...new Set(levels.filter(x=>x.mode===mode).map(x=>x.pack))]) { const list=levels.filter(x=>x.mode===mode && x.pack===pack); groups.push({mode,pack,title:list[0].title,count:list.length,cellsRange:range(list,'cells'), clueCountRange:[Math.min(...list.map(x=>x.clues.length)),Math.max(...list.map(x=>x.clues.length))], maxClueGapRange:range(list,'maxClueGap'),branchRange:range(list,'prefixBranchPoints'), unknownStart:list.filter(x=>!x.hasStart).map(x=>x.id),nonBipartite:list.filter(x=>!x.graphBipartite).length, spinnerRange:range(list,'spinners'),unique:list.filter(x=>x.solutionCountCappedAtTwo===1).length}); } } const reused=[]; for (const key of new Set(levels.map(x=>x.mode+':'+x.geometryKey))) { const list=levels.filter(x=>x.mode+':'+x.geometryKey===key); if (list.length>1) reused.push({mode:list[0].mode,levels:list.map(x=>x.id)}); } const output={auditDate:'2026-10-14',nodeVersion:process.version, method:'Original board models and solve(limit=2). Branch count follows unique solution prefix, checks physical reachability and immediate exact/parity clues only; excludes unknown-start choices. Not a human difficulty score. Hex bipartite uses static-wall graph with rotating walls ignored. Geometry reuse ignores clues/walls/phases, compares translation only.', total:levels.length, unique:levels.filter(x=>x.solutionCountCappedAtTwo===1).length,groups,reusedGeometries:reused,levels}; writeFileSync(new URL('level-structure-audit.json',import.meta.url),JSON.stringify(output,null,2)+'\n','utf8'); console.log(JSON.stringify({total:output.total,unique:output.unique,groups,reusedGeometries:reused},null,2));