test(offline): real-dictionary move-generator conformance in CI #190
@@ -252,6 +252,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
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/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
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -271,6 +272,7 @@ jobs:
|
|||||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
DICT_GOLD_DIR: /tmp/dictgold
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
DICT_VALID_DIR: /tmp/validgold
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
|
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||||
run: pnpm exec vitest run src/lib/dict/
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
@@ -108,12 +109,18 @@ type genCase struct {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if err := os.MkdirAll(*out, 0o755); err != nil {
|
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||||
log.Fatalf("movegen: mkdir %s: %v", *out, err)
|
log.Fatalf("movegen: mkdir %s: %v", *out, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *dawgDir != "" {
|
||||||
|
buildReal(*dawgDir, *out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||||
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||||
emptyCase("empty-dogs", englishRack("dogsent", 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))
|
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
|
||||||
|
// <variant>.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
|
// runCase fills a case's Moves by generating on a board holding the given placed
|
||||||
// tiles (nil = empty board).
|
// tiles (nil = empty board).
|
||||||
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
||||||
|
|||||||
@@ -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 <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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user