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).
375 lines
13 KiB
Go
375 lines
13 KiB
Go
// Command movegen emits golden conformance fixtures for the client-side move
|
||
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
|
||
// committed so the TypeScript parity tests run without a Go toolchain.
|
||
//
|
||
// For each small sample dictionary (English and Russian — the latter reaches
|
||
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
|
||
//
|
||
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
|
||
// - sample_<tag>.words.json the stored words + their alphabet indexes
|
||
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
|
||
// for a handful of positions, plus the ruleset the TS
|
||
// side rebuilds to score identically
|
||
//
|
||
// Positions are built with only the solver's public API: an empty board, and
|
||
// two-ply positions reached by applying the solver's own top move (so no internal
|
||
// encoding is needed). Regenerate with:
|
||
//
|
||
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"flag"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||
dawg "github.com/iliadenisov/dafsa"
|
||
)
|
||
|
||
// sampleWordsEN is the English sample dictionary, in strictly increasing
|
||
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
|
||
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
|
||
var sampleWordsEN = []string{
|
||
"a", "an", "and", "ant",
|
||
"car", "care", "cared", "cares", "cars", "cat", "cats",
|
||
"do", "doe", "does", "dog", "dogs", "done", "dot",
|
||
}
|
||
|
||
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
|
||
// order. It deliberately includes words starting with я (index 32) so the ported
|
||
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
|
||
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
|
||
|
||
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
|
||
// knows the exact word set (as alphabet indexes) to expect from enumeration.
|
||
type sampleFixture struct {
|
||
Alphabet string `json:"alphabet"`
|
||
NumAdded int `json:"numAdded"`
|
||
Words []string `json:"words"`
|
||
Indexes [][]int `json:"indexes"`
|
||
}
|
||
|
||
// genFixture is the move-generation golden set for one sample dictionary.
|
||
type genFixture struct {
|
||
Ruleset genRuleset `json:"ruleset"`
|
||
Cases []genCase `json:"cases"`
|
||
}
|
||
|
||
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
|
||
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
|
||
type genRuleset struct {
|
||
Size int `json:"size"`
|
||
Cols int `json:"cols"`
|
||
Center int `json:"center"`
|
||
RackSize int `json:"rackSize"`
|
||
Bingo int `json:"bingo"`
|
||
Values []int `json:"values"`
|
||
LetterMult [][]int `json:"letterMult"`
|
||
WordMult [][]int `json:"wordMult"`
|
||
}
|
||
|
||
// genTile is one placed tile (a board tile or a move placement).
|
||
type genTile struct {
|
||
Row int `json:"row"`
|
||
Col int `json:"col"`
|
||
Letter int `json:"letter"`
|
||
Blank bool `json:"blank"`
|
||
}
|
||
|
||
// genRack is a rack as a multiset of letter indexes plus a blank count.
|
||
type genRack struct {
|
||
Letters []int `json:"letters"`
|
||
Blanks int `json:"blanks"`
|
||
}
|
||
|
||
// genMove is one ranked generated play: its orientation, placed tiles and total score.
|
||
type genMove struct {
|
||
Dir int `json:"dir"`
|
||
Tiles []genTile `json:"tiles"`
|
||
Score int `json:"score"`
|
||
}
|
||
|
||
// genCase is one generation position: the tiles already on the board (empty when
|
||
// none), the rack, the mode/rule and the ranked moves the solver returns.
|
||
type genCase struct {
|
||
Name string `json:"name"`
|
||
Placed []genTile `json:"placed"`
|
||
Rack genRack `json:"rack"`
|
||
Mode int `json:"mode"`
|
||
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||
Moves []genMove `json:"moves"`
|
||
}
|
||
|
||
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),
|
||
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
|
||
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
|
||
})
|
||
|
||
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
|
||
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
|
||
})
|
||
}
|
||
|
||
// buildSample writes the dawg, the word fixture and the generation golden set for
|
||
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
|
||
// the first non-empty result is applied, then generation runs again on the new rack.
|
||
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
|
||
idx := rs.Alphabet
|
||
b := dawg.New(idx)
|
||
indexes := make([][]int, 0, len(words))
|
||
for _, w := range words {
|
||
if err := b.Add(w); err != nil {
|
||
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
|
||
}
|
||
enc, err := idx.Encode(w)
|
||
if err != nil {
|
||
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
|
||
}
|
||
ints := make([]int, len(enc))
|
||
for i, x := range enc {
|
||
ints[i] = int(x)
|
||
}
|
||
indexes = append(indexes, ints)
|
||
}
|
||
finder := b.Finish()
|
||
|
||
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
|
||
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
|
||
})
|
||
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
|
||
if _, err := finder.Save(dawgPath); err != nil {
|
||
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
|
||
}
|
||
|
||
s := scrabble.NewSolver(rs, finder)
|
||
for i := range cases {
|
||
runCase(s, rs, &cases[i], nil)
|
||
}
|
||
// A two-ply position from the first standard case that produced a move.
|
||
if two := twoPly(s, rs, cases); two != nil {
|
||
cases = append(cases, *two)
|
||
}
|
||
|
||
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
|
||
Ruleset: rulesetOf(rs), Cases: 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
|
||
// tiles (nil = empty board).
|
||
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
||
bd := board.New(rs.Rows, rs.Cols)
|
||
for _, t := range placed {
|
||
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
|
||
}
|
||
c.Placed = placed
|
||
rk := toRack(rs.Size(), c.Rack)
|
||
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
|
||
c.Moves = movesOf(moves)
|
||
}
|
||
|
||
// twoPly reaches a mid-game position by applying the top move of the first standard
|
||
// case that generated one, then generates again with a fresh rack of the same tiles.
|
||
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
|
||
for _, c := range cases {
|
||
if c.IgnoreCrossWords || len(c.Moves) == 0 {
|
||
continue
|
||
}
|
||
placed := c.Moves[0].Tiles
|
||
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
|
||
runCase(s, rs, &next, placed)
|
||
return &next
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
|
||
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
|
||
// to the solver module and cannot be imported.
|
||
func cellByte(letter int, blank bool) byte {
|
||
v := byte(letter+1) & 0x3f
|
||
if blank {
|
||
v |= 0x80
|
||
}
|
||
return v
|
||
}
|
||
|
||
func toRack(size int, r genRack) rack.Rack {
|
||
rk := rack.New(size)
|
||
for _, l := range r.Letters {
|
||
rk.Add(byte(l))
|
||
}
|
||
for i := 0; i < r.Blanks; i++ {
|
||
rk.AddBlank()
|
||
}
|
||
return rk
|
||
}
|
||
|
||
func rulesetOf(rs *rules.Ruleset) genRuleset {
|
||
lm := make([][]int, rs.Rows)
|
||
wm := make([][]int, rs.Rows)
|
||
for r := 0; r < rs.Rows; r++ {
|
||
lm[r] = make([]int, rs.Cols)
|
||
wm[r] = make([]int, rs.Cols)
|
||
for c := 0; c < rs.Cols; c++ {
|
||
p := rs.Premium(r, c)
|
||
lm[r][c] = p.LetterMult()
|
||
wm[r][c] = p.WordMult()
|
||
}
|
||
}
|
||
return genRuleset{
|
||
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
|
||
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
|
||
}
|
||
}
|
||
|
||
func movesOf(ms []scrabble.Move) []genMove {
|
||
out := make([]genMove, len(ms))
|
||
for i, m := range ms {
|
||
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func tilesOf(ps []scrabble.Placement) []genTile {
|
||
out := make([]genTile, len(ps))
|
||
for i, p := range ps {
|
||
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// emptyCase builds an empty-board case (Moves filled later by runCase).
|
||
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
|
||
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
|
||
}
|
||
|
||
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
|
||
func englishRack(letters string, blanks int) genRack {
|
||
idx := make([]int, 0, len(letters))
|
||
for _, ch := range letters {
|
||
idx = append(idx, int(ch-'a'))
|
||
}
|
||
return genRack{Letters: idx, Blanks: blanks}
|
||
}
|
||
|
||
// russianRack builds a rack from the Russian sample letters used above.
|
||
func russianRack(letters string, blanks int) genRack {
|
||
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
|
||
idx := make([]int, 0, len([]rune(letters)))
|
||
for _, ch := range letters {
|
||
i, ok := m[ch]
|
||
if !ok {
|
||
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
|
||
}
|
||
idx = append(idx, i)
|
||
}
|
||
return genRack{Letters: idx, Blanks: blanks}
|
||
}
|
||
|
||
func writeJSON(path string, v any) {
|
||
data, err := json.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
log.Fatalf("movegen: marshal %s: %v", path, err)
|
||
}
|
||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||
log.Fatalf("movegen: write %s: %v", path, err)
|
||
}
|
||
}
|