211 lines
9.8 KiB
JavaScript
211 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
||
// verifyBookwormBoard.js — Bookworm board-quality harness
|
||
//
|
||
// Compares the plain weighted-random board (baseline) against the steering
|
||
// layer (class steering + vowel band + word-completion boost) on:
|
||
// • static boards — words available, dead boards, vowel share
|
||
// • simulated sessions — play the longest word each turn, refill, repeat:
|
||
// words available per turn, dead turns (no word to play), turn survival
|
||
//
|
||
// Deterministic (seeded RNG). The dictionary is the same ENABLE list
|
||
// (3–15 letters) that /words/scrabble/validate accepts, so "available word"
|
||
// means "word the player can actually submit".
|
||
//
|
||
// Run: node tools/verifyBookwormBoard.js
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import {
|
||
GRID_SIZE, makeGrid, clearAndRefill, getAdjacent,
|
||
} from '../src/games/bookwork/BookworkLogic.js';
|
||
import {
|
||
makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER,
|
||
} from '../src/games/bookwork/BookworkSteering.js';
|
||
|
||
const N_BOARDS = 300;
|
||
const N_SESSIONS = 60;
|
||
const MAX_TURNS = 30;
|
||
|
||
let pass = 0, fail = 0;
|
||
function ok(label, cond) {
|
||
if (cond) { console.log(` ✓ ${label}`); pass++; }
|
||
else { console.error(` ✗ ${label}`); fail++; }
|
||
}
|
||
|
||
// ── deterministic rng ─────────────────────────────────────────────────────────
|
||
function mulberry32(seed) {
|
||
let a = seed >>> 0;
|
||
return function () {
|
||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
// ── word finding (boggle DFS with prefix pruning; one path per word) ─────────
|
||
function buildPrefixSet(wordSet) {
|
||
const pre = new Set();
|
||
for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i));
|
||
return pre;
|
||
}
|
||
|
||
function findWords(grid, wordSet, prefixSet) {
|
||
const found = new Map(); // word -> [cells]
|
||
const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false));
|
||
const dfs = (r, c, word, cells) => {
|
||
const w = word + grid[r][c].letter;
|
||
const isWord = w.length >= 3 && wordSet.has(w);
|
||
if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]);
|
||
if (!prefixSet.has(w)) return;
|
||
if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return;
|
||
for (const { r: nr, c: nc } of getAdjacent(r, c)) {
|
||
if (visited[nr][nc]) continue;
|
||
visited[nr][nc] = true;
|
||
dfs(nr, nc, w, [...cells, { r, c }]);
|
||
visited[nr][nc] = false;
|
||
}
|
||
};
|
||
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) {
|
||
visited[r][c] = true;
|
||
dfs(r, c, '', []);
|
||
visited[r][c] = false;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
|
||
const vowelShare = (grid) =>
|
||
grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE);
|
||
|
||
const quantile = (arr, q) => {
|
||
const s = [...arr].sort((a, b) => a - b);
|
||
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
||
};
|
||
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length);
|
||
|
||
// ── setup ─────────────────────────────────────────────────────────────────────
|
||
console.log('Loading dictionary…');
|
||
const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8'));
|
||
const prefixSet = buildPrefixSet(wordSet);
|
||
console.log(` ${wordSet.size} words (3–15 letters), ${prefixSet.size} prefixes\n`);
|
||
|
||
const variants = {
|
||
baseline: {
|
||
make: (rng) => makeGrid(rng),
|
||
refill: (g, cells, rng) => clearAndRefill(g, cells, rng),
|
||
},
|
||
steered: {
|
||
make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER),
|
||
refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER),
|
||
},
|
||
};
|
||
|
||
// ── steering sanity ───────────────────────────────────────────────────────────
|
||
console.log('Steering sanity');
|
||
{
|
||
const rng = mulberry32(1);
|
||
const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER);
|
||
ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE));
|
||
ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter)));
|
||
ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q'));
|
||
const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER);
|
||
ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0);
|
||
ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0);
|
||
const letter = pickWeighted(w, mulberry32(7));
|
||
ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q');
|
||
const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER);
|
||
ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter)));
|
||
}
|
||
|
||
// ── static board quality ──────────────────────────────────────────────────────
|
||
console.log(`\nStatic boards (N=${N_BOARDS} each)`);
|
||
const staticStats = {};
|
||
for (const [name, v] of Object.entries(variants)) {
|
||
const wordCounts = [], shares = [];
|
||
for (let i = 0; i < N_BOARDS; i++) {
|
||
const g = v.make(mulberry32(1000 + i));
|
||
wordCounts.push(findWords(g, wordSet, prefixSet).size);
|
||
shares.push(vowelShare(g));
|
||
}
|
||
staticStats[name] = {
|
||
wordCounts,
|
||
dead: wordCounts.filter((n) => n === 0).length,
|
||
min: Math.min(...wordCounts),
|
||
p5: quantile(wordCounts, 0.05),
|
||
median: quantile(wordCounts, 0.5),
|
||
mean: mean(wordCounts),
|
||
max: Math.max(...wordCounts),
|
||
vowelMin: Math.min(...shares),
|
||
vowelMax: Math.max(...shares),
|
||
vowelMean: mean(shares),
|
||
};
|
||
}
|
||
|
||
const row = (label, f) =>
|
||
console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`);
|
||
console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14));
|
||
row('words/board (min)', (n) => staticStats[n].min);
|
||
row('words/board (p5)', (n) => staticStats[n].p5);
|
||
row('words/board (median)', (n) => staticStats[n].median);
|
||
row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1));
|
||
row('words/board (max)', (n) => staticStats[n].max);
|
||
row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`);
|
||
row('vowel share (min–max)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}–${(100 * staticStats[n].vowelMax).toFixed(0)}%`);
|
||
row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`);
|
||
|
||
// ── simulated sessions ────────────────────────────────────────────────────────
|
||
console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`);
|
||
const sessionStats = {};
|
||
for (const [name, v] of Object.entries(variants)) {
|
||
const totals = [], wpt = [], dead = [], initial = [], longest = [];
|
||
for (let i = 0; i < N_SESSIONS; i++) {
|
||
const rng = mulberry32(90000 + i);
|
||
let grid = v.make(rng);
|
||
let deadTurn = false, total = 0;
|
||
for (let t = 0; t < MAX_TURNS; t++) {
|
||
const words = findWords(grid, wordSet, prefixSet);
|
||
if (t === 0) initial.push(words.size);
|
||
if (words.size === 0) { deadTurn = true; break; }
|
||
total += words.size;
|
||
wpt.push(words.size);
|
||
let best = null;
|
||
for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells };
|
||
longest.push(best.w.length);
|
||
grid = v.refill(grid, best.cells, rng);
|
||
}
|
||
totals.push(total);
|
||
if (deadTurn) dead.push(1); else dead.push(0);
|
||
}
|
||
sessionStats[name] = {
|
||
deadRate: mean(dead),
|
||
totalMedian: quantile(totals, 0.5),
|
||
totalMean: mean(totals),
|
||
wptMin: Math.min(...wpt),
|
||
wptP5: quantile(wpt, 0.05),
|
||
wptMedian: quantile(wpt, 0.5),
|
||
initialMedian: quantile(initial, 0.5),
|
||
longestMax: Math.max(...longest),
|
||
};
|
||
}
|
||
|
||
row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`);
|
||
row('initial words (median)', (n) => sessionStats[n].initialMedian);
|
||
row('words/turn (min)', (n) => sessionStats[n].wptMin);
|
||
row('words/turn (p5)', (n) => sessionStats[n].wptP5);
|
||
row('words/turn (median)', (n) => sessionStats[n].wptMedian);
|
||
row('total words (median)', (n) => sessionStats[n].totalMedian);
|
||
row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0));
|
||
row('longest word seen', (n) => sessionStats[n].longestMax);
|
||
|
||
// ── comparisons ───────────────────────────────────────────────────────────────
|
||
console.log('\nComparison');
|
||
ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median);
|
||
ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead);
|
||
ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5);
|
||
ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate);
|
||
ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian);
|
||
ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian);
|
||
|
||
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`);
|
||
process.exit(fail > 0 ? 1 : 0);
|