diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 517dd90..3b9edf1 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -193,11 +193,20 @@ button:disabled { position: relative; z-index: 1; margin: 0 auto 1rem; - max-width: 44rem; text-align: center; font-weight: 700; font-size: clamp(1rem, 2vw, 1.4rem); } +.display-results > h1 { + max-width: 44rem; +} +.display-results__quip { + width: fit-content; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} .display-results__fireworks { position: absolute; inset: 0; diff --git a/apps/client/src/styles.test.ts b/apps/client/src/styles.test.ts index 736ce1e..482760d 100644 --- a/apps/client/src/styles.test.ts +++ b/apps/client/src/styles.test.ts @@ -173,6 +173,21 @@ describe('tile typography', () => { expect(styles).not.toMatch( /\.display-results\s*\{[^}]*justify-content: center;/s, ); + expect(styles).toMatch( + /\.display-results > h1\s*\{[^}]*max-width: 44rem;/s, + ); + expect(styles).toMatch( + /\.display-results__quip\s*\{[^}]*width: fit-content;[^}]*max-width: 100%;[^}]*min-width: 0;[^}]*overflow-wrap: anywhere;[^}]*white-space: normal;/s, + ); + expect(styles).not.toMatch( + /\.display-results__quip\s*\{[^}]*width: max-content;/s, + ); + expect(styles).not.toMatch( + /\.display-results__quip\s*\{[^}]*max-width: 44rem;/s, + ); + expect(styles).not.toMatch( + /\.display-results__quip\s*\{[^}]*white-space: nowrap;/s, + ); expect(styles).toMatch( /\.display-results__cards\s*\{[^}]*width: fit-content;[^}]*max-width: 96vw;[^}]*grid-template-columns: repeat\(2, minmax\(16rem, max-content\)\);/s, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c018126..7fd13ba 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,7 +5,7 @@ import { generateDefaultBoard, loadProductionDictionary, PRODUCTION_DICTIONARY_IDENTITY, - selectMedianBoard, + selectStrongBoard, type ProductionDictionaryLoadResult, } from '@words/game-data'; import { @@ -243,7 +243,7 @@ export function createWordsServer( attempts: 0, }; } - return selectMedianBoard({ + return selectStrongBoard({ size, random: randomSource, dictionary: gameDataRuntime.dictionary, diff --git a/apps/server/tests/server.test.ts b/apps/server/tests/server.test.ts index 97f23c3..dd3d6be 100644 --- a/apps/server/tests/server.test.ts +++ b/apps/server/tests/server.test.ts @@ -1978,7 +1978,7 @@ describe('Words Stage 4B server', () => { }); }); - it('uses the median selector with eight production-valid candidates', async () => { + it('uses the strong selector with 32 production-valid candidates', async () => { await server.stop(); const randomSource = { next: vi.fn(() => 0.5) }; const boardGenerator = vi.fn(({ size, random }) => { @@ -2013,7 +2013,7 @@ describe('Words Stage 4B server', () => { ok: true, room: { round: { generationAttempts: 1 } }, }); - expect(boardGenerator).toHaveBeenCalledTimes(8); + expect(boardGenerator).toHaveBeenCalledTimes(32); }); it('broadcasts one count-only progress snapshot while keeping accepted words private', async () => { diff --git a/docs/DEVELOPMENT_LOG.md b/docs/DEVELOPMENT_LOG.md index faaa27c..e0d19ce 100644 --- a/docs/DEVELOPMENT_LOG.md +++ b/docs/DEVELOPMENT_LOG.md @@ -1,5 +1,20 @@ # Development log +## 2026-08-20 — Strong high-density board candidate (draft) + +- Added a separate server-only strong selector that chooses the highest + playable-word count from 32 production-valid candidates, breaking ties by + cell coverage and then generation order. The median selector remains intact. +- Audited 500 deterministic selections per size twice with seed + `strong-board-selection-v1`; outputs were byte-identical and selected medians + exceeded the retained original corpus P95 for 4 × 4, 5 × 5, and 6 × 6. +- Let the display result quip prefer its intrinsic width with `fit-content` + while shrinking safely on narrow portrait screens; normal wrapping and the + heading's 44rem bound remain intact. +- Kept generation profiles, dictionary, `QU`, network contracts, lifecycle, + result content, and production publication unchanged. This branch is only a + physical gameplay and TV test candidate. + ## 2026-08-14 — Post-round player board review (draft) - Player phones now retain the exact authoritative finished board as a frozen, diff --git a/docs/board-quality-research/STRONG_SELECTION_EXPERIMENT.md b/docs/board-quality-research/STRONG_SELECTION_EXPERIMENT.md new file mode 100644 index 0000000..8e2abb2 --- /dev/null +++ b/docs/board-quality-research/STRONG_SELECTION_EXPERIMENT.md @@ -0,0 +1,51 @@ +# Strong board selection experiment + +This physical-test candidate keeps the production generator, dictionary, board +acceptance profiles, `QU` policy, and generation-attempt bounds unchanged. For +each round it solves 32 accepted production-valid candidates, then selects by: + +1. highest playable-word count; +2. highest cell coverage; +3. earliest candidate generation index. + +Long-word count and vowel spread are measured but are not ranking criteria. +Production's existing median selector and targets remain available unchanged. + +## Deterministic audit + +`npm run research:strong-selection` ran 500 selections per board size with seed +`strong-board-selection-v1`, covering 48,000 requested candidates. A second run +produced a byte-identical +`generated/strong-selection-summary.json`. Runtime values are intentionally not +stored in that deterministic artifact because they vary by machine. + +| Size | Playable words P5 / P50 / P95 | Long words P5 / P50 / P95 | Cell coverage P5 / P50 / P95 | Mean successful candidates | Mean generation attempts | +| ---: | ----------------------------: | ------------------------: | ---------------------------: | -------------------------: | -----------------------: | +| 4×4 | 125.95 / 164 / 226.05 | 38 / 67 / 107.05 | 0.9375 / 1 / 1 | 32 | 39.316 | +| 5×5 | 250 / 307 / 398 | 98 / 147 / 220.1 | 0.92 / 1 / 1 | 32 | 38.832 | +| 6×6 | 404.95 / 479 / 603.05 | 179.95 / 244 / 357.05 | 0.9444444444444444 / 1 / 1 | 31.998 | 43.086 | + +One representative local run measured these complete selector runtimes: + +| Size | Mean ms | P50 ms | P95 ms | +| ---: | ------: | -----: | -----: | +| 4×4 | 9.537 | 9.402 | 10.562 | +| 5×5 | 20.046 | 19.994 | 22.020 | +| 6×6 | 36.022 | 35.722 | 38.966 | + +## Original-corpus comparison + +The retained original corpus contains 5,000 independently generated boards per +size. The strong selector's median is above that corpus's P95 for every size: + +| Size | Original P5 / P50 / P95 | Selected P5 / P50 / P95 | Selected P50 minus original P95 | +| ---: | ----------------------: | ----------------------: | ------------------------------: | +| 4×4 | 31 / 72 / 144 | 125.95 / 164 / 226.05 | +20 | +| 5×5 | 77 / 153 / 267 | 250 / 307 / 398 | +40 | +| 6×6 | 148 / 262 / 428.05 | 404.95 / 479 / 603.05 | +50.95 | + +Selected P5 remains slightly below the original P95, by 18.05, 17, and 23.10 +words respectively. The complete selected distributions nevertheless occupy +the intended upper tail rather than clustering around the median targets. This +audit establishes selection behavior, not whether denser boards are more fun; +that remains the purpose of physical testing. diff --git a/docs/board-quality-research/generated/strong-selection-summary.json b/docs/board-quality-research/generated/strong-selection-summary.json new file mode 100644 index 0000000..2df7459 --- /dev/null +++ b/docs/board-quality-research/generated/strong-selection-summary.json @@ -0,0 +1,149 @@ +{ + "schemaVersion": 1, + "seed": "strong-board-selection-v1", + "selectionsPerSize": 500, + "candidatePoolSize": 32, + "ranking": [ + "highest playable-word count", + "highest cell coverage", + "earliest candidate generation index" + ], + "sizes": { + "4": { + "originalFiveThousandBoardCorpus": { + "playableWordCount": { + "min": 6, + "p5": 31, + "p50": 72, + "p95": 144, + "p99": 180, + "max": 276 + }, + "longPlayableWordCount": { + "min": 0, + "p5": 3, + "p50": 18, + "p95": 58, + "p99": 81.01000000000022, + "max": 155 + }, + "cellCoverage": { + "min": 0.5625, + "p5": 0.875, + "p50": 1, + "p95": 1, + "p99": 1, + "max": 1 + } + }, + "selected": { + "playableWordCount": { + "p5": 125.95, + "p50": 164, + "p95": 226.04999999999995 + }, + "longPlayableWordCount": { + "p5": 38, + "p50": 67, + "p95": 107.04999999999995 + }, + "cellCoverage": { "p5": 0.9375, "p50": 1, "p95": 1 } + }, + "upperTailComparison": { + "selectedP5MinusOriginalP95": -18.049999999999997, + "selectedP50MinusOriginalP95": 20, + "selectedP5AtOrAboveOriginalP95": false + }, + "meanSuccessfulCandidateCount": 32, + "meanUnderlyingGenerationAttempts": 39.316 + }, + "5": { + "originalFiveThousandBoardCorpus": { + "playableWordCount": { + "min": 23, + "p5": 77, + "p50": 153, + "p95": 267, + "p99": 330.0100000000002, + "max": 436 + }, + "longPlayableWordCount": { + "min": 2, + "p5": 15, + "p50": 51, + "p95": 126, + "p99": 172, + "max": 256 + }, + "cellCoverage": { + "min": 0.6, + "p5": 0.88, + "p50": 1, + "p95": 1, + "p99": 1, + "max": 1 + } + }, + "selected": { + "playableWordCount": { "p5": 250, "p50": 307, "p95": 398 }, + "longPlayableWordCount": { + "p5": 98, + "p50": 147, + "p95": 220.0999999999999 + }, + "cellCoverage": { "p5": 0.92, "p50": 1, "p95": 1 } + }, + "upperTailComparison": { + "selectedP5MinusOriginalP95": -17, + "selectedP50MinusOriginalP95": 40, + "selectedP5AtOrAboveOriginalP95": false + }, + "meanSuccessfulCandidateCount": 32, + "meanUnderlyingGenerationAttempts": 38.832 + }, + "6": { + "originalFiveThousandBoardCorpus": { + "playableWordCount": { + "min": 51, + "p5": 148, + "p50": 262, + "p95": 428.0500000000002, + "p99": 514.0100000000002, + "max": 672 + }, + "longPlayableWordCount": { + "min": 2, + "p5": 38, + "p50": 101, + "p95": 215, + "p99": 282, + "max": 423 + }, + "cellCoverage": { + "min": 0.75, + "p5": 0.9166666666666666, + "p50": 1, + "p95": 1, + "p99": 1, + "max": 1 + } + }, + "selected": { + "playableWordCount": { "p5": 404.95, "p50": 479, "p95": 603.05 }, + "longPlayableWordCount": { + "p5": 179.95, + "p50": 244, + "p95": 357.04999999999995 + }, + "cellCoverage": { "p5": 0.9444444444444444, "p50": 1, "p95": 1 } + }, + "upperTailComparison": { + "selectedP5MinusOriginalP95": -23.100000000000193, + "selectedP50MinusOriginalP95": 50.94999999999982, + "selectedP5AtOrAboveOriginalP95": false + }, + "meanSuccessfulCandidateCount": 31.998, + "meanUnderlyingGenerationAttempts": 43.086 + } + } +} diff --git a/package.json b/package.json index 5c8c7b8..c44a4d0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "data:boards:audit": "tsx packages/game-data/scripts/audit-boards.mjs", "research:boards": "tsx scripts/research-board-quality.ts", "research:median-selection": "tsx scripts/audit-median-board-selection.ts", + "research:strong-selection": "tsx scripts/audit-strong-board-selection.ts", "privacy:check": "node scripts/check-public-privacy.mjs", "build:production": "npm run build --workspace @words/client && node scripts/build-production.mjs", "start:production": "node dist/production/server/index.mjs", diff --git a/packages/game-data/src/board-selector.ts b/packages/game-data/src/board-selector.ts new file mode 100644 index 0000000..55ab1f7 --- /dev/null +++ b/packages/game-data/src/board-selector.ts @@ -0,0 +1,126 @@ +import type { + Board, + BoardGenerationResult, + BoardSize, + RandomSource, + WordDictionary, +} from '@words/game-engine'; + +import { + createBoardPlayabilitySolver, + type BoardPlayabilityMetrics, + type BoardPlayabilitySolver, +} from './board-playability.js'; +import { generateDefaultBoard } from './board-profile.js'; + +type CandidateGenerator = (options: { + readonly size: BoardSize; + readonly random: RandomSource; +}) => BoardGenerationResult; + +export interface BoardSelectorOptions { + readonly size: BoardSize; + readonly random: RandomSource; + readonly dictionary: WordDictionary; + readonly dictionaryWords: readonly string[]; + readonly candidatePoolSize?: number; + readonly generateCandidate?: CandidateGenerator; + readonly solver?: BoardPlayabilitySolver; +} + +export type BoardSelectionResult = + | { + readonly success: true; + readonly board: Board; + readonly attempts: number; + readonly totalGenerationAttempts: number; + readonly successfulCandidateCount: number; + readonly selectedCandidateIndex: number; + readonly selectedMetrics: BoardPlayabilityMetrics; + } + | { + readonly success: false; + readonly code: 'NO_ACCEPTABLE_BOARD'; + readonly attempts: number; + }; + +export interface RankedBoardCandidate { + readonly board: Board; + readonly generationAttempts: number; + readonly index: number; + readonly metrics: BoardPlayabilityMetrics; +} + +interface BoardSelectorConfig { + readonly defaultCandidatePoolSize: number; + readonly maximumCandidatePoolSize: number; + readonly compareCandidates: ( + left: RankedBoardCandidate, + right: RankedBoardCandidate, + ) => number; +} + +export function selectBoardFromCandidates( + options: BoardSelectorOptions, + config: BoardSelectorConfig, +): BoardSelectionResult { + const candidatePoolSize = + options.candidatePoolSize ?? config.defaultCandidatePoolSize; + if ( + !Number.isInteger(candidatePoolSize) || + candidatePoolSize < 1 || + candidatePoolSize > config.maximumCandidatePoolSize + ) { + throw new RangeError( + `candidatePoolSize must be an integer from 1 to ${config.maximumCandidatePoolSize}.`, + ); + } + + const generateCandidate = options.generateCandidate ?? generateDefaultBoard; + const solver = + options.solver ?? + createBoardPlayabilitySolver(options.dictionary, options.dictionaryWords); + const candidates: RankedBoardCandidate[] = []; + let attempts = 0; + + for (let index = 0; index < candidatePoolSize; index += 1) { + try { + const generated = generateCandidate({ + size: options.size, + random: options.random, + }); + attempts += generated.attempts; + if (!generated.success) { + continue; + } + candidates.push({ + board: generated.board, + generationAttempts: generated.attempts, + index, + metrics: solver.measure(generated.board), + }); + } catch { + // One bad candidate must not discard other accepted candidates. + } + } + + if (candidates.length === 0) { + return Object.freeze({ + success: false, + code: 'NO_ACCEPTABLE_BOARD', + attempts, + }); + } + + candidates.sort(config.compareCandidates); + const selected = candidates[0]!; + return Object.freeze({ + success: true, + board: selected.board, + attempts: selected.generationAttempts, + totalGenerationAttempts: attempts, + successfulCandidateCount: candidates.length, + selectedCandidateIndex: selected.index, + selectedMetrics: selected.metrics, + }); +} diff --git a/packages/game-data/src/index.ts b/packages/game-data/src/index.ts index 20c9e48..09167b1 100644 --- a/packages/game-data/src/index.ts +++ b/packages/game-data/src/index.ts @@ -28,6 +28,13 @@ export { type MedianBoardTarget, type SelectMedianBoardOptions, } from './median-board-selector.js'; +export { + MAXIMUM_STRONG_BOARD_CANDIDATE_POOL_SIZE, + STRONG_BOARD_CANDIDATE_POOL_SIZE, + selectStrongBoard, + type SelectStrongBoardOptions, + type StrongBoardSelectionResult, +} from './strong-board-selector.js'; export { DEFAULT_DISTRIBUTION_METADATA, DEFAULT_TILE_DISTRIBUTION, diff --git a/packages/game-data/src/median-board-selector.ts b/packages/game-data/src/median-board-selector.ts index aba8487..4beb3ec 100644 --- a/packages/game-data/src/median-board-selector.ts +++ b/packages/game-data/src/median-board-selector.ts @@ -1,17 +1,11 @@ -import { - type Board, - type BoardGenerationResult, - type BoardSize, - type RandomSource, - type WordDictionary, -} from '@words/game-engine'; +import { type BoardSize } from '@words/game-engine'; import { - createBoardPlayabilitySolver, - type BoardPlayabilityMetrics, - type BoardPlayabilitySolver, -} from './board-playability.js'; -import { generateDefaultBoard } from './board-profile.js'; + selectBoardFromCandidates, + type BoardSelectionResult, + type BoardSelectorOptions, + type RankedBoardCandidate, +} from './board-selector.js'; export const MEDIAN_BOARD_CANDIDATE_POOL_SIZE = 8; export const MAXIMUM_MEDIAN_BOARD_CANDIDATE_POOL_SIZE = 12; @@ -29,112 +23,24 @@ export const MEDIAN_BOARD_TARGETS: Readonly< 6: Object.freeze({ playableWordCount: 262, longPlayableWordCount: 101 }), }); -type CandidateGenerator = (options: { - readonly size: BoardSize; - readonly random: RandomSource; -}) => BoardGenerationResult; - -export interface SelectMedianBoardOptions { - readonly size: BoardSize; - readonly random: RandomSource; - readonly dictionary: WordDictionary; - readonly dictionaryWords: readonly string[]; - readonly candidatePoolSize?: number; - readonly generateCandidate?: CandidateGenerator; - readonly solver?: BoardPlayabilitySolver; -} +export type SelectMedianBoardOptions = BoardSelectorOptions; -export type MedianBoardSelectionResult = - | { - readonly success: true; - readonly board: Board; - readonly attempts: number; - readonly totalGenerationAttempts: number; - readonly successfulCandidateCount: number; - readonly selectedCandidateIndex: number; - readonly selectedMetrics: BoardPlayabilityMetrics; - } - | { - readonly success: false; - readonly code: 'NO_ACCEPTABLE_BOARD'; - readonly attempts: number; - }; - -interface RankedCandidate { - readonly board: Board; - readonly generationAttempts: number; - readonly index: number; - readonly metrics: BoardPlayabilityMetrics; -} +export type MedianBoardSelectionResult = BoardSelectionResult; export function selectMedianBoard( options: SelectMedianBoardOptions, ): MedianBoardSelectionResult { - const candidatePoolSize = - options.candidatePoolSize ?? MEDIAN_BOARD_CANDIDATE_POOL_SIZE; - if ( - !Number.isInteger(candidatePoolSize) || - candidatePoolSize < 1 || - candidatePoolSize > MAXIMUM_MEDIAN_BOARD_CANDIDATE_POOL_SIZE - ) { - throw new RangeError( - `candidatePoolSize must be an integer from 1 to ${MAXIMUM_MEDIAN_BOARD_CANDIDATE_POOL_SIZE}.`, - ); - } - - const generateCandidate = options.generateCandidate ?? generateDefaultBoard; - const solver = - options.solver ?? - createBoardPlayabilitySolver(options.dictionary, options.dictionaryWords); - const candidates: RankedCandidate[] = []; - let attempts = 0; - - for (let index = 0; index < candidatePoolSize; index += 1) { - try { - const generated = generateCandidate({ - size: options.size, - random: options.random, - }); - attempts += generated.attempts; - if (!generated.success) { - continue; - } - candidates.push({ - board: generated.board, - generationAttempts: generated.attempts, - index, - metrics: solver.measure(generated.board), - }); - } catch { - // One bad candidate must not discard other accepted candidates. - } - } - - if (candidates.length === 0) { - return Object.freeze({ - success: false, - code: 'NO_ACCEPTABLE_BOARD', - attempts, - }); - } - const target = MEDIAN_BOARD_TARGETS[options.size]; - candidates.sort((left, right) => compareCandidates(left, right, target)); - const selected = candidates[0]!; - return Object.freeze({ - success: true, - board: selected.board, - attempts: selected.generationAttempts, - totalGenerationAttempts: attempts, - successfulCandidateCount: candidates.length, - selectedCandidateIndex: selected.index, - selectedMetrics: selected.metrics, + return selectBoardFromCandidates(options, { + defaultCandidatePoolSize: MEDIAN_BOARD_CANDIDATE_POOL_SIZE, + maximumCandidatePoolSize: MAXIMUM_MEDIAN_BOARD_CANDIDATE_POOL_SIZE, + compareCandidates: (left, right) => compareCandidates(left, right, target), }); } function compareCandidates( - left: RankedCandidate, - right: RankedCandidate, + left: RankedBoardCandidate, + right: RankedBoardCandidate, target: MedianBoardTarget, ): number { return ( diff --git a/packages/game-data/src/strong-board-selector.ts b/packages/game-data/src/strong-board-selector.ts new file mode 100644 index 0000000..02087c6 --- /dev/null +++ b/packages/game-data/src/strong-board-selector.ts @@ -0,0 +1,34 @@ +import { + selectBoardFromCandidates, + type BoardSelectionResult, + type BoardSelectorOptions, + type RankedBoardCandidate, +} from './board-selector.js'; + +export const STRONG_BOARD_CANDIDATE_POOL_SIZE = 32; +export const MAXIMUM_STRONG_BOARD_CANDIDATE_POOL_SIZE = 32; + +export type SelectStrongBoardOptions = BoardSelectorOptions; + +export type StrongBoardSelectionResult = BoardSelectionResult; + +export function selectStrongBoard( + options: SelectStrongBoardOptions, +): StrongBoardSelectionResult { + return selectBoardFromCandidates(options, { + defaultCandidatePoolSize: STRONG_BOARD_CANDIDATE_POOL_SIZE, + maximumCandidatePoolSize: MAXIMUM_STRONG_BOARD_CANDIDATE_POOL_SIZE, + compareCandidates: compareStrongCandidates, + }); +} + +function compareStrongCandidates( + left: RankedBoardCandidate, + right: RankedBoardCandidate, +): number { + return ( + right.metrics.playableWordCount - left.metrics.playableWordCount || + right.metrics.cellCoverage - left.metrics.cellCoverage || + left.index - right.index + ); +} diff --git a/packages/game-data/tests/strong-board-selector.test.ts b/packages/game-data/tests/strong-board-selector.test.ts new file mode 100644 index 0000000..57943ee --- /dev/null +++ b/packages/game-data/tests/strong-board-selector.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + STRONG_BOARD_CANDIDATE_POOL_SIZE, + selectStrongBoard, + type BoardPlayabilityMetrics, + type BoardPlayabilitySolver, +} from '../src/index.js'; +import { createResearchDictionary } from '../src/board-quality-research.js'; + +import type { Board, BoardSize } from '@words/game-engine'; + +const dictionaryWords = ['CAT', 'CATS', 'DOG', 'QUIZ']; +const dictionary = createResearchDictionary(dictionaryWords); + +function candidateBoard(size: BoardSize, index: number): Board { + const marker = String.fromCharCode(65 + (index % 26)); + return { + size, + tiles: Object.freeze(Array.from({ length: size * size }, () => marker)), + } as Board; +} + +function metrics( + playableWordCount: number, + longPlayableWordCount: number, + cellCoverage = 0.5, +): BoardPlayabilityMetrics { + return { + playableWordCount, + longPlayableWordCount, + cellCoverage, + largestRepeatedTokenComponent: 1, + }; +} + +function selectFromMetrics(candidates: readonly BoardPlayabilityMetrics[]) { + const boards = candidates.map((_, index) => candidateBoard(4, index)); + const byBoard = new Map( + boards.map((board, index) => [board, candidates[index]!] as const), + ); + const solver: BoardPlayabilitySolver = { + measure: (board) => byBoard.get(board)!, + analyze: vi.fn() as never, + }; + let generated = 0; + const result = selectStrongBoard({ + size: 4, + random: { next: () => 0.5 }, + dictionary, + dictionaryWords, + candidatePoolSize: candidates.length, + generateCandidate: () => ({ + success: true, + board: boards[generated]!, + attempts: generated++ + 1, + }), + solver, + }); + if (!result.success) { + throw new Error('Expected a selected candidate.'); + } + return result; +} + +describe('strong board selector', () => { + it('uses a bounded default pool of exactly 32 candidates', () => { + const board = candidateBoard(4, 0); + const generateCandidate = vi.fn(() => ({ + success: true as const, + board, + attempts: 1, + })); + const solver: BoardPlayabilitySolver = { + measure: () => metrics(10, 2, 1), + analyze: vi.fn() as never, + }; + + const result = selectStrongBoard({ + size: 4, + random: { next: () => 0.5 }, + dictionary, + dictionaryWords, + generateCandidate, + solver, + }); + + expect(STRONG_BOARD_CANDIDATE_POOL_SIZE).toBe(32); + expect(generateCandidate).toHaveBeenCalledTimes(32); + expect(result).toMatchObject({ + success: true, + successfulCandidateCount: 32, + totalGenerationAttempts: 32, + selectedCandidateIndex: 0, + }); + }); + + it('selects the candidate with the highest playable-word count', () => { + expect( + selectFromMetrics([metrics(100, 20), metrics(140, 25), metrics(120, 30)]) + .selectedCandidateIndex, + ).toBe(1); + }); + + it('uses higher cell coverage for an exact playable-word tie', () => { + expect( + selectFromMetrics([metrics(140, 25, 0.9), metrics(140, 20, 1)]) + .selectedCandidateIndex, + ).toBe(1); + }); + + it('uses the earliest candidate for an exact remaining tie', () => { + expect( + selectFromMetrics([metrics(140, 20, 1), metrics(140, 30, 1)]) + .selectedCandidateIndex, + ).toBe(0); + }); + + it('never lets long-word count outweigh total playable words', () => { + expect( + selectFromMetrics([metrics(139, 100, 1), metrics(140, 1, 0.5)]) + .selectedCandidateIndex, + ).toBe(1); + }); + + it('skips failed candidates while preserving attempt metadata', () => { + const accepted = candidateBoard(4, 1); + let call = 0; + const result = selectStrongBoard({ + size: 4, + random: { next: () => 0.5 }, + dictionary, + dictionaryWords, + candidatePoolSize: 2, + generateCandidate: () => { + call += 1; + return call === 1 + ? { + success: false, + code: 'NO_ACCEPTABLE_BOARD', + attempts: 8, + } + : { success: true, board: accepted, attempts: 2 }; + }, + }); + + expect(result).toMatchObject({ + success: true, + board: accepted, + attempts: 2, + totalGenerationAttempts: 10, + successfulCandidateCount: 1, + selectedCandidateIndex: 1, + }); + }); + + it('isolates candidate exceptions from the remaining pool', () => { + const accepted = candidateBoard(4, 1); + let call = 0; + const result = selectStrongBoard({ + size: 4, + random: { next: () => 0.5 }, + dictionary, + dictionaryWords, + candidatePoolSize: 2, + generateCandidate: () => { + call += 1; + if (call === 1) throw new Error('candidate failed'); + return { success: true, board: accepted, attempts: 2 }; + }, + }); + + expect(result).toMatchObject({ + success: true, + board: accepted, + totalGenerationAttempts: 2, + successfulCandidateCount: 1, + selectedCandidateIndex: 1, + }); + }); + + it('returns NO_ACCEPTABLE_BOARD when no candidate succeeds', () => { + const result = selectStrongBoard({ + size: 4, + random: { next: () => 0.5 }, + dictionary, + dictionaryWords, + candidatePoolSize: 3, + generateCandidate: () => ({ + success: false, + code: 'NO_ACCEPTABLE_BOARD', + attempts: 8, + }), + }); + + expect(result).toEqual({ + success: false, + code: 'NO_ACCEPTABLE_BOARD', + attempts: 24, + }); + }); +}); diff --git a/scripts/audit-strong-board-selection.ts b/scripts/audit-strong-board-selection.ts new file mode 100644 index 0000000..cf991bf --- /dev/null +++ b/scripts/audit-strong-board-selection.ts @@ -0,0 +1,246 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { performance } from 'node:perf_hooks'; + +import { + loadProductionDictionary, + selectStrongBoard, + STRONG_BOARD_CANDIDATE_POOL_SIZE, +} from '@words/game-data'; +import { format as formatWithPrettier } from 'prettier'; + +import { + createSeededRandom, + percentile, + RESEARCH_SIZES, +} from '../packages/game-data/src/board-quality-research.ts'; + +const AUDIT_SEED = 'strong-board-selection-v1'; +const DEFAULT_SELECTIONS_PER_SIZE = 500; +const REPORTED_PERCENTILES = [5, 50, 95] as const; + +interface Options { + readonly count: number; + readonly output: string; + readonly baseline: string; +} + +interface SelectionRow { + readonly playableWordCount: number; + readonly longPlayableWordCount: number; + readonly cellCoverage: number; + readonly successfulCandidateCount: number; + readonly totalGenerationAttempts: number; +} + +interface BaselineMetric { + readonly min: number; + readonly max: number; + readonly mean: number; + readonly p5: number; + readonly p50: number; + readonly p95: number; + readonly p99: number; +} + +function parseArgs(argv: readonly string[]): Options { + const values = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument?.startsWith('--')) continue; + const [key, inlineValue] = argument.slice(2).split('=', 2); + const value = inlineValue ?? argv[++index]; + if (key && value) values.set(key, value); + } + + const count = Number(values.get('count') ?? DEFAULT_SELECTIONS_PER_SIZE); + if (!Number.isInteger(count) || count <= 0 || count > 5_000) { + throw new Error('--count must be an integer from 1 to 5000.'); + } + + return { + count, + output: + values.get('output') ?? + 'docs/board-quality-research/generated/strong-selection-summary.json', + baseline: + values.get('baseline') ?? + 'docs/board-quality-research/generated/summary.json', + }; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const [dictionaryResult, baselineText] = await Promise.all([ + loadProductionDictionary(), + readFile(options.baseline, 'utf8'), + ]); + if (!dictionaryResult.success) { + throw new Error( + `Production dictionary failed to load: ${dictionaryResult.code}`, + ); + } + + const baseline = JSON.parse(baselineText) as { + readonly sizes: Readonly< + Record< + string, + { + readonly metrics: { + readonly playableWordCount: BaselineMetric; + readonly longPlayableWordCount: BaselineMetric; + readonly cellCoverage: BaselineMetric; + }; + } + > + >; + }; + const summaries: Record = {}; + const runtimes: Record = {}; + + for (const size of RESEARCH_SIZES) { + const random = createSeededRandom(`${AUDIT_SEED}:${size}`); + const rows: SelectionRow[] = []; + const runtimeMilliseconds: number[] = []; + + for ( + let selectionIndex = 0; + selectionIndex < options.count; + selectionIndex += 1 + ) { + const started = performance.now(); + const selected = selectStrongBoard({ + size, + random, + dictionary: dictionaryResult.dictionary, + dictionaryWords: dictionaryResult.words, + }); + runtimeMilliseconds.push(performance.now() - started); + if (!selected.success) { + throw new Error( + `Strong selector produced no candidate for ${size}x${size} selection ${selectionIndex}.`, + ); + } + + rows.push({ + playableWordCount: selected.selectedMetrics.playableWordCount, + longPlayableWordCount: selected.selectedMetrics.longPlayableWordCount, + cellCoverage: selected.selectedMetrics.cellCoverage, + successfulCandidateCount: selected.successfulCandidateCount, + totalGenerationAttempts: selected.totalGenerationAttempts, + }); + } + + const baselineSize = baseline.sizes[String(size)]; + if (!baselineSize) { + throw new Error(`Baseline summary is missing size ${size}.`); + } + const selectedPlayableWords = summarizePercentiles( + rows.map((row) => row.playableWordCount), + ); + + summaries[size] = { + originalFiveThousandBoardCorpus: { + playableWordCount: pickBaselinePercentiles( + baselineSize.metrics.playableWordCount, + ), + longPlayableWordCount: pickBaselinePercentiles( + baselineSize.metrics.longPlayableWordCount, + ), + cellCoverage: pickBaselinePercentiles( + baselineSize.metrics.cellCoverage, + ), + }, + selected: { + playableWordCount: selectedPlayableWords, + longPlayableWordCount: summarizePercentiles( + rows.map((row) => row.longPlayableWordCount), + ), + cellCoverage: summarizePercentiles(rows.map((row) => row.cellCoverage)), + }, + upperTailComparison: { + selectedP5MinusOriginalP95: + selectedPlayableWords.p5 - baselineSize.metrics.playableWordCount.p95, + selectedP50MinusOriginalP95: + selectedPlayableWords.p50 - + baselineSize.metrics.playableWordCount.p95, + selectedP5AtOrAboveOriginalP95: + selectedPlayableWords.p5 >= + baselineSize.metrics.playableWordCount.p95, + }, + meanSuccessfulCandidateCount: mean( + rows.map((row) => row.successfulCandidateCount), + ), + meanUnderlyingGenerationAttempts: mean( + rows.map((row) => row.totalGenerationAttempts), + ), + }; + runtimes[size] = summarizeRuntime(runtimeMilliseconds); + } + + const output = { + schemaVersion: 1, + seed: AUDIT_SEED, + selectionsPerSize: options.count, + candidatePoolSize: STRONG_BOARD_CANDIDATE_POOL_SIZE, + ranking: [ + 'highest playable-word count', + 'highest cell coverage', + 'earliest candidate generation index', + ], + sizes: summaries, + }; + + await mkdir(dirname(options.output), { recursive: true }); + await writeFile( + options.output, + await formatWithPrettier(JSON.stringify(output), { parser: 'json' }), + ); + console.log( + JSON.stringify( + { + seed: AUDIT_SEED, + selectionsPerSize: options.count, + candidatePoolSize: STRONG_BOARD_CANDIDATE_POOL_SIZE, + runtimeMilliseconds: runtimes, + output: options.output, + }, + null, + 2, + ), + ); +} + +function pickBaselinePercentiles(metric: BaselineMetric) { + return { + min: metric.min, + p5: metric.p5, + p50: metric.p50, + p95: metric.p95, + p99: metric.p99, + max: metric.max, + }; +} + +function summarizePercentiles(values: readonly number[]) { + return Object.fromEntries( + REPORTED_PERCENTILES.map((percentage) => [ + `p${percentage}`, + percentile(values, percentage), + ]), + ) as Record<`p${(typeof REPORTED_PERCENTILES)[number]}`, number>; +} + +function summarizeRuntime(values: readonly number[]) { + return { + mean: mean(values), + p50: percentile(values, 50), + p95: percentile(values, 95), + }; +} + +function mean(values: readonly number[]): number { + return values.reduce((total, value) => total + value, 0) / values.length; +} + +await main();