Files
scrabble-solver/PLAN.md
T
Ilia Denisov 15c7959d96 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.
2026-06-01 16:07:32 +02:00

175 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (az) | 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` (1001000 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.