From a9c8f1ecfe1c40609ca71c13b75dab1f5e47b806 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 02:09:42 +0200 Subject: [PATCH] test(offline): real-dictionary move-generator conformance in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .gitea/workflows/ci.yaml | 2 + backend/cmd/movegen/main.go | 79 ++++++++++++++ ui/src/lib/dict/generate.realparity.test.ts | 113 ++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui/src/lib/dict/generate.realparity.test.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e81e94e..5e58272 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -252,6 +252,7 @@ jobs: run: | go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold + go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold" - name: Set up Node uses: actions/setup-node@v4 @@ -271,6 +272,7 @@ jobs: DICT_DAWG_DIR: ${{ github.workspace }}/dawg DICT_GOLD_DIR: /tmp/dictgold DICT_VALID_DIR: /tmp/validgold + DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold run: pnpm exec vitest run src/lib/dict/ # gate is the single branch-protection required check. It always runs and passes diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go index f7003cb..0898722 100644 --- a/backend/cmd/movegen/main.go +++ b/backend/cmd/movegen/main.go @@ -19,6 +19,7 @@ package main import ( + "bytes" "encoding/json" "flag" "log" @@ -108,12 +109,18 @@ type genCase struct { func main() { out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures") + dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples") flag.Parse() if err := os.MkdirAll(*out, 0o755); err != nil { log.Fatalf("movegen: mkdir %s: %v", *out, err) } + if *dawgDir != "" { + buildReal(*dawgDir, *out) + return + } + buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{ emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false), emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false), @@ -172,6 +179,78 @@ func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []gen log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases)) } +// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks +// the conformance positions use. smallRack/blankRack keep the first-move (empty board) +// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position, +// kept small by the anchors around the already-placed word. +type realVariant struct { + file, variant, smallRack, blankRack, fullRack string + rs *rules.Ruleset +} + +// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir — +// the full alphabets and deep graphs the tiny samples cannot reach — one +// .movegen.json per variant. Like the dictgen/validategen vectors it is +// regenerated in CI and never committed, so it pins no dictionary version into the repo. +func buildReal(dawgDir, out string) { + reals := []realVariant{ + {"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()}, + {"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()}, + {"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()}, + } + for _, v := range reals { + data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg")) + if err != nil { + log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err) + } + finder, err := dawg.Read(bytes.NewReader(data), 0) + if err != nil { + log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err) + } + s := scrabble.NewSolver(v.rs, finder) + + cases := []genCase{ + emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false), + emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false), + } + for i := range cases { + runCase(s, v.rs, &cases[i], nil) + } + // A deep 7-tile mid-game: place the top first move, then generate again. The + // anchors around the placed word bound the list while still exercising a full rack, + // deep left/right extension and wide cross-sets over the real graph. + full := encRack(v.rs, v.fullRack, 0) + b := board.New(v.rs.Rows, v.rs.Cols) + if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 { + mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)} + runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles)) + cases = append(cases, mid) + } + + writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases}) + total := 0 + for _, c := range cases { + total += len(c.Moves) + } + _ = finder.Close() + log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total) + } +} + +// encRack encodes a rack given as the variant's letters (plus a blank count) into the +// index-based genRack the fixtures carry. +func encRack(rs *rules.Ruleset, letters string, blanks int) genRack { + enc, err := rs.Alphabet.Encode(letters) + if err != nil { + log.Fatalf("movegen: encode rack %q: %v", letters, err) + } + idx := make([]int, len(enc)) + for i, b := range enc { + idx[i] = int(b) + } + return genRack{Letters: idx, Blanks: blanks} +} + // runCase fills a case's Moves by generating on a board holding the given placed // tiles (nil = empty board). func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) { diff --git a/ui/src/lib/dict/generate.realparity.test.ts b/ui/src/lib/dict/generate.realparity.test.ts new file mode 100644 index 0000000..b460154 --- /dev/null +++ b/ui/src/lib/dict/generate.realparity.test.ts @@ -0,0 +1,113 @@ +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 -out ` 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, + ); + } + }); + } +}); -- 2.52.0