# Scrabble Solver — Implementation Plan ## Outcome (current state) Both generators were implemented and verified to produce identical moves, then compared by self-play stress test (`RESULTS.md`). The **GADDAG was removed**: for a scoring solver it was ~7× larger and no faster than the **DAWG**, which is now the sole generator. Shipped: the DAWG generator, full scoring + breakdown, the public `Solver` (`GenerateMoves`/`ScorePlay`/`ValidatePlay`), and three rulesets (English Scrabble, Russian Scrabble, Эрудит). The rest of this document is the original roadmap, kept for history; the DAWG/GADDAG comparison it describes is preserved in `RESULTS.md`. ## Context We are building a Go library that, given a dictionary, a current game position and a player's rack, returns every legal new play ranked by descending score. The core is a fast finite-automaton move generator based on two papers (analysed in `ALGORITHM.md`): - Appel & Jacobson, *The World's Fastest Scrabble Program* (CACM 1988) — the **DAWG** algorithm (cross-checks, anchor squares, `LeftPart`/`ExtendRight`, edge encoding, cross-sums for scoring, transpose for the perpendicular direction). - Gordon, *A Faster Scrabble Move Generation Algorithm* (SP&E 1994) — the **GADDAG** (`REV(x)◊y` representation, single `Gen`/`GoOn` generator, deterministic cross-sets, construction algorithm). The graph engine is `github.com/iliadenisov/dafsa` — a compact, bit-packed minimized DAWG with a 6-bit "compact alphabet" (`alphabet.Indexer`, ≤63 symbols) and an index-based (`*B`) API, checked out locally at `../dafsa`. ### Headline approach **Implement BOTH generators — DAWG and GADDAG — behind one shared `Generator` interface, then decide which becomes the production default empirically**, via a clean self-play stress test (two greedy players, several games) on the *same* dictionary, measuring speed and memory. The choice is made **after** implementation and measurement. Both implementations are kept; the comparison output (`RESULTS.md`) picks the default. ### Locked decisions | # | Topic | Decision | |---|---|---| | 1 | Core algorithm | **Implement both**: DAWG (Appel-Jacobson) and GADDAG (Gordon, over `dafsa` as a DAWG of `{REV(x)◊y}` with per-node final flags). Pick the default after a self-play stress test. | | 2 | dafsa changes | **Edit `../dafsa` directly**, wire via `go.mod replace`. Leave a spec/CHANGELOG there. | | 3 | Ruleset scope | Default **standard English Scrabble**, fully **parameterizable** (board geometry, premium layout DL/TL/DW/TW, tile values & counts, alphabet, blank count, bingo bonus). Must support **Russian "Эрудит"** (same 15×15 board + premiums; different tile values/counts; Cyrillic alphabet; one word per move, horizontal **or** vertical). | | 4 | Scoring | **Full tournament scoring + breakdown**: main word + all cross-words + premiums (newly-placed tiles only) + bingo bonus; result carries formed cross-words and a per-tile breakdown. | | 5 | Symbol encoding | **`0x80` = wildcard/blank** flag (board/rack/output only — never in the graph). **GADDAG separator `◊` = index == alphabet size** (`cbits` minimal; measured optimum). | | 6 | State model | **Compact byte board** is the generation core; a structured **`Play`** type + a constructor that applies plays to build a board provide the full game-state overlay. | | 7 | API scope | **Generation + scoring + validation** of arbitrary plays. | | 8 | Dictionary | `kamilmielnik/scrabble-dictionaries` as a **git submodule**; `cmd/builddict` builds serialized structures **cached in `testdata/` (gitignored)**. English now; Russian later. | ### Static structure probe (informs expectations; NOT the decision) Full SOWPODS (267,752 words, Σlen = 2,439,269), built through `dafsa`: | Structure | nodes | bytes | bits/char | build | ns/arc | |---|---:|---:|---:|---:|---:| | DAWG (a–z) | 77,808 | 750 KB | 2.46 | 186 ms | 48.7 | | GADDAG sep=size(26) · cbits5 | 587,940 | 5.37 MB | 17.61 | 2.92 s | 61.0 | | GADDAG sep=62 · cbits6 (measured) | 587,940 | 5.53 MB | 18.13 | — | 60.9 | | GADDAG sep=0x40 · cbits7 (extrapolated) | 587,940 | ~5.69 MB | ~18.6 | — | ~61 | GADDAG is ~7× the DAWG and ~25% costlier per arc, but ~2× faster at actual move generation (Gordon Table IV: ~2.5× fewer arcs). The stress test settles it. ## Deliverable documents 1. **`ALGORITHM.md`** — single source of truth (verbatim pseudocode + our adaptation). 2. **`PLAN.md`** — this plan. 3. **`RESULTS.md`** — stress-test comparison + the production-default decision. ## Architecture & package layout ``` scrabble-solver/ go.mod # + replace github.com/iliadenisov/dafsa => ../dafsa PLAN.md ALGORITHM.md RESULTS.md README.md dictionaries/ # git submodule: kamilmielnik/scrabble-dictionaries testdata/ # gitignored: cached serialized DAWG + GADDAG internal/ gaddag/ # REV(x)◊y transform + build + traversal wrapper over dafsa dictdawg/ # plain-DAWG build + traversal wrapper over dafsa encoding/ # byte conventions (wildcard 0x80, separator, board cells) board/ # compact board grid, transpose, premium layout rack/ # rack as per-letter counts + blanks rules/ # Ruleset: geometry, premiums, tile values/counts, alphabet, bonus scrabble/ (public pkg) # Solver + Generator interface; Play/Move types; gen_dawg.go # DAWG generator (LeftPart/ExtendRight) gen_gaddag.go # GADDAG generator (Gen/GoOn) selfplay/ # bag + greedy player + game loop (self-play engine) cmd/builddict/ # word list -> serialized DAWG/GADDAG -> testdata cmd/stress/ # run N self-play games per generator, emit comparison ``` Shared `Generator` interface so the harness can swap implementations: ```go type Generator interface { GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move // ranked, descending score Name() string } ``` Board, rack, rules and **scoring are shared**; cross-set computation is per-generator (DAWG: probe the dictionary DAWG incl. the non-deterministic left set; GADDAG: deterministic GADDAG walk) — that difference is part of what is measured. ## Changes to `../dafsa` (additive ⇒ backward compatible) 1. **Low-level traversal API**: `type Node` (opaque bit-offset); `Root() Node`; `Next(n, ch) (child, final, ok)` (= Gordon's `NextArc`, wraps private `getEdge`); `Arcs(n, fn)` (wraps `getNode`, for blanks/cross-sets); a reusable allocation-free cursor for hot-path `Next`. 2. **Custom-alphabet persistence**: `WriteTo`/`SaveWith` (allow non-embedded alphabet); `ReadWith`/`LoadWith` (inject a known indexer, skip language reconstruction). 3. (Optional) accurate serialized arc/node count; document that `NumEdges()` is build-time. ## Data model & compact formats - **Byte symbol**: low 6 bits = alphabet index; `0x80` = wildcard/blank (I/O only); `◊` = index `len(alphabet)` (GADDAG graph only). - **Board**: `[]byte`, row-major. `0` = empty; occupied = `letterIndex+1`; blank = `(letterIndex+1) | 0x80`. Helpers: `At`, `Set`, `Transpose`, premium lookup. - **Rack**: `[]byte` counts, length `alphabetSize+1`; last slot = blank count. - **`Play`**: `{Row, Col; Dir; Tiles []byte (0x80 flags); Main; CrossWords; Score; Breakdown}` — input for apply/validate/score and the output element. - **Modes**: `Both`, `Horizontal`, `Vertical`. ## Staged implementation - **Stage 0** — Scaffolding & docs: `ALGORITHM.md`, `PLAN.md`, `dictionaries/` submodule, `go.mod` replace, `.gitignore`. - **Stage 1** — dafsa traversal API (shared): `Node`, `Root`, `Next`, `Arcs`, cursor; tests. - **Stage 2** — dafsa custom-alphabet persistence: `SaveWith`/`ReadWith`; round-trip. - **Stage 3** — Shared infra: encoding, board (+transpose), rack, rules (EN; Эрудит stub), scoring, `Generator` interface, `Move`/`Play` types. - **Stage 4** — Dictionary build: `internal/dictdawg` + `internal/gaddag`; `cmd/builddict` caching serialized DAWG **and** GADDAG in `testdata`. - **Stage 5** — Cross-sets: DAWG cross-checks (incl. non-deterministic left set) and GADDAG deterministic cross-sets; validated against each other + brute force on a small lexicon. - **Stage 6** — DAWG generator (`LeftPart`/`ExtendRight`). - **Stage 7** — GADDAG generator (`Gen`/`GoOn`). - **Stage 8** — Correctness gate: DAWG and GADDAG identical move sets on random positions (each move once) + brute force on a tiny dictionary. Must pass before perf comparison. - **Stage 9** — Self-play stress test: `selfplay` engine (bag, racks, greedy policy, seeded RNG, end conditions); `cmd/stress` plays N games per generator measuring time, arcs, allocations (`runtime.MemStats`), peak RSS (`/proc/self/status` VmHWM), footprint; emit `RESULTS.md`. - **Stage 10** — Decision + public API: choose default from `RESULTS.md` (both selectable); finalize `Solver` API, Play↔board constructors, examples. - **Stage 11** — Polish: benchmarks, README, optional prebuilt-graph distribution. ## Verification - `go test ./...`, `go vet`, lint green per stage. - Mutual oracle (Stage 8): identical move sets; brute force on a tiny dictionary. - Build EN structures from the SOWPODS submodule via `cmd/builddict`; run `GenerateMoves` on canonical positions (e.g. Gordon's "CARE on ABLE") and assert top moves/scores. - Run `cmd/stress` (100–1000 seeded games per generator) → `RESULTS.md`. ## Assumptions & caveats - Both algorithms ship; the production default is decided by the stress test. Both remain selectable. - Self-play policy defaults to **greedy** (deterministic tie-break, seeded RNG); tunable. - Separator = real 27th token (`◊`, index = size, `cbits=5`); `0x40` reserved on the board. Wildcard/blank = `0x80`, never in the graph. - **Stateless per-call** generation in v1 (anchors + cross-sets recomputed per call); incremental maintenance is a later optimization (both generators run stateless — a fairness note for the comparison). - Persistence stores only the graph; the (custom) alphabet is injected on load. - Russian "Эрудит" alphabet specifics (Е/Ё handling, tile values/counts) resolved at Stage 3/4; "one word per move, H or V" is satisfied by the modes. - The final-flag GADDAG is larger than Gordon's letter-set form; letter-sets-on-arcs remain a possible future optimization.