a9c8f1ecfe
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
Phase A (A4): prove the ported move generator (#188) against the FULL shipped dictionaries, not just the tiny samples — the deep graphs and complete 26/33-letter alphabets the samples cannot reach. - backend/cmd/movegen: add a -dawg-dir mode that emits per-variant golden move-gen vectors from the real dawgs (a bounded first-move + a blank case + a deep 7-tile mid-game position). Regenerated in CI to /tmp, never committed (like the dictgen/validategen vectors), so no dictionary version is pinned into the repo. - ui/src/lib/dict/generate.realparity.test.ts: env-gated (DICT_DAWG_DIR + DICT_MOVEGEN_DIR) parity against that golden — 9 positions across scrabble_en / scrabble_ru / erudit_ru match the Go solver exactly. Skips cleanly when unset. - .gitea/workflows/ci.yaml: the conformance job now generates the movegen golden and points the gated vitest at it (DICT_MOVEGEN_DIR).
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { Dawg } from './dawg';
|
|
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
|
import type { Ruleset } from './validate';
|
|
|
|
// Full-dictionary conformance for the ported move generator: for positions over the real
|
|
// shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed
|
|
// samples cannot reach) it must return exactly the ranked list the Go solver does. The
|
|
// golden vectors come from `go run ./backend/cmd/movegen -dawg-dir <dawg> -out <dir>` and
|
|
// the CI conformance job wires the two directories in; the suite skips when they are unset.
|
|
const dawgDir = process.env.DICT_DAWG_DIR;
|
|
const goldDir = process.env.DICT_MOVEGEN_DIR;
|
|
const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir);
|
|
|
|
// variant -> the release dawg file name (matches dawg.parity.test.ts / the movegen tool).
|
|
const variants = [
|
|
{ variant: 'scrabble_en', dawg: 'en_sowpods' },
|
|
{ variant: 'scrabble_ru', dawg: 'ru_scrabble' },
|
|
{ variant: 'erudit_ru', dawg: 'ru_erudit' },
|
|
];
|
|
|
|
interface Tile {
|
|
row: number;
|
|
col: number;
|
|
letter: number;
|
|
blank: boolean;
|
|
}
|
|
interface GenMove {
|
|
dir: number;
|
|
tiles: Tile[];
|
|
score: number;
|
|
}
|
|
interface Fixture {
|
|
ruleset: {
|
|
size: number;
|
|
cols: number;
|
|
center: number;
|
|
rackSize: number;
|
|
bingo: number;
|
|
values: number[];
|
|
letterMult: number[][];
|
|
wordMult: number[][];
|
|
};
|
|
cases: {
|
|
name: string;
|
|
placed: Tile[] | null;
|
|
rack: { letters: number[]; blanks: number };
|
|
mode: number;
|
|
ignoreCrossWords: boolean;
|
|
moves: GenMove[];
|
|
}[];
|
|
}
|
|
|
|
function buildBoard(placed: Tile[], cols: number): GenBoard {
|
|
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
|
|
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
|
|
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
|
|
return {
|
|
rows: cols,
|
|
cols,
|
|
inBounds,
|
|
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
|
cellAt: (r, c) => grid[r * cols + c]!,
|
|
isEmpty: () => placed.length === 0,
|
|
};
|
|
}
|
|
|
|
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
|
|
const r = fx.ruleset;
|
|
return {
|
|
cols: r.cols,
|
|
center: r.center,
|
|
rackSize: r.rackSize,
|
|
bingo: r.bingo,
|
|
values: r.values,
|
|
letterMult: (row, col) => r.letterMult[row][col],
|
|
wordMult: (row, col) => r.wordMult[row][col],
|
|
ignoreCrossWords,
|
|
};
|
|
}
|
|
|
|
function sig(m: GenMove): string {
|
|
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
|
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
|
|
}
|
|
|
|
describe.skipIf(!ready)('move generator parity vs Go on the real dictionaries', () => {
|
|
for (const v of variants) {
|
|
describe(v.variant, () => {
|
|
// Guard the collection-time reads: the block is skipped when the dirs are unset, but
|
|
// the describe callback still runs to register tests, so touch the filesystem only
|
|
// when ready (otherwise join(undefined, …) would throw during a normal unit run).
|
|
if (!ready) return;
|
|
const fx = JSON.parse(readFileSync(join(goldDir!, `${v.variant}.movegen.json`), 'utf8')) as Fixture;
|
|
const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, `${v.dawg}.dawg`))));
|
|
for (const c of fx.cases) {
|
|
it(
|
|
c.name,
|
|
() => {
|
|
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
|
|
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
|
|
const rs = rulesetFor(fx, c.ignoreCrossWords);
|
|
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
|
|
expect(got.map(sig)).toEqual(c.moves.map(sig));
|
|
},
|
|
60_000,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|