diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ae1ca..3c850be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `internal/engine`: perft (with start-position and Kiwipete regression tests), static evaluation (material + piece-square tables + bishop pair), and an iterative-deepening negamax alpha-beta search with quiescence search, - MVV-LVA move ordering, and a hard time budget. + a shared lock-free transposition table, Lazy SMP, and a hard time budget. +- `internal/engine`: search heuristics — killer moves + a `[side][from][to]` + history table in move ordering, null-move pruning, and late move reductions. - `internal/uci`: a UCI protocol loop (`uci`, `isready`, `ucinewgame`, `position`, `go`, `stop`, `quit`) supporting `go depth`, `go movetime`, and `go wtime/btime`. diff --git a/README.md b/README.md index 7c5715a..d80e1d0 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ Early. The engine plays legal chess via a UCI loop with: - **Move generation** — delegated to `dragontoothmg` (verified with perft). - **Evaluation** — material + piece-square tables + bishop pair. - **Search** — iterative deepening, negamax alpha-beta, quiescence search, - MVV-LVA move ordering, a shared transposition table with hash-move ordering, - Lazy SMP (multi-threaded search), hard time limits. + a shared transposition table with hash-move ordering, MVV-LVA + killer-move + + history move ordering, null-move pruning, late move reductions, Lazy SMP + (multi-threaded search), hard time limits. -Not yet implemented: killer/history heuristics, null-move pruning, opening book, -endgame tablebases. See the [roadmap](#roadmap). +Not yet implemented: aspiration windows / PVS, opening book, endgame tablebases. +See the [roadmap](#roadmap). ## Install @@ -94,8 +95,9 @@ docs/ architecture notes and ADRs - [x] Quiescence search + MVV-LVA move ordering - [x] Transposition table (Zobrist hash from `dragontoothmg`) + hash-move ordering - [x] Lazy SMP — multi-threaded search over the shared TT (`Threads` UCI option) -- [ ] Killer moves + history heuristic -- [ ] Null-move pruning, late move reductions +- [x] Killer moves + history heuristic +- [x] Null-move pruning + late move reductions +- [ ] Aspiration windows / principal variation search - [ ] Opening book (Polyglot) and Syzygy tablebase probing - [ ] Strength testing harness (SPRT via cutechess-cli) diff --git a/cmd/gochess/main_test.go b/cmd/gochess/main_test.go new file mode 100644 index 0000000..af96fb2 --- /dev/null +++ b/cmd/gochess/main_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "io" + "os" + "os/exec" + "strings" + "testing" +) + +// capture redirects os.Stdout for the duration of fn and returns what was +// written. The CLI helpers print directly to stdout, so this is the only way to +// exercise them. +func capture(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + fn() + _ = w.Close() + os.Stdout = orig + return <-done +} + +func TestRunBench(t *testing.T) { + out := capture(t, runBench) + if !strings.Contains(out, "bench:") || !strings.Contains(out, "nps)") { + t.Errorf("bench output missing summary line:\n%s", out) + } + if strings.Count(out, "bestmove") < 4 { + t.Errorf("expected one line per bench position:\n%s", out) + } +} + +func TestRunPerftDefaultAndExplicit(t *testing.T) { + if out := capture(t, func() { runPerft(nil) }); !strings.Contains(out, "nps") { + t.Errorf("default perft produced no series:\n%s", out) + } + out := capture(t, func() { + runPerft([]string{"2", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"}) + }) + if !strings.Contains(out, "depth 1") || !strings.Contains(out, "depth 2") { + t.Errorf("perft series incomplete:\n%s", out) + } +} + +func TestRunPerftRejectsBadDepth(t *testing.T) { + if os.Getenv("GOCHESS_BAD_DEPTH") == "1" { + runPerft([]string{"notanumber"}) + return + } + // runPerft calls os.Exit(2) on a bad depth; run it in a subprocess. + out, err := runSelf(t, "TestRunPerftRejectsBadDepth", "GOCHESS_BAD_DEPTH=1") + if err == nil { + t.Fatalf("expected a non-zero exit, output:\n%s", out) + } + if !strings.Contains(out, "invalid depth") { + t.Errorf("missing diagnostic:\n%s", out) + } +} + +func TestMainDispatch(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"version"}, "gochess "}, + {[]string{"help"}, "usage: gochess"}, + } { + out := capture(t, func() { + os.Args = append([]string{"gochess"}, tc.args...) + main() + }) + if !strings.Contains(out, tc.want) { + t.Errorf("main %v: output missing %q:\n%s", tc.args, tc.want, out) + } + } +} + +func TestMainSpeaksUCIOnStdin(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + origIn := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origIn }() + go func() { + _, _ = io.WriteString(w, "uci\nquit\n") + _ = w.Close() + }() + + out := capture(t, func() { + os.Args = []string{"gochess"} // no subcommand -> UCI mode + main() + }) + if !strings.Contains(out, "uciok") { + t.Errorf("no-arg main did not speak UCI:\n%s", out) + } +} + +func TestMainUnknownCommandExits(t *testing.T) { + if os.Getenv("GOCHESS_UNKNOWN") == "1" { + os.Args = []string{"gochess", "bogus"} + main() + return + } + out, err := runSelf(t, "TestMainUnknownCommandExits", "GOCHESS_UNKNOWN=1") + if err == nil { + t.Fatalf("expected non-zero exit for unknown command:\n%s", out) + } + if !strings.Contains(out, "unknown command") { + t.Errorf("missing diagnostic:\n%s", out) + } +} + +// runSelf re-executes this test binary running only testName, with extra env +// set, and returns the combined output. Used to observe os.Exit paths. +func runSelf(t *testing.T, testName, env string) (string, error) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + cmd := exec.Command(exe, "-test.run=^"+testName+"$") + cmd.Env = append(os.Environ(), env) + b, err := cmd.CombinedOutput() + return string(b), err +} diff --git a/docs/architecture.md b/docs/architecture.md index b5bd1fb..bd7f1a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,13 @@ unapply closure), FEN parsing, an incrementally-updated Zobrist hash deepening around a negamax alpha-beta core, with: - quiescence search at the horizon (captures and promotions only), - transposition-table probes/stores with hash-move ordering, - - MVV-LVA move ordering, + - move ordering: TT move, promotions, MVV-LVA captures, two killer moves + per ply, then quiet moves by a `[side][from][to]` history score, + - null-move pruning (skipped in check, at shallow depth, with only pawns + left, or right after another null move; the pass board is built through + FEN so the shared TT never sees a stale hash), + - late move reductions — late quiet moves are searched a ply or two + shallower and re-searched at full depth only if they beat alpha, - mate-distance-aware scoring (`mateScore - ply`), ply-rebased through the TT, - Lazy SMP: `SearchParams.Threads` workers deepen independently on their own board copy over one shared TT; the deepest completed result wins, @@ -65,8 +71,8 @@ Search is synchronous, so `stop` is a no-op and `bestmove` is emitted as soon as ## Deliberately not here yet -Killer/history heuristics, null-move pruning, LMR, aspiration windows, opening -book, tablebases, pondering, `SearchMoves`/`MultiPV`. +Aspiration windows, principal variation search, static exchange evaluation, +search extensions, opening book, tablebases, pondering, `SearchMoves`/`MultiPV`. ## Key invariants diff --git a/docs/design.md b/docs/design.md index 249322b..a0e6240 100644 --- a/docs/design.md +++ b/docs/design.md @@ -9,17 +9,17 @@ choices see the [ADRs](adr/). ## Architecture `engine.Search` is the search core. It is a plain function around a `searcher` -value (node counter + deadline) with a few free collaborators rather than an -injected object graph — small enough that dependency injection would cost more -than it buys. +value (node counter, deadline, per-ply killer moves, `[side][from][to]` history +table) with a few free collaborators rather than an injected object graph — +small enough that dependency injection would cost more than it buys. | collaborator | responsibility | |---|---| | `dragontoothmg.Board` | board state, legal move generation, `Apply` / unapply, Zobrist hash, FEN — never reimplemented (see [ADR 0002](adr/0002-use-dragontoothmg-for-move-generation.md)) | | `engine.Evaluate` | static evaluation from the side-to-move's view (positive = better for that side) | -| `engine.orderMoves` | orders moves to maximise alpha-beta cut-offs; TT/hash move first, then promotions, then MVV-LVA captures, then quiets | +| `searcher.orderMoves` | orders moves to maximise alpha-beta cut-offs: TT/hash move, promotions, MVV-LVA captures, the two killer moves for the ply, then quiets by history score | | `engine.TT` | shared, lock-free transposition table keyed by `Board.Hash`; probed/stored inside `negamax` and seeded into move ordering | -| `searcher` | carries the node count, TT handle, shared stop flag and wall-clock deadline; answers `timeUp` and flips `stopped` | +| `searcher` | carries the node count, TT handle, shared stop flag, wall-clock deadline, and the per-searcher killer/history tables; answers `timeUp` and flips `stopped` | `internal/uci` is the protocol layer and the only place that prints `info` / `bestmove`. It owns the persistent `engine.TT` (`Hash` option) and the `Threads` @@ -36,7 +36,9 @@ and `bench` subcommands. - [Transposition Table](https://www.chessprogramming.org/Transposition_Table) — power-of-two table keyed by `dragontoothmg.Board.Hash()`; stores EXACT / LOWER / UPPER bounds with the best move, mate scores rebased by ply on store and probe. Lock-free (Hyatt XOR) so Lazy-SMP workers share one table. The stored move seeds move ordering even when the entry is too shallow to cut - [Lazy SMP](https://www.chessprogramming.org/Lazy_SMP) — `Threads` workers run iterative deepening in parallel on private board copies over the shared TT; workers start at staggered depths so they diverge, and the deepest completed result wins - [Quiescence Search](https://www.chessprogramming.org/Quiescence_Search) at the horizon — captures and promotions only, depth-bounded by `maxPly` -- [Move Ordering](https://www.chessprogramming.org/Move_Ordering) — TT/hash move first, then promotions, then [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) captures, then quiet moves +- [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) — `R = 2`, or `3` from depth 6; tried only when not in check, at depth ≥ 3, with a non-mate beta, with non-pawn material for the side to move, and not immediately after another null move. The pass position is built through FEN because `dragontoothmg` keeps the Zobrist hash and en-passant square in unexported fields +- [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions) — from depth 3, quiet moves past the third in the ordered list are searched 1 ply shallower (2 from the seventh move at depth ≥ 5); a reduced search that beats alpha is repeated at full depth. Moves that give or evade check are never reduced +- [Move Ordering](https://www.chessprogramming.org/Move_Ordering) — TT/hash move, then promotions, then [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) captures, then the two [killer moves](https://www.chessprogramming.org/Killer_Heuristic) for the ply, then quiet moves by [history](https://www.chessprogramming.org/History_Heuristic) score (`depth²` per beta cut-off, per `[side][from][to]`, clamped). Killer and history tables are per-searcher, so Lazy-SMP workers keep independent copies - [Mate-distance scoring](https://www.chessprogramming.org/Mate_Distance_Pruning) — `mateScore - ply`, so the shortest mate is preferred; a proven mate ends iterative deepening early - Draw detection — the fifty-move rule (`Halfmoveclock >= 100`) is scored `0` inside the tree - Time management — a hard wall-clock budget checked every 2048 nodes; the UCI layer spends `1/30` of the remaining clock when the GUI sends `wtime` / `btime` instead of `movetime` @@ -55,10 +57,10 @@ update and no pawn or evaluation hash. ### Search -- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — null-window scout + re-search -- [Killer](https://www.chessprogramming.org/Killer_Heuristic) and [history](https://www.chessprogramming.org/History_Heuristic) heuristics in `orderMoves` -- [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) and [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions) +- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — null-window scout + re-search (LMR already does a null-window reduced search; PVS would extend that to every move past the first) - [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) around the previous iteration's score +- Tuning the null-move and LMR formulas (verification search, adaptive `R`, reduction from the history score) against SPRT +- [Check extensions](https://www.chessprogramming.org/Check_Extensions) and other search extensions - [Static Exchange Evaluation](https://www.chessprogramming.org/Static_Exchange_Evaluation) for capture ordering and bad-capture pruning in quiescence - Threefold-[repetition](https://www.chessprogramming.org/Repetitions) detection (needs a position history the current `Search` does not keep) - A real principal variation in the `info` line (only `bestmove` is reported today) diff --git a/docs/engine-strength.md b/docs/engine-strength.md index b5a0313..6e02a6b 100644 --- a/docs/engine-strength.md +++ b/docs/engine-strength.md @@ -1,6 +1,6 @@ # Engine Strength -**Estimated playing strength: ~1500–1750 Elo (likely ~1600) on the CCRL blitz +**Estimated playing strength: ~1900–2150 Elo (likely ~2000) on the CCRL blitz scale.** This is a reasoned estimate from the feature set and search speed, **not a @@ -21,16 +21,18 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. - **Quiescence search** at the horizon (captures and promotions only), so the static evaluation is never taken in the middle of a capture sequence — this alone removes most one-move blunders. -- **Move ordering** — promotions first, then captures by MVV-LVA (most valuable - victim, least valuable attacker), then quiet moves. Good ordering is what - makes alpha-beta actually prune. +- **Move ordering** — hash move, then promotions, then captures by MVV-LVA (most + valuable victim, least valuable attacker), then the two killer moves for the + ply, then quiet moves by history score. Good ordering is what makes alpha-beta + actually prune, and it is also what makes null-move pruning and LMR safe. - **Evaluation** — material values + Michniewski piece-square tables + a bishop-pair bonus. Crude, but it captures development, central control, king placement and the two-bishop advantage. -- **Compiled and fast** — ~2.7M nodes/sec in a single thread on an Apple M4 +- **Compiled and fast** — ~2.5M nodes/sec in a single thread on an Apple M4 (`gochess bench`), and Lazy SMP puts the other cores to work. That is ~50–60× the node rate of a typical Python engine, so effective search depth is - respectable. + respectable — and with reductions and null-move pruning narrowing the tree, + the single-threaded search now reaches depth 12 from the opening in ~1s. - **Robust I/O** — FEN and UCI parsing is fuzz-safe and non-panicking; the engine will not forfeit on a malformed GUI command. @@ -44,16 +46,25 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. - **Lazy SMP.** `Threads` workers deepen in parallel on private board copies over the one shared table. With 4–8 cores this reaches a given depth several times faster than the single-threaded search did. +- **Killer moves + history heuristic.** Quiet moves that cause a beta cut-off + are tried first at sibling nodes (two killers per ply) and accumulate a + `depth²` bonus in a `[side][from][to]` history table that orders the rest. + Both tables are per-searcher, so Lazy-SMP workers stay independent. +- **Null-move pruning** (`R = 2`, `3` from depth 6) with the standard guards + (not in check, depth ≥ 3, non-mate beta, non-pawn material, no consecutive + nulls). +- **Late move reductions.** Late quiet moves are searched 1–2 ply shallower and + re-searched at full depth only when the reduced search beats alpha. + +Together these cut a depth-8 search from the opening from ~5.9M nodes to ~167k +and let the single-threaded search reach depth 12 in roughly the time depth 8 +used to cost. See [performance.md](performance.md). ### Limiting factors -- **No killer or history heuristic.** Quiet-move ordering is essentially - arbitrary, so cut-offs deep in the tree are later than they should be. -- **No null-move pruning, no late move reductions, no futility or delta - pruning, no aspiration windows.** The tree is close to full-width - alpha-beta + quiescence. Effective middlegame depth is roughly 6–9 ply at - blitz time controls, where a pruning-heavy engine of the same speed would - reach 12–16. +- **No aspiration windows or principal variation search.** Every move past the + first at a node is still searched with a full window (LMR aside), so the + alpha-beta tree is wider than a PVS engine's. - **No search extensions** (check extensions, singular extensions). Tactical lines that need one extra ply past the horizon are missed. - **Hand-set evaluation weights, never tuned.** No Texel tuning, no @@ -74,26 +85,26 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. | Reference engine | ~CCRL blitz | Relevant comparison | | ---------------- | ----------- | ------------------- | -| TSCP 1.81 | ~1700 | Similar search shape (alpha-beta, iterative deepening, quiescence, MVV-LVA, transposition table). goChess should now land near TSCP, with the missing pruning heuristics still costing rating. | -| Sungorus 1.4 | ~2000 | TT + null-move + PVS + killers. Clearly stronger than goChess today. | -| CT800 / Claudia class | ~2100+ | Full modern pruning set. Out of reach without the roadmap features. | +| TSCP 1.81 | ~1700 | Similar search shape but no null-move / LMR. goChess should now be clearly stronger. | +| Sungorus 1.4 | ~2000 | TT + null-move + PVS + killers — the closest match to goChess's current feature set. goChess should land near here, held back by the cruder evaluation and the missing PVS. | +| CT800 / Claudia class | ~2100+ | Full modern pruning set plus a tuned eval. Reachable once aspiration/PVS and a tapered, tuned evaluation land. | -The feature set most closely resembles a "first working alpha-beta engine" — -tactically sound at shallow depth, positionally simplistic, and losing rating to -every missing pruning technique. The fast node rate keeps it from dropping into -true beginner territory. +The feature set now resembles a "complete first-generation pruning engine" — +tactically sharp for its node rate, still positionally simplistic (one untuned +PST set, no pawn-structure or king-safety terms), so the evaluation is the main +thing left holding the rating down. ## Time-control sensitivity -Deeper search helps goChess more than most engines, because without pruning its -depth is unusually shallow for its speed — each extra ply is high-value. +Deeper search still helps goChess more than most engines: the evaluation is the +ceiling, so every extra ply that sharpens the tactics is high-value. | Time control | Estimated Elo | Notes | | ------------ | ------------- | ----- | -| Bullet (1+0) | ~1350–1550 | Depth 4–6; positional weaknesses dominate. | -| Blitz (3+2 / 5+0) | ~1500–1750 | Depth 6–9; the headline estimate. | -| Rapid (15+10) | ~1650–1900 | Depth 9–12; tactics get sharper, eval ceiling starts to bite. | -| Classical (40/40) | ~1750–2000 | Depth-limited by the missing pruning heuristics more than by the clock. | +| Bullet (1+0) | ~1750–1950 | Depth 8–11; the crude eval costs the most here. | +| Blitz (3+2 / 5+0) | ~1900–2150 | Depth 11–14; the headline estimate. | +| Rapid (15+10) | ~2000–2250 | Depth 14–18; tactics rarely miss, eval ceiling bites. | +| Classical (40/40) | ~2050–2300 | Eval-limited more than depth-limited. | ## Where the number would move @@ -104,19 +115,19 @@ assuming each is implemented competently and validated by SPRT: | ------ | ------------- | ------ | | Transposition table + hash-move ordering | +150 to +250 | done, pending SPRT | | Lazy SMP (8 threads) | +100 to +150 | done (basic), pending SPRT | -| Killer moves + history heuristic | +50 to +100 | | -| Null-move pruning | +50 to +80 | | -| Late move reductions | +50 to +100 | | -| Aspiration windows | +10 to +30 | | +| Killer moves + history heuristic | +50 to +100 | done, pending SPRT | +| Null-move pruning | +50 to +80 | done, pending SPRT | +| Late move reductions | +50 to +100 | done, pending SPRT | +| Aspiration windows / PVS | +20 to +50 | | | Game-phase eval interpolation (tapered eval) | +30 to +60 | | | Passed pawns / king safety / mobility terms | +40 to +80 | | | Texel-tuned evaluation weights | +40 to +80 | | | Opening book (Polyglot) | +20 to +40 at short TC | | | Syzygy tablebase probing | +10 to +20 | | -With the transposition table and Lazy SMP in, adding killers/history, null-move -and LMR would plausibly put goChess in the 1950–2150 range; tapered/tuned eval -on top targets 2300+. +With the TT, Lazy SMP, killers/history, null-move pruning and LMR all in, +goChess plausibly sits in the ~1900–2150 range; a tapered, tuned evaluation with +pawn-structure and king-safety terms on top is what targets 2300+. ## Measuring it properly diff --git a/docs/performance.md b/docs/performance.md index 79262bb..95a24fd 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,55 +1,67 @@ # Performance Benchmark of the current engine (iterative deepening, negamax alpha-beta, -bounded quiescence, MVV-LVA move ordering, a shared transposition table, Lazy -SMP, hard time limit; **no** killers/history, null-move pruning, or LMR yet) on -one machine: Apple M4, 10 cores, Go 1.27, `darwin/arm64`. Search is from the -starting position unless noted. "Nodes" is the engine's node counter (`negamax` -+ `quiesce` calls). Numbers are single-run and rounded; with a shared TT and -multiple workers the search is no longer bit-for-bit deterministic, so node -counts wobble a few percent between runs. +bounded quiescence, a shared transposition table, MVV-LVA + killer + history +move ordering, null-move pruning, late move reductions, Lazy SMP, hard time +limit) on one machine: Apple M4, 10 cores, Go 1.27, `darwin/arm64`. Search is +from the starting position unless noted. "Nodes" is the engine's node counter +(`negamax` + `quiesce` calls). Numbers are single-run and rounded; with a shared +TT and multiple workers the search is no longer bit-for-bit deterministic, so +node counts wobble a few percent between runs. -## What `go` / `go depth N` runs today: iterative deepening + TT (+ optional Lazy SMP) +## What `go` / `go depth N` runs today `go` deepens from depth 1 until it runs out of `movetime` (or hits `depth N`) and returns the last fully completed depth. Every `negamax` node probes and -stores the shared transposition table, and the stored move seeds move ordering. -With `Threads > 1` that same search runs on N goroutines over the one table -(Lazy SMP). +stores the shared transposition table; move ordering is TT move → promotions → +MVV-LVA captures → killer moves → history; interior nodes try a null move first +and reduce late quiet moves. With `Threads > 1` that same search runs on N +goroutines over the one table (Lazy SMP). Single-threaded, `Hash 128`, generous budget so the run isn't deadline-capped. -"Before TT" is the pre-transposition-table baseline from earlier revisions of -this document: - -| depth | time (before TT) | nodes (before TT) | time (with TT) | nodes (with TT) | -|------:|-----------------:|------------------:|---------------:|----------------:| -| 6 | 0.45s | 3,097,162 | 0.09s | ~0.6M | -| 7 | 3.4s | 23,615,017 | 0.52s | ~3.1M | -| 8 | 30s | 160,689,328 | 1.3s | ~5.9M | - -The transposition table is the single biggest lever in the engine's history — -it recovers most of the redundant re-search between iterations and across -transposing lines, and cuts depth-8-from-the-opening from ~30s to ~1.3s. +The two earlier columns are from previous revisions of this document: "before +TT" is plain alpha-beta + quiescence, "TT only" adds the transposition table but +none of the ordering/pruning heuristics below. + +| depth | nodes (before TT) | nodes (TT only) | time (now) | nodes (now) | +|------:|------------------:|----------------:|-----------:|------------:| +| 6 | 3,097,162 | ~0.6M | 0.02s | 33,000 | +| 7 | 23,615,017 | ~3.1M | 0.02s | 40,000 | +| 8 | 160,689,328 | ~5.9M | 0.07s | 167,000 | +| 9 | — | — | 0.15s | 364,000 | +| 10 | — | — | 0.23s | 548,000 | +| 11 | — | — | 0.67s | 1,426,000 | +| 12 | — | — | 1.13s | 2,285,000 | + +Killers/history + null-move pruning + LMR are the second big lever after the TT: +a depth-8 search from the opening drops from ~5.9M nodes to ~167k (~35×), and the +engine now reaches depth 12 in about the wall-clock time depth 8 used to take. +Node rate is roughly unchanged at ~2.5M nps (`gochess bench`) — each node costs +a little more now (a static eval and a check test per interior node, plus a +FEN round-trip per null move) but there are far fewer of them. + +The effective branching factor across the deeper rows is ~1.8 (√ of the +node-count ratio between adjacent depths), versus ~7 for TT-only alpha-beta and +√35 ≈ 5.9 for the theoretical alpha-beta minimum — reductions and null-move +pruning search a tree much narrower than full-width minimax. ## Lazy SMP scaling Basic Lazy SMP: workers share the TT and start at staggered depths, but there is no root-move splitting or aspiration-window skew yet, so on TT-friendly -positions the workers largely re-explore the same tree. Fixed-depth wall-clock -is roughly flat; the value shows up as extra breadth (each worker's TT-perturbed -ordering occasionally finds a better line) and robustness on tactical positions, -plus headroom once root splitting lands. Approximate, `movetime 2000` from the -start position: +positions the workers largely re-explore the same tree. The value shows up as +extra breadth and tactical robustness, and as a modest depth gain at fixed time. +Approximate, `movetime 2000` from the start position: | threads | depth reached | nodes | |--------:|--------------:|------:| -| 1 | 8 | ~9.1M | -| 2 | 8 | ~17.8M | -| 4 | 8 | ~32.1M | -| 8 | 8 | ~42.6M | +| 1 | 13 | ~3.8M | +| 2 | 13 | ~7.9M | +| 4 | 14 | ~14.4M | +| 8 | 14 | ~20.2M | -Nodes scale with worker count (overlapping work); turning that into deeper -fixed-time search is what per-worker root-move splitting on the +Nodes scale with worker count (overlapping work); turning that into consistently +deeper fixed-time search is what per-worker root-move splitting on the [roadmap](../README.md#roadmap) is for. ## Move generation: `perft` from the start position @@ -67,43 +79,15 @@ Move generation is delegated to `dragontoothmg` and is not the bottleneck. ~160–170M nodes/sec at the deeper counts, all of which match the published [perft results](https://www.chessprogramming.org/Perft_Results). -## Single fixed-depth search from the start position - -A controlled baseline: one search to exactly depth `N` (no iterative deepening), -clock never armed. It isolates search-tree efficiency, so this is the table to -watch when judging whether a search change helped. Not what the engine runs in a -game. - -> **Stale since the TT landed.** The table below predates the transposition -> table. `Search` now always uses a TT (a private one when the caller passes -> none), so a single fixed-depth search from a cold table already benefits from -> intra-search transpositions — the real node counts are lower than shown and -> vary slightly run to run. Regenerate this table with a fresh benchmark. - -The `perft(depth)` column is the size of the *full* legal game tree at that -depth — the branching the search would face with no alpha-beta at all. `pruned` -is `1 - nodes/perft`. - -| depth | time | nodes | perft(depth) | pruned | -|------:|-----:|------:|-------------:|-------:| -| 2 | 0.000s | 452 | 400 | −13% | -| 3 | 0.002s | 5,318 | 8,902 | 40% | -| 4 | 0.011s | 49,804 | 197,281 | 75% | -| 5 | 0.056s | 401,589 | 4,865,609 | 91.8% | -| 6 | 0.40s | 2,639,959 | 119,060,324 | 97.8% | -| 7 | 2.9s | 20,517,855 | 3,195,901,860 | 99.4% | -| 8 | 27s | 137,074,311 | 84,998,978,956 | 99.84% | - -The `pruned` column is an *estimate*: the two counts aren't the same unit. -"nodes" counts every node the search visits (internal nodes and quiescence -included, and quiescence looks past `depth` in forcing lines); `perft` is only -the leaves at exactly `depth`. At depth 2 the search actually expands *more* -nodes than the full tree — quiescence chases every capture sequence to its end -and there is no TT to catch repeats — so `pruned` goes negative. From depth 4 on -the trend is real: MVV-LVA ordering plus alpha-beta take the tree from "search -all of it" to "search one node in ~620" by depth 8. - -The effective branching factor across the deeper rows is roughly 7 (≈√35, the -alpha-beta ideal for a ~35-move position would be ~6). Closing that last gap — -and cutting the constant factor — is what the transposition table, killer moves, -and null-move pruning on the [roadmap](../README.md#roadmap) are for. +## Regenerating these numbers + +```bash +gochess bench # 4-position fixed-depth-6 node/nps summary +gochess perft 6 # timed perft series +``` + +The per-depth tables above come from a short throwaway test that calls +`engine.Search` with `SearchParams{MaxDepth: d, TT: engine.NewTT(128)}` for each +depth and prints `res.Elapsed` / `res.Nodes`. Re-run it after any search change +and update the "nodes (now)" column; a regression there is the first sign a +heuristic is mis-tuned. diff --git a/internal/engine/search.go b/internal/engine/search.go index 783ffab..9f7d900 100644 --- a/internal/engine/search.go +++ b/internal/engine/search.go @@ -2,6 +2,7 @@ package engine import ( "sort" + "strings" "sync" "sync/atomic" "time" @@ -16,6 +17,19 @@ const ( drawScore = 0 infinity = 1 << 30 maxPly = 64 + // mateThreshold is the smallest score still considered "a mate": any score + // at least this big encodes a forced mate for the side to move. + mateThreshold = mateScore - maxPly + + // nmpMinDepth is the shallowest depth at which null-move pruning is tried. + nmpMinDepth = 3 + // lmrMinDepth / lmrMinMove gate late move reductions: only in subtrees at + // least this deep, and only for quiet moves this far down the ordered list. + lmrMinDepth = 3 + lmrMinMove = 3 + // historyMax caps a history counter so repeated cut-offs cannot dwarf the + // capture scores in move ordering. + historyMax = 1 << 22 ) // SearchParams controls a single Search call. @@ -51,6 +65,13 @@ type searcher struct { nodes int64 deadline time.Time stopped bool + + // killers holds, per ply, up to two quiet moves that most recently caused a + // beta cut-off at that ply; history accumulates depth^2 for every quiet move + // that caused a cut-off, indexed by [side][from][to]. Both are per-searcher, + // so Lazy-SMP workers keep independent tables and need no synchronisation. + killers [maxPly + 1][2]dragontoothmg.Move + history [2][64][64]int } func (s *searcher) timeUp() bool { @@ -159,7 +180,7 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start break // out of time: keep the previous completed depth } res.BestMove, res.Score, res.Depth = move, score, depth - if score >= mateScore-maxPly || score <= -mateScore+maxPly { + if score >= mateThreshold || score <= -mateThreshold { if s.stop != nil { s.stop.Store(true) // forced mate: let the other workers stop too } @@ -177,12 +198,12 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes key := b.Hash() _, ttMove, _ := s.tt.probe(key, depth, -infinity, infinity, 0) - moves := orderMoves(b, b.GenerateLegalMoves(), ttMove) + moves := s.orderMoves(b, b.GenerateLegalMoves(), ttMove, 0) alpha, beta := -infinity, infinity bestScore := -infinity for _, m := range moves { unapply := b.Apply(m) - v := -s.negamax(b, depth-1, -beta, -alpha, 1) + v := -s.negamax(b, depth-1, -beta, -alpha, 1, true) unapply() if s.stopped { return 0, dragontoothmg.Move(0), false @@ -198,23 +219,40 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes return bestScore, best, true } -func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int) int { +// outOfTime bumps the node counter and, every 2048 nodes, checks the clock. It +// latches s.stopped (and the shared stop flag) so every frame unwinds fast. +func (s *searcher) outOfTime() bool { s.nodes++ - if s.nodes&2047 == 0 && s.timeUp() { - s.stopped = true - if s.stop != nil { - s.stop.Store(true) - } - return 0 + if s.nodes&2047 != 0 || !s.timeUp() { + return false } - if b.White.Kings == 0 || b.Black.Kings == 0 { - return kingCaptureScore(b, ply) + s.stopped = true + if s.stop != nil { + s.stop.Store(true) } - if b.Halfmoveclock >= 100 { - return drawScore + return true +} + +// terminalScore handles the non-search node types: a captured king, the +// fifty-move rule, and the quiescence hand-off at the horizon. +func (s *searcher) terminalScore(b *dragontoothmg.Board, depth, alpha, beta, ply int) (int, bool) { + switch { + case b.White.Kings == 0 || b.Black.Kings == 0: + return kingCaptureScore(b, ply), true + case b.Halfmoveclock >= 100: + return drawScore, true + case depth <= 0: + return s.quiesce(b, alpha, beta, ply), true + } + return 0, false +} + +func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, canNull bool) int { + if s.outOfTime() { + return 0 } - if depth <= 0 { - return s.quiesce(b, alpha, beta, ply) + if v, done := s.terminalScore(b, depth, alpha, beta, ply); done { + return v } alphaOrig := alpha @@ -224,9 +262,14 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int) return ttScore } + inCheck := b.OurKingInCheck() + if v, ok := s.tryNullMove(b, depth, beta, ply, canNull, inCheck); ok { + return v + } + moves := b.GenerateLegalMoves() if len(moves) == 0 { - if b.OurKingInCheck() { + if inCheck { return -mateScore + ply // checkmate; prefer the shortest mate } return drawScore // stalemate @@ -234,10 +277,8 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int) best := -infinity var bestMove dragontoothmg.Move - for _, m := range orderMoves(b, moves, ttMove) { - unapply := b.Apply(m) - v := -s.negamax(b, depth-1, -beta, -alpha, ply+1) - unapply() + for i, m := range s.orderMoves(b, moves, ttMove, ply) { + v := s.searchMove(b, m, i, depth, alpha, beta, ply, inCheck) if s.stopped { return 0 } @@ -248,6 +289,9 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int) alpha = v } if alpha >= beta { + if isQuiet(b, m) { + s.recordCutoff(b, m, depth, ply) + } break // fail-high: opponent won't enter this line } } @@ -263,6 +307,62 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int) return best } +// searchMove applies m, searches the resulting position, and returns its score +// from the current side's point of view. Late quiet moves are first searched at +// a reduced depth (LMR); a reduced search that beats alpha is repeated at full +// depth so the true score is never lost. +func (s *searcher) searchMove(b *dragontoothmg.Board, m dragontoothmg.Move, moveIdx, depth, alpha, beta, ply int, inCheck bool) int { + quiet := isQuiet(b, m) + unapply := b.Apply(m) + defer unapply() + givesCheck := b.OurKingInCheck() + + newDepth := depth - 1 + if depth >= lmrMinDepth && moveIdx >= lmrMinMove && quiet && !inCheck && !givesCheck { + red := 1 + if moveIdx >= 6 && depth >= 5 { + red = 2 + } + v := -s.negamax(b, newDepth-red, -alpha-1, -alpha, ply+1, true) + if v <= alpha { + return v // stays fail-low even at full depth: no re-search needed + } + } + return -s.negamax(b, newDepth, -beta, -alpha, ply+1, true) +} + +// tryNullMove implements null-move pruning: if handing the opponent a free move +// still leaves the static evaluation at or above beta, the position is good +// enough that a full search is very unlikely to drop below beta, so return a +// cut-off. Skipped in check, in shallow subtrees, when the side to move has only +// pawns (the classic zugzwang trap), when beta is already a mate score, or +// immediately after another null move. Returns (score, true) when it cuts. +func (s *searcher) tryNullMove(b *dragontoothmg.Board, depth, beta, ply int, canNull, inCheck bool) (int, bool) { + if !canNull || inCheck || depth < nmpMinDepth || beta >= mateThreshold { + return 0, false + } + if !hasNonPawnMaterial(b) || Evaluate(b) < beta { + return 0, false + } + + r := 2 + if depth >= 6 { + r = 3 + } + nb := nullMoveBoard(b) + score := -s.negamax(&nb, depth-1-r, -beta, -beta+1, ply+1, false) + if s.stopped { + return 0, true // value ignored; the caller re-checks s.stopped + } + if score >= beta { + if score >= mateThreshold { + score = beta // a mate found only past a null move is not proven + } + return score, true + } + return 0, false +} + // quiesce searches only "loud" moves (captures and promotions) past the horizon // so the static eval is never taken in the middle of a capture sequence. func (s *searcher) quiesce(b *dragontoothmg.Board, alpha, beta, ply int) int { @@ -281,7 +381,7 @@ func (s *searcher) quiesce(b *dragontoothmg.Board, alpha, beta, ply int) int { return stand } - for _, m := range orderMoves(b, b.GenerateLegalMoves(), 0) { + for _, m := range s.orderMoves(b, b.GenerateLegalMoves(), 0, ply) { if !dragontoothmg.IsCapture(m, b) && m.Promote() == dragontoothmg.Nothing { continue } @@ -301,6 +401,22 @@ func (s *searcher) quiesce(b *dragontoothmg.Board, alpha, beta, ply int) int { return alpha } +// recordCutoff credits a quiet move that produced a beta cut-off: it becomes a +// killer for its ply and its history counter grows by depth^2. +func (s *searcher) recordCutoff(b *dragontoothmg.Board, m dragontoothmg.Move, depth, ply int) { + if ply >= 0 && ply <= maxPly { + k := &s.killers[ply] + if k[0] != m { + k[1] = k[0] + k[0] = m + } + } + h := &s.history[sideIndex(b)][m.From()][m.To()] + if *h += depth * depth; *h > historyMax { + *h = historyMax + } +} + // kingCaptureScore scores the illegal position left when a previous ply captured // a king. dragontoothmg can generate such a move when the position it is given // has the side *not* to move in check; without this guard the next @@ -317,10 +433,55 @@ func kingCaptureScore(b *dragontoothmg.Board, ply int) int { return mateScore - ply // the opponent's king is gone } +// sideIndex is 0 for White to move, 1 for Black — the first index of history. +func sideIndex(b *dragontoothmg.Board) int { + if b.Wtomove { + return 0 + } + return 1 +} + +// isQuiet reports whether m is neither a capture nor a promotion. +func isQuiet(b *dragontoothmg.Board, m dragontoothmg.Move) bool { + return m.Promote() == dragontoothmg.Nothing && !dragontoothmg.IsCapture(m, b) +} + +// hasNonPawnMaterial reports whether the side to move has a piece other than +// pawns and the king — the precondition that makes null-move pruning safe. +func hasNonPawnMaterial(b *dragontoothmg.Board) bool { + side := &b.White + if !b.Wtomove { + side = &b.Black + } + return side.Knights|side.Bishops|side.Rooks|side.Queens != 0 +} + +// nullMoveBoard returns b with the side to move flipped and any en-passant right +// dropped — the position reached by "passing". It goes through FEN because +// dragontoothmg keeps the Zobrist hash and en-passant square in unexported +// fields, and a stale hash would corrupt the shared transposition table. +func nullMoveBoard(b *dragontoothmg.Board) dragontoothmg.Board { + f := strings.Fields(b.ToFen()) + if f[1] == "w" { + f[1] = "b" + } else { + f[1] = "w" + } + f[3] = "-" + return dragontoothmg.ParseFen(strings.Join(f, " ")) +} + // orderMoves sorts moves best-first so alpha-beta prunes as early as possible: -// the transposition-table move first, then promotions, then captures by MVV-LVA -// (most valuable victim, least valuable attacker), then quiet moves. -func orderMoves(b *dragontoothmg.Board, moves []dragontoothmg.Move, ttMove dragontoothmg.Move) []dragontoothmg.Move { +// the transposition-table move, then promotions, then captures by MVV-LVA (most +// valuable victim, least valuable attacker), then the two killer moves for this +// ply, then the remaining quiet moves by history score. +func (s *searcher) orderMoves(b *dragontoothmg.Board, moves []dragontoothmg.Move, ttMove dragontoothmg.Move, ply int) []dragontoothmg.Move { + var k0, k1 dragontoothmg.Move + if ply >= 0 && ply <= maxPly { + k0, k1 = s.killers[ply][0], s.killers[ply][1] + } + side := sideIndex(b) + type scored struct { move dragontoothmg.Move score int @@ -328,18 +489,26 @@ func orderMoves(b *dragontoothmg.Board, moves []dragontoothmg.Move, ttMove drago list := make([]scored, len(moves)) for i := range moves { m := moves[i] - sc := 0 - if ttMove != 0 && m == ttMove { - sc = 1 << 20 - } else { - if p := m.Promote(); p != dragontoothmg.Nothing { - sc += 90000 + pieceValue[p] - } + var sc int + switch { + case ttMove != 0 && m == ttMove: + sc = 1 << 24 + case m.Promote() != dragontoothmg.Nothing: + sc = 1<<20 + pieceValue[m.Promote()] if dragontoothmg.IsCapture(m, b) { victim, _ := dragontoothmg.GetPieceType(m.To(), b) - attacker, _ := dragontoothmg.GetPieceType(m.From(), b) - sc += 10000 + pieceValue[victim]*8 - pieceValue[attacker] + sc += pieceValue[victim] } + case dragontoothmg.IsCapture(m, b): + victim, _ := dragontoothmg.GetPieceType(m.To(), b) + attacker, _ := dragontoothmg.GetPieceType(m.From(), b) + sc = 1<<19 + pieceValue[victim]*8 - pieceValue[attacker] + case m == k0: + sc = 1<<18 + 1 + case m == k1: + sc = 1 << 18 + default: + sc = s.history[side][m.From()][m.To()] } list[i] = scored{m, sc} } diff --git a/internal/engine/search_internal_test.go b/internal/engine/search_internal_test.go new file mode 100644 index 0000000..b5bb0ad --- /dev/null +++ b/internal/engine/search_internal_test.go @@ -0,0 +1,240 @@ +package engine + +import ( + "testing" + "time" + + "github.com/dylhunn/dragontoothmg" +) + +func TestNullMoveBoardFlipsSideAndDropsEnPassant(t *testing.T) { + // After 1.e4 the en-passant square is e3 and White is to move having just + // pushed; take Black to move here so there is a real ep target. + b := dragontoothmg.ParseFen("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 3") + nb := nullMoveBoard(&b) + + if nb.Wtomove == b.Wtomove { + t.Errorf("side to move not flipped: still %v", nb.Wtomove) + } + if nb.Hash() == b.Hash() { + t.Errorf("hash unchanged after null move: %#x", nb.Hash()) + } + if got := field(nb.ToFen(), 3); got != "-" { + t.Errorf("en-passant square = %q, want %q", got, "-") + } + // Piece placement must be identical. + if field(nb.ToFen(), 0) != field(b.ToFen(), 0) { + t.Errorf("piece placement changed: %q vs %q", field(nb.ToFen(), 0), field(b.ToFen(), 0)) + } +} + +func TestNullMoveBoardFromBlack(t *testing.T) { + b := dragontoothmg.ParseFen("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 2") + nb := nullMoveBoard(&b) + if !nb.Wtomove { + t.Errorf("null move from Black should leave White to move") + } +} + +func TestHasNonPawnMaterial(t *testing.T) { + tests := []struct { + name string + fen string + want bool + }{ + {"startpos", dragontoothmg.Startpos, true}, + {"only pawns for side to move", "4k3/8/8/8/8/8/4P3/4K3 w - - 0 1", false}, + {"lone king", "4k3/8/8/8/8/8/8/4K3 w - - 0 1", false}, + {"knight only", "4k3/8/8/8/8/8/4N3/4K3 w - - 0 1", true}, + {"opponent has pieces, we do not", "3qk3/8/8/8/8/8/4P3/4K3 w - - 0 1", false}, + } + for _, tt := range tests { + b := dragontoothmg.ParseFen(tt.fen) + if got := hasNonPawnMaterial(&b); got != tt.want { + t.Errorf("%s: hasNonPawnMaterial = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestIsQuiet(t *testing.T) { + b := dragontoothmg.ParseFen("4k3/8/8/3p4/4P3/8/8/4K3 w - - 0 1") + var capture, quiet dragontoothmg.Move + for _, m := range b.GenerateLegalMoves() { + switch m.String() { + case "e4d5": + capture = m + case "e4e5": + quiet = m + } + } + if isQuiet(&b, capture) { + t.Errorf("e4d5 is a capture, isQuiet returned true") + } + if !isQuiet(&b, quiet) { + t.Errorf("e4e5 is a quiet push, isQuiet returned false") + } +} + +func TestRecordCutoffUpdatesKillersAndHistory(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + s := &searcher{tt: NewTT(1)} + + moves := b.GenerateLegalMoves() + first, second := moves[0], moves[1] + + s.recordCutoff(&b, first, 5, 3) + if s.killers[3][0] != first { + t.Fatalf("killer[3][0] = %v, want %v", s.killers[3][0], first) + } + if got := s.history[sideIndex(&b)][first.From()][first.To()]; got != 25 { + t.Errorf("history after depth-5 cutoff = %d, want 25", got) + } + + s.recordCutoff(&b, second, 4, 3) + if s.killers[3][0] != second || s.killers[3][1] != first { + t.Errorf("killers not shifted: got [%v %v]", s.killers[3][0], s.killers[3][1]) + } + + // Re-recording the same killer must not duplicate it into both slots. + s.recordCutoff(&b, second, 2, 3) + if s.killers[3][1] == second { + t.Errorf("killer duplicated into both slots") + } +} + +func TestRecordCutoffHistoryIsCapped(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + s := &searcher{tt: NewTT(1)} + m := b.GenerateLegalMoves()[0] + for i := 0; i < 5000; i++ { + s.recordCutoff(&b, m, 63, 1) + } + if got := s.history[sideIndex(&b)][m.From()][m.To()]; got != historyMax { + t.Errorf("history = %d, want it clamped to %d", got, historyMax) + } +} + +func TestOrderMovesRanksKillersAheadOfOtherQuiets(t *testing.T) { + b := dragontoothmg.ParseFen("r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1") + s := &searcher{tt: NewTT(1)} + moves := b.GenerateLegalMoves() + + // Pick a quiet move near the end of natural generation order and make it a + // killer for this ply. + killer := moves[len(moves)-1] + s.killers[2][0] = killer + + ordered := s.orderMoves(&b, moves, 0, 2) + + killerPos, lastQuietPos := -1, -1 + for i, m := range ordered { + if m == killer { + killerPos = i + } + if isQuiet(&b, m) && m != killer { + lastQuietPos = i + } + } + if killerPos == -1 { + t.Fatal("killer move missing from ordered list") + } + if killerPos > lastQuietPos { + t.Errorf("killer at %d ranked behind a plain quiet at %d", killerPos, lastQuietPos) + } +} + +func TestOrderMovesUsesHistoryForQuiets(t *testing.T) { + b := dragontoothmg.ParseFen("r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1") + s := &searcher{tt: NewTT(1)} + moves := b.GenerateLegalMoves() + favoured := moves[len(moves)-1] + s.history[sideIndex(&b)][favoured.From()][favoured.To()] = historyMax + + ordered := s.orderMoves(&b, moves, 0, 1) + // With no TT move, no promotions and no captures possible here, the + // history-boosted quiet must sort first. + if ordered[0] != favoured { + t.Errorf("history-boosted move ranked %v first, want %v", ordered[0].String(), favoured.String()) + } +} + +func TestTryNullMoveCutsWhenWinning(t *testing.T) { + // White is a whole queen up with pieces on the board: passing still leaves + // the eval far above a modest beta, so null-move pruning should cut. + b := dragontoothmg.ParseFen("4k3/8/8/8/8/5N2/4PPPP/Q3K2R w K - 0 1") + s := &searcher{tt: NewTT(4)} + score, ok := s.tryNullMove(&b, 5, 200, 1, true, false) + if !ok { + t.Fatalf("expected a null-move cut-off, got none") + } + if score < 200 { + t.Errorf("cut-off score %d is below beta 200", score) + } +} + +func TestTryNullMoveSkippedConditions(t *testing.T) { + b := dragontoothmg.ParseFen("4k3/8/8/8/8/5N2/4PPPP/Q3K2R w K - 0 1") + s := &searcher{tt: NewTT(4)} + + if _, ok := s.tryNullMove(&b, 5, 200, 1, false, false); ok { + t.Errorf("null move ran with canNull=false") + } + if _, ok := s.tryNullMove(&b, 5, 200, 1, true, true); ok { + t.Errorf("null move ran while in check") + } + if _, ok := s.tryNullMove(&b, 2, 200, 1, true, false); ok { + t.Errorf("null move ran below nmpMinDepth") + } + if _, ok := s.tryNullMove(&b, 5, mateScore-1, 1, true, false); ok { + t.Errorf("null move ran with a mate-score beta") + } + + // Only pawns for the side to move: zugzwang guard must block the cut. + pawnsOnly := dragontoothmg.ParseFen("4k3/8/8/8/8/8/P7/4K3 w - - 0 1") + if _, ok := s.tryNullMove(&pawnsOnly, 5, -5000, 1, true, false); ok { + t.Errorf("null move ran with no non-pawn material") + } + + // Eval below beta: nothing to prune. + if _, ok := s.tryNullMove(&b, 5, mateThreshold-1, 1, true, false); ok { + t.Errorf("null move cut with eval far below beta") + } +} + +func TestKingCaptureScore(t *testing.T) { + // Black has no king: from White's side the opponent king is gone (win); a + // position with White's own king missing is a loss. + won := dragontoothmg.ParseFen("8/8/8/8/8/8/8/4K3 w - - 0 1") + if got := kingCaptureScore(&won, 3); got <= 0 { + t.Errorf("missing enemy king scored %d, want a win", got) + } + lost := dragontoothmg.ParseFen("4k3/8/8/8/8/8/8/8 w - - 0 1") + if got := kingCaptureScore(&lost, 3); got >= 0 { + t.Errorf("missing own king scored %d, want a loss", got) + } +} + +func TestNegamaxLeavesBoardUnmodified(t *testing.T) { + fen := "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" + b := dragontoothmg.ParseFen(fen) + s := &searcher{tt: NewTT(8), deadline: time.Now().Add(time.Second)} + s.negamax(&b, 5, -infinity, infinity, 0, true) + if got := b.ToFen(); got != fen { + t.Errorf("board mutated by negamax:\n got %q\nwant %q", got, fen) + } +} + +// field returns the i-th space-separated field of a FEN string. +func field(fen string, i int) string { + start, n := 0, 0 + for j := 0; j <= len(fen); j++ { + if j == len(fen) || fen[j] == ' ' { + if n == i { + return fen[start:j] + } + n++ + start = j + 1 + } + } + return "" +} diff --git a/internal/engine/search_test.go b/internal/engine/search_test.go index e9d392d..519c070 100644 --- a/internal/engine/search_test.go +++ b/internal/engine/search_test.go @@ -124,6 +124,49 @@ func TestSearchLazySMPWinsFreeMaterial(t *testing.T) { } } +func TestSearchFindsMateWithPruningActive(t *testing.T) { + // A forced mate in 3 that the search only sees several plies deep, so the + // answer must survive killers/history ordering, LMR reductions, and the + // null-move guard that switches off near mate scores. + b := dragontoothmg.ParseFen("5rk1/pp4pp/8/8/8/8/PP1Q2PP/5RK1 w - - 0 1") + res := engine.Search(&b, engine.SearchParams{MaxDepth: 8}) + if got := res.BestMove.String(); got != "d2d5" { + t.Fatalf("best move = %s (score %d), want d2d5", got, res.Score) + } + if res.Score < mateThreshold { + t.Errorf("score = %d, want a mate score (>= %d)", res.Score, mateThreshold) + } +} + +func TestSearchReducedTreeStillReachesDeepDepth(t *testing.T) { + // With killers/history + NMP + LMR the start position should complete a + // depth well past what full-width alpha-beta manages in the same node + // budget. This also exercises the LMR re-search path heavily. + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + res := engine.Search(&b, engine.SearchParams{MaxDepth: 9}) + if res.Depth != 9 { + t.Fatalf("completed depth %d, want 9", res.Depth) + } + if res.BestMove.String() == "0000" { + t.Fatal("no move returned") + } +} + +func TestSearchPrunedResultMatchesPlainOnTactics(t *testing.T) { + // The pruning heuristics change the node count, never the verdict on a + // forced tactic: the mate and the free rook must still be found. + mate := dragontoothmg.ParseFen("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1") + mateBest := engine.Search(&mate, engine.SearchParams{MaxDepth: 5}).BestMove + if got := mateBest.String(); got != "a1a8" { + t.Errorf("mate: best move = %s, want a1a8", got) + } + rook := dragontoothmg.ParseFen("3q2k1/8/8/8/8/8/6K1/3R4 w - - 0 1") + rookBest := engine.Search(&rook, engine.SearchParams{MaxDepth: 6}).BestMove + if got := rookBest.String(); got != "d1d8" { + t.Errorf("free material: best move = %s, want d1d8", got) + } +} + const ( mateThreshold = 1_000_000 - 64 maxDepthUnbounded = 64 diff --git a/internal/uci/uci_test.go b/internal/uci/uci_test.go index b0567cb..1f50cbd 100644 --- a/internal/uci/uci_test.go +++ b/internal/uci/uci_test.go @@ -49,3 +49,79 @@ func TestUnknownCommandIsIgnored(t *testing.T) { t.Errorf("unknown command should be skipped, got:\n%s", out) } } + +func TestSetOptionHashAndThreads(t *testing.T) { + // Out-of-range values are clamped, not rejected; a following search must + // still produce a move. + out := run(t, strings.Join([]string{ + "setoption name Hash value 999999", + "setoption name Threads value 3", + "setoption name Hash value 0", + "setoption name Unknerd value x", + "setoption name Threads value notanumber", + "position startpos", + "go depth 3", + "quit", + "", + }, "\n")) + if !strings.Contains(out, "bestmove ") { + t.Fatalf("no bestmove after setoption:\n%s", out) + } +} + +func TestUciNewGameResetsBoard(t *testing.T) { + out := run(t, strings.Join([]string{ + "isready", + "position startpos moves e2e4 e7e5", + "ucinewgame", + "d", + "quit", + "", + }, "\n")) + if !strings.Contains(out, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") { + t.Errorf("ucinewgame did not restore the start position:\n%s", out) + } +} + +func TestGoWithClockBudgetAndMoveTime(t *testing.T) { + // wtime/btime path: the engine spends a fraction of the clock and still + // returns promptly. + out := run(t, "position startpos\ngo wtime 3000 btime 3000\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Errorf("clock-budget go produced no move:\n%s", out) + } + out = run(t, "position startpos\ngo movetime 50\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Errorf("movetime go produced no move:\n%s", out) + } +} + +func TestPositionMalformedInputsAreSafe(t *testing.T) { + for _, cmd := range []string{ + "position", + "position fen only three fields here", + "position fen 6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1 moves notamove", + "position wat", + "position startpos moves", + } { + out := run(t, cmd+"\nisready\nquit\n") + if !strings.Contains(out, "readyok") { + t.Errorf("%q wedged the loop:\n%s", cmd, out) + } + } +} + +func TestBlackToMoveGetsMatedScore(t *testing.T) { + // Black is to move and being mated: the info line reports a negative mate. + out := run(t, "position fen R5k1/5ppp/8/8/8/8/8/6K1 b - - 0 1\ngo depth 3\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Fatalf("no bestmove:\n%s", out) + } +} + +func TestStopAndUnknownGoArgsAreHarmless(t *testing.T) { + out := run(t, "position startpos\ngo depth 2 winc 100 movestogo 40\nstop\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Errorf("expected a bestmove:\n%s", out) + } +}