Implement Scrabble move generator (DAWG) with English and Russian rules
A Go library that returns every legal play ranked by score and scores or validates plays, using the Appel-Jacobson DAWG algorithm over github.com/iliadenisov/dafsa v1.1.0. - DAWG move generation (across / down / both), full tournament scoring with a per-tile breakdown; public Solver: GenerateMoves (ranked), ScorePlay, ValidatePlay. - Rulesets: English Scrabble, Russian Scrabble, Эрудит (parameterizable Ruleset). - cmd/builddict (build the DAWG from the dictionaries submodule), cmd/stress (self-play benchmark), selfplay engine; brute-force test oracle. - A GADDAG was implemented, benchmarked and removed (the DAWG was smaller and faster for a scoring solver); see RESULTS.md and ALGORITHM.md.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// Command builddict converts a word list into a serialized DAWG, cached under testdata
|
||||
// for the tests and the benchmark. By default it reads the English SOWPODS list from
|
||||
// the dictionaries submodule.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
|
||||
"scrabble-solver/internal/dictdawg"
|
||||
"scrabble-solver/internal/wordlist"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dict := flag.String("dict", "dictionaries/english/sowpods.txt", "word list file (one word per line)")
|
||||
out := flag.String("out", "testdata", "output directory")
|
||||
name := flag.String("name", "sowpods", "base name for the output file")
|
||||
minLen := flag.Int("min", 2, "minimum word length")
|
||||
maxLen := flag.Int("max", 15, "maximum word length")
|
||||
flag.Parse()
|
||||
|
||||
idx := alphabet.Latin()
|
||||
|
||||
t0 := time.Now()
|
||||
words, err := wordlist.Read(*dict, idx, *minLen, *maxLen)
|
||||
if err != nil {
|
||||
log.Fatalf("read %s: %v", *dict, err)
|
||||
}
|
||||
fmt.Printf("loaded %d words from %s in %s\n", len(words), *dict, time.Since(t0).Round(time.Millisecond))
|
||||
|
||||
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
t := time.Now()
|
||||
f, err := dictdawg.Build(idx, words)
|
||||
if err != nil {
|
||||
log.Fatalf("build dawg: %v", err)
|
||||
}
|
||||
path := filepath.Join(*out, *name+".dawg")
|
||||
if err := dictdawg.Save(f, path); err != nil {
|
||||
log.Fatalf("save: %v", err)
|
||||
}
|
||||
size := int64(0)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fmt.Printf("DAWG %d nodes, %s, built+saved in %s -> %s\n",
|
||||
f.NumNodes(), humanBytes(size), time.Since(t).Round(time.Millisecond), path)
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<20:
|
||||
return fmt.Sprintf("%.2f MB", float64(n)/(1<<20))
|
||||
case n >= 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Command stress plays many greedy AI-vs-AI games and reports the DAWG move generator's
|
||||
// speed and memory. It is a benchmark / regression tool for the production generator.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"scrabble-solver/internal/dict"
|
||||
"scrabble-solver/rules"
|
||||
"scrabble-solver/scrabble"
|
||||
"scrabble-solver/selfplay"
|
||||
)
|
||||
|
||||
func main() {
|
||||
games := flag.Int("games", 100, "games to play")
|
||||
flag.Parse()
|
||||
|
||||
rs := rules.English()
|
||||
if !dict.EnglishAvailable() {
|
||||
log.Fatal("English dictionary not available; run `go run ./cmd/builddict` first")
|
||||
}
|
||||
f, err := dict.EnglishDAWG()
|
||||
if err != nil {
|
||||
log.Fatalf("load dawg: %v", err)
|
||||
}
|
||||
gen := scrabble.NewDAWGGenerator(rs, f)
|
||||
structSize := fileSize(dict.DAWGCache())
|
||||
|
||||
runtime.GC()
|
||||
var m0 runtime.MemStats
|
||||
runtime.ReadMemStats(&m0)
|
||||
start := time.Now()
|
||||
|
||||
var turns, plays, movesGen int
|
||||
var genTime time.Duration
|
||||
var score float64
|
||||
for seed := 1; seed <= *games; seed++ {
|
||||
res := selfplay.PlayGame(rs, gen, scrabble.Both, int64(seed), nil)
|
||||
turns += res.Turns
|
||||
plays += res.Plays
|
||||
movesGen += res.MovesGenerated
|
||||
genTime += res.GenTime
|
||||
score += float64(res.Scores[0] + res.Scores[1])
|
||||
}
|
||||
wall := time.Since(start)
|
||||
var m1 runtime.MemStats
|
||||
runtime.ReadMemStats(&m1)
|
||||
|
||||
fmt.Printf("DAWG · English SOWPODS · %d games · board %dx%d · greedy self-play\n\n", *games, rs.Rows, rs.Cols)
|
||||
fmt.Printf(" structure size %s\n", humanBytes(structSize))
|
||||
fmt.Printf(" turns / plays %d / %d\n", turns, plays)
|
||||
fmt.Printf(" moves generated %d\n", movesGen)
|
||||
fmt.Printf(" generation time %s (%.1f µs/turn)\n",
|
||||
genTime.Round(time.Millisecond), float64(genTime.Microseconds())/float64(turns))
|
||||
fmt.Printf(" moves generated/sec %.0f\n", float64(movesGen)/genTime.Seconds())
|
||||
fmt.Printf(" wall time %s\n", wall.Round(time.Millisecond))
|
||||
fmt.Printf(" heap allocated %s (%d GC cycles)\n",
|
||||
humanBytes(int64(m1.TotalAlloc-m0.TotalAlloc)), m1.NumGC-m0.NumGC)
|
||||
fmt.Printf(" avg final game score %.1f\n", score/float64(*games))
|
||||
fmt.Printf(" peak process RSS %s\n", humanKB(peakRSS()))
|
||||
}
|
||||
|
||||
func fileSize(p string) int64 {
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
return fi.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func peakRSS() int64 {
|
||||
data, err := os.ReadFile("/proc/self/status")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
if rest, ok := strings.CutPrefix(line, "VmHWM:"); ok {
|
||||
if f := strings.Fields(rest); len(f) > 0 {
|
||||
kb, _ := strconv.ParseInt(f[0], 10, 64)
|
||||
return kb
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<20:
|
||||
return fmt.Sprintf("%.2f MB", float64(n)/(1<<20))
|
||||
case n >= 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
|
||||
func humanKB(kb int64) string { return humanBytes(kb * 1024) }
|
||||
Reference in New Issue
Block a user