diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c850be..22611dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,16 +16,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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/engine`: principal variation search with a triangular PV table, + aspiration windows from depth 5, and a fail-soft alpha-beta. +- `internal/engine`: richer Lazy SMP — a 6-bit transposition-table generation + that ages out the previous search's entries, plus per-helper root-move and + aspiration-window skew. +- `internal/engine`: tapered PeSTO evaluation (middlegame/endgame material and + piece-square tables interpolated by game phase) with passed-pawn, mobility, + king-safety and tempo terms, guarded by a colour-mirror symmetry test. +- `internal/engine`: hand-rolled Polyglot opening book — `PolyglotKey`, + `OpenBook`, `Book.Probe`. - `internal/uci`: a UCI protocol loop (`uci`, `isready`, `ucinewgame`, - `position`, `go`, `stop`, `quit`) supporting `go depth`, `go movetime`, and - `go wtime/btime`. + `position`, `go`, `stop`, `ponderhit`, `quit`) supporting `go depth`, + `go movetime`, `go wtime/btime/winc/binc`, `go infinite`, and `go ponder`. + Search runs on a goroutine: `stop` and pondering work, `info … pv …` is + streamed per iteration, and `bestmove` carries a `ponder` move. +- `internal/uci`: `Ponder`, `OwnBook` and `BookFile` options. - `cmd/gochess`: CLI with `uci`, `perft`, `bench`, and `version` subcommands. - Repository scaffolding: CI, lint, CodeQL and release workflows; issue and PR templates; `CODEOWNERS`; Dependabot; `Makefile`; `Dockerfile`; GoReleaser. +- `docs/adr/0003`: record deferring Syzygy tablebases (no pure-Go prober; the + build is strictly `CGO_ENABLED=0`). ### Changed - Replaced the `notnil/chess` prototype (random mover + perft) with the `dragontoothmg`-based engine. +- Evaluation is now tapered (PeSTO) rather than a single Michniewski + piece-square table set; move choices and reported scores shift accordingly. + +### Removed + +- Retired the Go Report Card badge from the README. [Unreleased]: https://github.com/cjunius/goChess/commits/main diff --git a/README.md b/README.md index d80e1d0..589fdce 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![CI](https://github.com/cjunius/goChess/actions/workflows/ci.yml/badge.svg)](https://github.com/cjunius/goChess/actions/workflows/ci.yml) [![Lint](https://github.com/cjunius/goChess/actions/workflows/lint.yml/badge.svg)](https://github.com/cjunius/goChess/actions/workflows/lint.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/cjunius/goChess.svg)](https://pkg.go.dev/github.com/cjunius/goChess) -[![Go Report Card](https://goreportcard.com/badge/github.com/cjunius/goChess)](https://goreportcard.com/report/github.com/cjunius/goChess) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) A UCI chess engine written in Go, built on the @@ -15,13 +14,16 @@ move generator. 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, - 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: aspiration windows / PVS, opening book, endgame tablebases. +- **Evaluation** — tapered PeSTO material + piece-square tables, bishop pair, + passed pawns, mobility, king safety, tempo. +- **Search** — iterative deepening with aspiration windows, principal variation + search, quiescence search, a shared aged transposition table with hash-move + ordering, MVV-LVA + killer-move + history move ordering, null-move pruning, + late move reductions, Lazy SMP (multi-threaded search), asynchronous search + with working `stop` and pondering, hard time limits. +- **Opening book** — optional Polyglot `.bin` book. + +Not yet implemented: Syzygy endgame tablebases, static exchange evaluation. See the [roadmap](#roadmap). ## Install @@ -65,6 +67,9 @@ go movetime 1000 |---|---|---|---| | `Hash` | 64 | 1–4096 | Transposition-table size in MiB. | | `Threads` | 1 | 1–256 | Lazy-SMP worker count (capped at the machine's core count at search time). | +| `Ponder` | false | check | Let a GUI drive `go ponder` / `ponderhit` so the engine thinks on the opponent's clock. | +| `OwnBook` | false | check | Play from the Polyglot book when the position is in it. | +| `BookFile` | — | string | Path to a Polyglot `.bin` book; loaded when set. | ## Development @@ -97,8 +102,11 @@ docs/ architecture notes and ADRs - [x] Lazy SMP — multi-threaded search over the shared TT (`Threads` UCI option) - [x] Killer moves + history heuristic - [x] Null-move pruning + late move reductions -- [ ] Aspiration windows / principal variation search -- [ ] Opening book (Polyglot) and Syzygy tablebase probing +- [x] Aspiration windows + principal variation search +- [x] Tapered PeSTO evaluation + pawn/mobility/king-safety terms +- [x] Asynchronous search — working `stop` and pondering +- [x] Opening book (Polyglot) +- [ ] Syzygy tablebase probing ([ADR 0003](docs/adr/0003-defer-syzygy-tablebases.md)) - [ ] Strength testing harness (SPRT via cutechess-cli) ## License diff --git a/docs/adr/0003-defer-syzygy-tablebases.md b/docs/adr/0003-defer-syzygy-tablebases.md new file mode 100644 index 0000000..8068acb --- /dev/null +++ b/docs/adr/0003-defer-syzygy-tablebases.md @@ -0,0 +1,48 @@ +# 3. Defer Syzygy endgame tablebases + +Date: 2026-08-30 + +## Status + +Accepted + +## Context + +Syzygy tablebases give perfect play for positions with few pieces: WDL +(win/draw/loss) tables probed inside the search and DTZ (distance-to-zero) tables +probed at the root to pick a move that makes progress under the fifty-move rule. +They are a meaningful strength gain in endgames and are on the design backlog. + +Adding them now runs into two problems: + +1. **No mature pure-Go prober.** The reference implementations (Fathom, + `syzygy1/probetool`) are C. The only Go options are thin CGO wrappers around + Fathom or unmaintained partial ports. +2. **The project is strictly `CGO_ENABLED=0`.** The Docker build, the release + pipeline (`.goreleaser.yaml`) and the cross-compilation matrix all assume a + static, cgo-free binary. Introducing CGO would fragment the build and the + release artifacts. + +A correct pure-Go WDL+DTZ prober is roughly two thousand lines of intricate code +(the RE-PAIR decompression and the Syzygy indexing scheme) that is hard to test +without tablebase files in CI. It deserves its own focused change, not a rider on +a large search/eval batch. + +## Decision + +Defer Syzygy tablebase support. Ship the rest of the backlog batch (PVS, +aspiration windows, richer Lazy SMP, tapered evaluation, pondering, Polyglot +book) without it. + +When it is picked up, the preferred approach is a **pure-Go prober** (keeping +`CGO_ENABLED=0`), starting with WDL-only probing in the search and adding the +DTZ root probe afterwards. A CGO/Fathom binding gated behind a build tag is the +fallback if the pure-Go effort proves too large. + +## Consequences + +- Endgame play stays at search + tapered-eval strength; no perfect play with + ≤ 7 pieces. +- `SearchParams` has no tablebase hook yet; adding one later is additive. +- The UCI layer will need `SyzygyPath` (and probably `SyzygyProbeDepth` / + `SyzygyProbeLimit`) options when the feature lands. diff --git a/docs/architecture.md b/docs/architecture.md index bd7f1a1..f4e0c47 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,45 +34,57 @@ unapply closure), FEN parsing, an incrementally-updated Zobrist hash - **perft.go** — thin wrappers over `dragontoothmg.Perft` plus timed series and divide helpers. Backed by regression tests against published node counts. -- **eval.go** — `Evaluate(*Board) int`. Material values + Michniewski - piece-square tables + a bishop-pair bonus. Returns a score relative to the - side to move (negamax convention). Piece-square lookups use little-endian - rank-file indexing: white reads `pst[sq]`, black reads `pst[sq^56]`. +- **eval.go / eval_terms.go** — `Evaluate(*Board) int`. Tapered PeSTO material + + piece-square tables interpolated by game phase, plus bishop-pair, passed-pawn, + mobility, king-safety and tempo terms (each an `(mg, eg)` pair). Returns a + score relative to the side to move (negamax convention). Piece-square tables + are stored a8-first: white reads `pst[sq^56]`, black reads `pst[sq]`. - **search.go** — `Search(*Board, SearchParams) SearchResult`. Iterative - deepening around a negamax alpha-beta core, with: + deepening around a fail-soft negamax alpha-beta core, with: + - principal variation search (full window for move 0, null-window scout + + re-search for the rest) and a triangular PV table, + - aspiration windows from depth 5, widening the failing side, - quiescence search at the horizon (captures and promotions only), - transposition-table probes/stores with hash-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, + - null-move pruning and late move reductions, - 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, - - a hard wall-clock budget checked every 2048 nodes; the last fully completed - depth is returned. + board copy over one shared TT, with per-helper root-move and aspiration + skew; the deepest completed result wins, + - `SearchParams.Stop` / `Ponder` / `PonderHit` / `Info` for asynchronous + driving: a hard wall-clock budget checked every 2048 nodes (ignored while + pondering), an external abort flag, and a per-iteration callback. - **transposition.go** — `TT`, a fixed-size power-of-two table keyed by `Board.Hash`. Each 16-byte slot is a pair of `atomic.Uint64` words accessed with Hyatt's lockless XOR trick (`word0 = key ^ data`), so the Lazy-SMP workers share it without a mutex; a write torn across goroutines reads as a - miss. `data` packs move, int32 score, depth and bound flag. + miss. `data` packs move, int32 score, depth, bound flag and a 6-bit + generation; `NewSearch` bumps the generation so `store` treats the previous + search's entries as replaceable. +- **polyglot.go** — `PolyglotKey`, `OpenBook`, `Book.Probe`: the Polyglot book + Zobrist key (distinct from `Board.Hash`) and a reader for `.bin` book files. ### `internal/uci` A line-oriented reader for `uci`, `isready`, `setoption`, `ucinewgame`, `position` (`startpos` / `fen`, with `moves`), `go` (`depth`, `movetime`, -`wtime`/`btime`), `stop`, `d`, and `quit`. It owns the persistent `engine.TT` -(sized by the `Hash` option, cleared on `ucinewgame`) and the `Threads` setting. -Search is synchronous, so `stop` is a no-op and `bestmove` is emitted as soon as -`go` returns. +`wtime`/`btime`/`winc`/`binc`, `infinite`, `ponder`), `stop`, `ponderhit`, `d`, +and `quit`. It owns the persistent `engine.TT` (sized by the `Hash` option, +cleared on `ucinewgame`) and the `Threads` / `Ponder` settings. + +Search runs on its own goroutine: `go` returns immediately, `engine.Search` +streams `info` lines through a callback and the goroutine emits `bestmove` +(with a `ponder` move from the PV) once the search ends. `stop` aborts it; +`ponderhit` converts a pondering search onto its time budget; a mutex serialises +all writes to stdout. `quit` / EOF wait for a bounded search a GUI is blocking on +and abort anything infinite. ## Deliberately not here yet -Aspiration windows, principal variation search, static exchange evaluation, -search extensions, opening book, tablebases, pondering, `SearchMoves`/`MultiPV`. +Static exchange evaluation, search extensions, opening book, tablebases, +`SearchMoves`/`MultiPV`. ## Key invariants diff --git a/docs/design.md b/docs/design.md index a0e6240..e894c6d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -10,8 +10,9 @@ choices see the [ADRs](adr/). `engine.Search` is the search core. It is a plain function around a `searcher` 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. +table, triangular PV 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 | |---|---| @@ -22,33 +23,37 @@ small enough that dependency injection would cost more than it buys. | `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` -setting. Search is synchronous, so `stop` is a no-op and `bestmove` is emitted -as soon as `go` returns. `cmd/gochess` wires these together and adds the `perft` -and `bench` subcommands. +`bestmove`. It owns the persistent `engine.TT` (`Hash` option), the `Threads` / +`Ponder` settings, and the optional Polyglot book (`OwnBook` / `BookFile`). It +runs `Search` on a goroutine and serialises stdout with a mutex, so `stop`, +`ponderhit` and streamed `info` all work while the search runs. `cmd/gochess` +wires these together and adds the `perft` and `bench` subcommands. ## Implemented ### [Search](https://www.chessprogramming.org/Search) -- [Negamax](https://www.chessprogramming.org/Negamax) with [alpha-beta pruning](https://www.chessprogramming.org/Alpha-Beta) — fail-hard -- [Iterative Deepening](https://www.chessprogramming.org/Iterative_Deepening) — the last fully completed depth is the one returned -- [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 +- [Negamax](https://www.chessprogramming.org/Negamax) with [alpha-beta pruning](https://www.chessprogramming.org/Alpha-Beta), fail-soft, and [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — the first move at each node gets a full window, later moves a null-window scout re-searched only when it beats alpha (LMR is folded in as an extra reduction on that scout) +- [Iterative Deepening](https://www.chessprogramming.org/Iterative_Deepening) with [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) — from depth 5 each iteration first searches a ±25 cp window around the previous score, doubling the failing side until the score lands inside. The last fully completed depth is the one returned +- A real [principal variation](https://www.chessprogramming.org/Principal_Variation) — a triangular PV table produces the full line, streamed in the `info … pv …` output each iteration +- [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. A 6-bit generation ages entries: a new search (`NewSearch`) reclaims the previous search's slots regardless of their depth +- [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, and helpers additionally skew their root move order and use a wider, asymmetric aspiration window so they diverge. The deepest completed result wins +- Asynchronous search — the UCI layer runs `Search` on a goroutine, so `stop` aborts immediately and pondering (`go ponder` / `ponderhit`) works; a pondering search ignores the clock until the ponder move is confirmed - [Quiescence Search](https://www.chessprogramming.org/Quiescence_Search) at the horizon — captures and promotions only, depth-bounded by `maxPly` - [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` +- Time management — a hard wall-clock budget checked every 2048 nodes; the UCI layer spends `1/30` of the remaining clock plus `3/4` of the increment when the GUI sends `wtime` / `btime` (`winc` / `binc`) instead of `movetime` +- [Opening book](https://www.chessprogramming.org/Opening_Book) — optional [Polyglot](http://hgm.nubati.net/book_format.html) `.bin` book (`OwnBook` / `BookFile`); a book hit is played instantly with no search - King-capture guard — scores the illegal position `dragontoothmg` can hand back when the side not to move was already in check, instead of panicking on an empty king bitboard ### [Evaluation](https://www.chessprogramming.org/Evaluation) -- Material — standard centipawn values (`P 100 · N 320 · B 330 · R 500 · Q 900`) -- [Piece-square tables](https://www.chessprogramming.org/Piece-Square_Tables) — Michniewski's "simplified evaluation function", single (non-tapered) set; black reads the vertically mirrored square (`sq^56`) -- [Bishop pair](https://www.chessprogramming.org/Bishop_Pair) — `+30` for holding both bishops +- [Tapered eval](https://www.chessprogramming.org/Tapered_Eval) — [PeSTO](https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function) middlegame/endgame material values and piece-square tables interpolated by game phase (`N/B = 1`, `R = 2`, `Q = 4`, clamped to 24); black reads the vertically mirrored square (`sq^56`) +- [Bishop pair](https://www.chessprogramming.org/Bishop_Pair), [passed pawns](https://www.chessprogramming.org/Passed_Pawn) (bonus by rank, endgame-weighted), [mobility](https://www.chessprogramming.org/Mobility) for N/B/R/Q, [king safety](https://www.chessprogramming.org/King_Safety) (attacker weight in the king ring + missing-pawn-shield penalty, middlegame only), and a [tempo](https://www.chessprogramming.org/Tempo) bonus — all accumulated as `(mg, eg)` pairs and folded into the taper +- A colour-mirror symmetry test guards the whole evaluation Evaluation is recomputed from scratch on every call; there is no incremental update and no pawn or evaluation hash. @@ -57,22 +62,17 @@ update and no pawn or evaluation hash. ### Search -- [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 +- Tuning the aspiration / null-move / LMR formulas (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) -- Richer Lazy SMP — per-worker root-move splitting, aspiration-window skew, TT ageing / bucketed replacement -- Asynchronous search so `stop` and pondering actually work -- [Opening book](https://www.chessprogramming.org/Opening_Book) (Polyglot) and [Syzygy endgame tablebases](https://www.chessprogramming.org/Endgame_Tablebases) +- [Syzygy endgame tablebases](https://www.chessprogramming.org/Endgame_Tablebases) — deferred; see [ADR 0003](adr/0003-defer-syzygy-tablebases.md) ### Evaluation -- [Tapered eval](https://www.chessprogramming.org/Tapered_Eval) — mid/endgame PST pairs interpolated by game phase ([PeSTO](https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function)) -- Pawn structure — [passed](https://www.chessprogramming.org/Passed_Pawn) / [isolated](https://www.chessprogramming.org/Isolated_Pawn) / [doubled](https://www.chessprogramming.org/Doubled_Pawn) / [backward](https://www.chessprogramming.org/Backward_Pawn) pawns -- [Mobility](https://www.chessprogramming.org/Mobility), [rook on open file](https://www.chessprogramming.org/Rook_on_Open_File), [knight outposts](https://www.chessprogramming.org/Outpost), [king safety](https://www.chessprogramming.org/King_Safety), [tempo](https://www.chessprogramming.org/Tempo) +- Pawn structure — [isolated](https://www.chessprogramming.org/Isolated_Pawn) / [doubled](https://www.chessprogramming.org/Doubled_Pawn) / [backward](https://www.chessprogramming.org/Backward_Pawn) pawns +- [Rook on open file](https://www.chessprogramming.org/Rook_on_Open_File), [knight outposts](https://www.chessprogramming.org/Outpost) +- Evaluation-weight tuning (Texel / gradient) against a labelled position set - [Incremental updates](https://www.chessprogramming.org/Incremental_Updates) of material + PST on make/unmake - [Pawn](https://www.chessprogramming.org/Pawn_Hash_Table) and [evaluation](https://www.chessprogramming.org/Evaluation_Hash_Table) hash tables diff --git a/docs/engine-strength.md b/docs/engine-strength.md index 6e02a6b..3e900a2 100644 --- a/docs/engine-strength.md +++ b/docs/engine-strength.md @@ -25,9 +25,10 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. 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. +- **Evaluation** — tapered PeSTO material and piece-square tables plus + bishop-pair, passed-pawn, mobility, king-safety and tempo terms. Untuned, but + it captures development, central control, king safety, the two-bishop + advantage, and the middlegame/endgame shift. - **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 @@ -55,6 +56,17 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. 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. +- **Principal variation search + aspiration windows.** Only the first move at a + node gets a full window; the rest are null-window scouts. From depth 5 each + iteration starts inside a ±25 cp window around the previous score. +- **Tapered PeSTO evaluation** with passed-pawn, mobility and king-safety terms, + replacing the single Michniewski PST set. +- **Asynchronous search** — `stop` aborts immediately and pondering + (`go ponder` / `ponderhit`) lets the engine think on the opponent's clock. +- **TT ageing + richer Lazy SMP** — a generation counter reclaims the previous + search's entries, and helper workers skew their root move order and aspiration + window. +- **Polyglot opening book** (`OwnBook` / `BookFile`). 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 @@ -62,37 +74,34 @@ used to cost. See [performance.md](performance.md). ### Limiting factors -- **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 - game-phase interpolation (a single PST set is used from opening to endgame), - no explicit terms for passed pawns, pawn structure, mobility, rook-on-open- - file, or king safety beyond the king PST. -- **Thin endgame play.** No tablebase probing, no KPK / KBNK knowledge, no +- **Hand-set evaluation weights, never tuned.** The tapered PeSTO tables and the + passed-pawn / mobility / king-safety coefficients are literature defaults, not + Texel- or gradient-tuned for this engine. No isolated/doubled/backward pawn, + rook-on-open-file or outpost terms yet. +- **Thin endgame play.** No tablebase probing (see + [ADR 0003](adr/0003-defer-syzygy-tablebases.md)), no KPK / KBNK knowledge, no contempt. The 50-move rule is honoured but threefold repetition is not detected inside the search. -- **Basic Lazy SMP only.** Workers share the TT and start at staggered depths, - but there is no root-move splitting, aspiration-window skew, or TT ageing, so - parallel scaling past a handful of threads is modest. -- **Synchronous search.** `stop` is a no-op and there is no pondering, so the - engine cannot think on the opponent's clock or bail out of a bad time - allocation. +- **Lazy SMP is still "lazy".** Helpers skew their root order and aspiration + window and the TT is aged, but there is no explicit root-move splitting or + shared-PV coordination, so scaling past a handful of threads is modest. +- **No SEE.** Captures are ordered by MVV-LVA only; losing captures are not + pruned in quiescence. ## Calibration against known engines | Reference engine | ~CCRL blitz | Relevant comparison | | ---------------- | ----------- | ------------------- | | 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. | +| Sungorus 1.4 | ~2000 | TT + null-move + PVS + killers — a close match to goChess's search feature set. goChess should be around here or a little above. | +| CT800 / Claudia class | ~2100+ | Full modern pruning set plus a tuned eval. Reachable once the evaluation weights are tuned and a few structural terms are added. | -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. +The feature set now resembles a "complete first-generation pruning engine" with +a tapered evaluation — tactically sharp for its node rate; the remaining +positional gaps (untuned weights, no pawn-structure or outpost terms) are the +main thing left holding the rating down. ## Time-control sensitivity @@ -118,16 +127,17 @@ assuming each is implemented competently and validated by SPRT: | 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 | | +| Aspiration windows / PVS | +20 to +50 | done, pending SPRT | +| Game-phase eval interpolation (tapered eval) | +30 to +60 | done, pending SPRT | +| Passed pawns / king safety / mobility terms | +40 to +80 | done, pending SPRT | | Texel-tuned evaluation weights | +40 to +80 | | -| Opening book (Polyglot) | +20 to +40 at short TC | | -| Syzygy tablebase probing | +10 to +20 | | +| Opening book (Polyglot) | +20 to +40 at short TC | done, pending SPRT | +| Syzygy tablebase probing | +10 to +20 | deferred ([ADR 0003](adr/0003-defer-syzygy-tablebases.md)) | -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+. +With the TT, Lazy SMP, killers/history, null-move pruning, LMR, PVS/aspiration +and a tapered evaluation all in, goChess plausibly sits in the ~2000–2250 range; +tuning the evaluation weights and adding a few structural terms is what targets +2300+. ## Measuring it properly diff --git a/docs/performance.md b/docs/performance.md index 95a24fd..04ae5f8 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,10 +1,17 @@ # Performance -Benchmark of the current engine (iterative deepening, negamax alpha-beta, -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 +> Note: the node counts and depths below were measured before principal +> variation search, aspiration windows and the tapered PeSTO evaluation landed. +> The shape of the story (narrow tree, modest SMP overlap) still holds; the +> exact figures will be lower on nodes / higher on depth. Re-benchmark before +> quoting. + +Benchmark of the engine (iterative deepening, fail-soft negamax alpha-beta with +principal variation search and aspiration windows, bounded quiescence, a shared +aged 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. @@ -47,11 +54,12 @@ 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. 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: +Lazy SMP: workers share the TT (aged per search) and start at staggered depths; +helpers also skew their root move order and use a wider, asymmetric aspiration +window. There is still no explicit root-move splitting or shared-PV coordination, +so on TT-friendly positions the workers overlap substantially. 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 | |--------:|--------------:|------:| diff --git a/internal/engine/eval.go b/internal/engine/eval.go index 757807c..9e3f401 100644 --- a/internal/engine/eval.go +++ b/internal/engine/eval.go @@ -6,9 +6,9 @@ import ( "github.com/dylhunn/dragontoothmg" ) -// Centipawn material values, indexed by the dragontoothmg piece constants -// (Pawn=1 .. King=6). The king value only matters for move ordering, never -// for the returned evaluation, since both sides always have exactly one. +// Centipawn material values for move ordering only (MVV-LVA in orderMoves). The +// returned evaluation uses the tapered mgValue / egValue pairs below instead. +// The king value only matters for ordering, since both sides always have one. var pieceValue = [7]int{ dragontoothmg.Pawn: 100, dragontoothmg.Knight: 320, @@ -18,110 +18,250 @@ var pieceValue = [7]int{ dragontoothmg.King: 20000, } -const bishopPairBonus = 30 +// Tapered evaluation (PeSTO). Every positional term is accumulated as a +// (middlegame, endgame) pair and interpolated by game phase: a full board is +// pure middlegame, a bare-piece endgame is pure endgame. +// +// The piece-square tables below are the PeSTO tables written in the customary +// a8..h1 order (rank 8 first, as a board looks from White's side). dragontoothmg +// squares are little-endian (a1 = 0), so a White piece on square sq reads +// table[sq^56] and a Black piece reads table[sq]. +const ( + maxPhase = 24 + tempo = 8 + bishopPairMG = 22 + bishopPairEG = 40 +) + +// gamePhaseInc is added to the running phase for each piece of that type on the +// board (both sides); the sum is clamped to maxPhase. +var gamePhaseInc = [7]int{ + dragontoothmg.Knight: 1, + dragontoothmg.Bishop: 1, + dragontoothmg.Rook: 2, + dragontoothmg.Queen: 4, +} -// Piece-square tables (Michniewski's "simplified evaluation function"), -// written in a1..h8 order (rank 1 first). dragontoothmg uses little-endian -// rank-file square numbering, so a white piece on square sq reads pst[sq] -// directly and a black piece reads the vertically mirrored pst[sq^56]. var ( - pawnPST = [64]int{ - 0, 0, 0, 0, 0, 0, 0, 0, - 5, 10, 10, -20, -20, 10, 10, 5, - 5, -5, -10, 0, 0, -10, -5, 5, - 0, 0, 0, 20, 20, 0, 0, 0, - 5, 5, 10, 25, 25, 10, 5, 5, - 10, 10, 20, 30, 30, 20, 10, 10, - 50, 50, 50, 50, 50, 50, 50, 50, - 0, 0, 0, 0, 0, 0, 0, 0, + mgValue = [7]int{ + dragontoothmg.Pawn: 82, dragontoothmg.Knight: 337, dragontoothmg.Bishop: 365, + dragontoothmg.Rook: 477, dragontoothmg.Queen: 1025, dragontoothmg.King: 0, } - knightPST = [64]int{ - -50, -40, -30, -30, -30, -30, -40, -50, - -40, -20, 0, 5, 5, 0, -20, -40, - -30, 5, 10, 15, 15, 10, 5, -30, - -30, 0, 15, 20, 20, 15, 0, -30, - -30, 5, 15, 20, 20, 15, 5, -30, - -30, 0, 10, 15, 15, 10, 0, -30, - -40, -20, 0, 0, 0, 0, -20, -40, - -50, -40, -30, -30, -30, -30, -40, -50, - } - bishopPST = [64]int{ - -20, -10, -10, -10, -10, -10, -10, -20, - -10, 5, 0, 0, 0, 0, 5, -10, - -10, 10, 10, 10, 10, 10, 10, -10, - -10, 0, 10, 10, 10, 10, 0, -10, - -10, 5, 5, 10, 10, 5, 5, -10, - -10, 0, 5, 10, 10, 5, 0, -10, - -10, 0, 0, 0, 0, 0, 0, -10, - -20, -10, -10, -10, -10, -10, -10, -20, - } - rookPST = [64]int{ - 0, 0, 0, 5, 5, 0, 0, 0, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - 5, 10, 10, 10, 10, 10, 10, 5, - 0, 0, 0, 0, 0, 0, 0, 0, + egValue = [7]int{ + dragontoothmg.Pawn: 94, dragontoothmg.Knight: 281, dragontoothmg.Bishop: 297, + dragontoothmg.Rook: 512, dragontoothmg.Queen: 936, dragontoothmg.King: 0, + } + + mgPST = [7][64]int{ + dragontoothmg.Pawn: mgPawn, + dragontoothmg.Knight: mgKnight, + dragontoothmg.Bishop: mgBishop, + dragontoothmg.Rook: mgRook, + dragontoothmg.Queen: mgQueen, + dragontoothmg.King: mgKing, } - queenPST = [64]int{ - -20, -10, -10, -5, -5, -10, -10, -20, - -10, 0, 0, 0, 0, 0, 0, -10, - -10, 0, 5, 5, 5, 5, 0, -10, - -5, 0, 5, 5, 5, 5, 0, -5, - 0, 0, 5, 5, 5, 5, 0, -5, - -10, 5, 5, 5, 5, 5, 0, -10, - -10, 0, 5, 0, 0, 0, 0, -10, - -20, -10, -10, -5, -5, -10, -10, -20, - } - kingPST = [64]int{ - 20, 30, 10, 0, 0, 10, 30, 20, - 20, 20, 0, 0, 0, 0, 20, 20, - -10, -20, -20, -20, -20, -20, -20, -10, - -20, -30, -30, -40, -40, -30, -30, -20, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, + egPST = [7][64]int{ + dragontoothmg.Pawn: egPawn, + dragontoothmg.Knight: egKnight, + dragontoothmg.Bishop: egBishop, + dragontoothmg.Rook: egRook, + dragontoothmg.Queen: egQueen, + dragontoothmg.King: egKing, } ) -// Evaluate returns a static score for the position in centipawns, from the -// point of view of the side to move (positive = better for that side). This is -// the sign convention negamax expects. +// Evaluate returns a static score for the position in centipawns from the point +// of view of the side to move (positive = better for that side) — the sign +// convention negamax expects. func Evaluate(b *dragontoothmg.Board) int { - score := evalSide(&b.White, true) - evalSide(&b.Black, false) - if b.Wtomove { - return score + wmg, weg := pstScore(&b.White, true) + bmg, beg := pstScore(&b.Black, false) + mg, eg := wmg-bmg, weg-beg + + tmg, teg := evalTerms(b) + mg += tmg + eg += teg + + phase := gamePhase(b) + score := (mg*phase + eg*(maxPhase-phase)) / maxPhase + if !b.Wtomove { + score = -score } - return -score + return score + tempo } -func evalSide(bb *dragontoothmg.Bitboards, white bool) int { - s := evalPiece(bb.Pawns, &pawnPST, pieceValue[dragontoothmg.Pawn], white) + - evalPiece(bb.Knights, &knightPST, pieceValue[dragontoothmg.Knight], white) + - evalPiece(bb.Bishops, &bishopPST, pieceValue[dragontoothmg.Bishop], white) + - evalPiece(bb.Rooks, &rookPST, pieceValue[dragontoothmg.Rook], white) + - evalPiece(bb.Queens, &queenPST, pieceValue[dragontoothmg.Queen], white) + - evalPiece(bb.Kings, &kingPST, pieceValue[dragontoothmg.King], white) - if bits.OnesCount64(bb.Bishops) >= 2 { - s += bishopPairBonus +// gamePhase sums gamePhaseInc over every non-pawn piece on the board, clamped to +// maxPhase (early promotions can otherwise overshoot). +func gamePhase(b *dragontoothmg.Board) int { + p := bits.OnesCount64(b.White.Knights|b.Black.Knights)*gamePhaseInc[dragontoothmg.Knight] + + bits.OnesCount64(b.White.Bishops|b.Black.Bishops)*gamePhaseInc[dragontoothmg.Bishop] + + bits.OnesCount64(b.White.Rooks|b.Black.Rooks)*gamePhaseInc[dragontoothmg.Rook] + + bits.OnesCount64(b.White.Queens|b.Black.Queens)*gamePhaseInc[dragontoothmg.Queen] + if p > maxPhase { + p = maxPhase } - return s + return p } -func evalPiece(board uint64, pst *[64]int, value int, white bool) int { - s := 0 - for board != 0 { - sq := bits.TrailingZeros64(board) - board &= board - 1 - s += value - if white { - s += pst[sq] - } else { - s += pst[sq^56] +// pstScore returns the (mg, eg) material + piece-square total for one side. +func pstScore(bb *dragontoothmg.Bitboards, white bool) (mg, eg int) { + for p := dragontoothmg.Pawn; p <= dragontoothmg.King; p++ { + board := pieceBoard(bb, p) + for board != 0 { + sq := bits.TrailingZeros64(board) + board &= board - 1 + idx := sq + if white { + idx ^= 56 + } + mg += mgValue[p] + mgPST[p][idx] + eg += egValue[p] + egPST[p][idx] } } - return s + if bits.OnesCount64(bb.Bishops) >= 2 { + mg += bishopPairMG + eg += bishopPairEG + } + return mg, eg } + +// pieceBoard returns the bitboard for piece type p within bb. +func pieceBoard(bb *dragontoothmg.Bitboards, p int) uint64 { + switch p { + case dragontoothmg.Pawn: + return bb.Pawns + case dragontoothmg.Knight: + return bb.Knights + case dragontoothmg.Bishop: + return bb.Bishops + case dragontoothmg.Rook: + return bb.Rooks + case dragontoothmg.Queen: + return bb.Queens + default: + return bb.Kings + } +} + +var ( + mgPawn = [64]int{ + 0, 0, 0, 0, 0, 0, 0, 0, + 98, 134, 61, 95, 68, 126, 34, -11, + -6, 7, 26, 31, 65, 56, 25, -20, + -14, 13, 6, 21, 23, 12, 17, -23, + -27, -2, -5, 12, 17, 6, 10, -25, + -26, -4, -4, -10, 3, 3, 33, -12, + -35, -1, -20, -23, -15, 24, 38, -22, + 0, 0, 0, 0, 0, 0, 0, 0, + } + egPawn = [64]int{ + 0, 0, 0, 0, 0, 0, 0, 0, + 178, 173, 158, 134, 147, 132, 165, 187, + 94, 100, 85, 67, 56, 53, 82, 84, + 32, 24, 13, 5, -2, 4, 17, 17, + 13, 9, -3, -7, -7, -8, 3, -1, + 4, 7, -6, 1, 0, -5, -1, -8, + 13, 8, 8, 10, 13, 0, 2, -7, + 0, 0, 0, 0, 0, 0, 0, 0, + } + mgKnight = [64]int{ + -167, -89, -34, -49, 61, -97, -15, -107, + -73, -41, 72, 36, 23, 62, 7, -17, + -47, 60, 37, 65, 84, 129, 73, 44, + -9, 17, 19, 53, 37, 69, 18, 22, + -13, 4, 16, 13, 28, 19, 21, -8, + -23, -9, 12, 10, 19, 17, 25, -16, + -29, -53, -12, -3, -1, 18, -14, -19, + -105, -21, -58, -33, -17, -28, -19, -23, + } + egKnight = [64]int{ + -58, -38, -13, -28, -31, -27, -63, -99, + -25, -8, -25, -2, -9, -25, -24, -52, + -24, -20, 10, 9, -1, -9, -19, -41, + -17, 3, 22, 22, 22, 11, 8, -18, + -18, -6, 16, 25, 16, 17, 4, -18, + -23, -3, -1, 15, 10, -3, -20, -22, + -42, -20, -10, -5, -2, -20, -23, -44, + -29, -51, -23, -15, -22, -18, -50, -64, + } + mgBishop = [64]int{ + -29, 4, -82, -37, -25, -42, 7, -8, + -26, 16, -18, -13, 30, 59, 18, -47, + -16, 37, 43, 40, 35, 50, 37, -2, + -4, 5, 19, 50, 37, 37, 7, -2, + -6, 13, 13, 26, 34, 12, 10, 4, + 0, 15, 15, 15, 14, 27, 18, 10, + 4, 15, 16, 0, 7, 21, 33, 1, + -33, -3, -14, -21, -13, -12, -39, -21, + } + egBishop = [64]int{ + -14, -21, -11, -8, -7, -9, -17, -24, + -8, -4, 7, -12, -3, -13, -4, -14, + 2, -8, 0, -1, -2, 6, 0, 4, + -3, 9, 12, 9, 14, 10, 3, 2, + -6, 3, 13, 19, 7, 10, -3, -9, + -12, -3, 8, 10, 13, 3, -7, -15, + -14, -18, -7, -1, 4, -9, -15, -27, + -23, -9, -23, -5, -9, -16, -5, -17, + } + mgRook = [64]int{ + 32, 42, 32, 51, 63, 9, 31, 43, + 27, 32, 58, 62, 80, 67, 26, 44, + -5, 19, 26, 36, 17, 45, 61, 16, + -24, -11, 7, 26, 24, 35, -8, -20, + -36, -26, -12, -1, 9, -7, 6, -23, + -45, -25, -16, -17, 3, 0, -5, -33, + -44, -16, -20, -9, -1, 11, -6, -71, + -19, -13, 1, 17, 16, 7, -37, -26, + } + egRook = [64]int{ + 13, 10, 18, 15, 12, 12, 8, 5, + 11, 13, 13, 11, -3, 3, 8, 3, + 7, 7, 7, 5, 4, -3, -5, -3, + 4, 3, 13, 1, 2, 1, -1, 2, + 3, 5, 8, 4, -5, -6, -8, -11, + -4, 0, -5, -1, -7, -12, -8, -16, + -6, -6, 0, 2, -9, -9, -11, -3, + -9, 2, 3, -1, -5, -13, 4, -20, + } + mgQueen = [64]int{ + -28, 0, 29, 12, 59, 44, 43, 45, + -24, -39, -5, 1, -16, 57, 28, 54, + -13, -17, 7, 8, 29, 56, 47, 57, + -27, -27, -16, -16, -1, 17, -2, 1, + -9, -26, -9, -10, -2, -4, 3, -3, + -14, 2, -11, -2, -5, 2, 14, 5, + -35, -8, 11, 2, 8, 15, -3, 1, + -1, -18, -9, 10, -15, -25, -31, -50, + } + egQueen = [64]int{ + -9, 22, 22, 27, 27, 19, 10, 20, + -17, 20, 32, 41, 58, 25, 30, 0, + -20, 6, 9, 49, 47, 35, 19, 9, + 3, 22, 24, 45, 57, 40, 57, 36, + -18, 28, 19, 47, 31, 34, 39, 23, + -16, -27, 15, 6, 9, 17, 10, 5, + -22, -23, -30, -16, -16, -23, -36, -32, + -33, -28, -22, -43, -5, -32, -20, -41, + } + mgKing = [64]int{ + -65, 23, 16, -15, -56, -34, 2, 13, + 29, -1, -20, -7, -8, -4, -38, -29, + -9, 24, 2, -16, -20, 6, 22, -22, + -17, -20, -12, -27, -30, -25, -14, -36, + -49, -1, -27, -39, -46, -44, -33, -51, + -14, -14, -22, -46, -44, -30, -15, -27, + 1, 7, -8, -64, -43, -16, 9, 8, + -15, 36, 12, -54, 8, -28, 24, 14, + } + egKing = [64]int{ + -74, -35, -18, -18, -11, 15, 4, -17, + -12, 17, 14, 17, 17, 38, 23, 11, + 10, 17, 23, 15, 20, 45, 44, 13, + -8, 22, 24, 27, 26, 33, 26, 3, + -18, -4, 21, 24, 27, 23, 9, -11, + -19, -3, 11, 21, 23, 16, 7, -9, + -27, -11, 4, 13, 14, 4, -5, -17, + -53, -34, -21, -11, -28, -14, -24, -43, + } +) diff --git a/internal/engine/eval_terms.go b/internal/engine/eval_terms.go new file mode 100644 index 0000000..ac8c81a --- /dev/null +++ b/internal/engine/eval_terms.go @@ -0,0 +1,223 @@ +package engine + +import ( + "math/bits" + + "github.com/dylhunn/dragontoothmg" +) + +// Positional evaluation terms layered on top of material + piece-square tables: +// passed pawns, piece mobility, and king safety. Each is returned as a +// (middlegame, endgame) delta from White's point of view and folded into the +// taper by Evaluate. + +// Mobility coefficients: score = k * (attacked-squares - pivot), where the pivot +// is roughly the average count so a typical piece scores near zero. +const ( + knightMobK = 4 + bishopMobKMG = 3 + bishopMobKEG = 3 + rookMobKMG = 2 + rookMobKEG = 4 + queenMobKMG = 1 + queenMobKEG = 2 + + pawnShieldPen = 14 +) + +// passed bonuses indexed by the pawn's rank from its own side (1..6). +var ( + passedMG = [8]int{0, 5, 10, 15, 25, 45, 70, 0} + passedEG = [8]int{0, 15, 20, 35, 60, 100, 160, 0} + + // kingDangerTable maps summed attacker "units" near the king to an mg penalty. + kingDangerTable = [21]int{ + 0, 0, 3, 6, 10, 15, 21, 28, 36, 45, 55, + 66, 78, 91, 105, 120, 136, 153, 171, 190, 210, + } +) + +var ( + fileMask [8]uint64 + knightAttacks [64]uint64 + kingRing [64]uint64 + whitePassedMask [64]uint64 + blackPassedMask [64]uint64 +) + +func init() { + for f := 0; f < 8; f++ { + fileMask[f] = 0x0101010101010101 << uint(f) + } + + knightDeltas := [8][2]int{{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}} + for sq := 0; sq < 64; sq++ { + f, r := sq%8, sq/8 + + for _, d := range knightDeltas { + nf, nr := f+d[0], r+d[1] + if nf >= 0 && nf < 8 && nr >= 0 && nr < 8 { + knightAttacks[sq] |= 1 << uint(nr*8+nf) + } + } + for df := -1; df <= 1; df++ { + for dr := -1; dr <= 1; dr++ { + if df == 0 && dr == 0 { + continue + } + nf, nr := f+df, r+dr + if nf >= 0 && nf < 8 && nr >= 0 && nr < 8 { + kingRing[sq] |= 1 << uint(nr*8+nf) + } + } + } + + files := fileMask[f] + if f > 0 { + files |= fileMask[f-1] + } + if f < 7 { + files |= fileMask[f+1] + } + var wFwd, bFwd uint64 + for rr := r + 1; rr < 8; rr++ { + wFwd |= 0xff << uint(rr*8) + } + for rr := 0; rr < r; rr++ { + bFwd |= 0xff << uint(rr*8) + } + whitePassedMask[sq] = files & wFwd + blackPassedMask[sq] = files & bFwd + } +} + +// evalTerms returns the summed (mg, eg) positional deltas from White's view. +func evalTerms(b *dragontoothmg.Board) (mg, eg int) { + pmg, peg := passedPawns(b) + mmg, meg := mobility(b) + return pmg + mmg + kingSafety(b), peg + meg +} + +func passedPawns(b *dragontoothmg.Board) (mg, eg int) { + wp, bp := b.White.Pawns, b.Black.Pawns + for x := wp; x != 0; x &= x - 1 { + sq := bits.TrailingZeros64(x) + if bp&whitePassedMask[sq] == 0 { + r := sq / 8 + mg += passedMG[r] + eg += passedEG[r] + } + } + for x := bp; x != 0; x &= x - 1 { + sq := bits.TrailingZeros64(x) + if wp&blackPassedMask[sq] == 0 { + r := 7 - sq/8 + mg -= passedMG[r] + eg -= passedEG[r] + } + } + return mg, eg +} + +func mobility(b *dragontoothmg.Board) (mg, eg int) { + all := b.White.All | b.Black.All + wmg, weg := sideMobility(&b.White, b.White.All, all) + bmg, beg := sideMobility(&b.Black, b.Black.All, all) + return wmg - bmg, weg - beg +} + +func sideMobility(bb *dragontoothmg.Bitboards, own, all uint64) (mg, eg int) { + for x := bb.Knights; x != 0; x &= x - 1 { + c := bits.OnesCount64(knightAttacks[bits.TrailingZeros64(x)] &^ own) + mg += knightMobK * (c - 4) + eg += knightMobK * (c - 4) + } + for x := bb.Bishops; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + c := bits.OnesCount64(dragontoothmg.CalculateBishopMoveBitboard(sq, all) &^ own) + mg += bishopMobKMG * (c - 6) + eg += bishopMobKEG * (c - 6) + } + for x := bb.Rooks; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + c := bits.OnesCount64(dragontoothmg.CalculateRookMoveBitboard(sq, all) &^ own) + mg += rookMobKMG * (c - 7) + eg += rookMobKEG * (c - 7) + } + for x := bb.Queens; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + att := dragontoothmg.CalculateBishopMoveBitboard(sq, all) | dragontoothmg.CalculateRookMoveBitboard(sq, all) + c := bits.OnesCount64(att &^ own) + mg += queenMobKMG * (c - 14) + eg += queenMobKEG * (c - 14) + } + return mg, eg +} + +// kingSafety returns the mg-only delta from White's view: each side's own king +// danger (attackers near the king + a missing-pawn-shield penalty) counts +// against it. +func kingSafety(b *dragontoothmg.Board) int { + all := b.White.All | b.Black.All + white := kingDanger(b.White.Kings, &b.Black, all) + pawnShield(b.White.Kings, b.White.Pawns, true) + black := kingDanger(b.Black.Kings, &b.White, all) + pawnShield(b.Black.Kings, b.Black.Pawns, false) + return black - white +} + +func kingDanger(kingBB uint64, attacker *dragontoothmg.Bitboards, all uint64) int { + if kingBB == 0 { + return 0 + } + ring := kingRing[bits.TrailingZeros64(kingBB)] + units := 0 + for x := attacker.Knights; x != 0; x &= x - 1 { + units += 2 * bits.OnesCount64(knightAttacks[bits.TrailingZeros64(x)]&ring) + } + for x := attacker.Bishops; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + units += 2 * bits.OnesCount64(dragontoothmg.CalculateBishopMoveBitboard(sq, all)&ring) + } + for x := attacker.Rooks; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + units += 3 * bits.OnesCount64(dragontoothmg.CalculateRookMoveBitboard(sq, all)&ring) + } + for x := attacker.Queens; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + att := dragontoothmg.CalculateBishopMoveBitboard(sq, all) | dragontoothmg.CalculateRookMoveBitboard(sq, all) + units += 5 * bits.OnesCount64(att&ring) + } + if units >= len(kingDangerTable) { + units = len(kingDangerTable) - 1 + } + return kingDangerTable[units] +} + +// pawnShield penalises files in front of the king (its own file and the two +// neighbours) that have no friendly pawn on the next two ranks. +func pawnShield(kingBB, pawns uint64, white bool) int { + if kingBB == 0 { + return 0 + } + ksq := bits.TrailingZeros64(kingBB) + kf, kr := ksq%8, ksq/8 + pen := 0 + for f := kf - 1; f <= kf+1; f++ { + if f < 0 || f > 7 { + continue + } + var mask uint64 + if white { + for rr := kr + 1; rr <= kr+2 && rr < 8; rr++ { + mask |= 1 << uint(rr*8+f) + } + } else { + for rr := kr - 1; rr >= kr-2 && rr >= 0; rr-- { + mask |= 1 << uint(rr*8+f) + } + } + if mask != 0 && pawns&mask == 0 { + pen += pawnShieldPen + } + } + return pen +} diff --git a/internal/engine/eval_test.go b/internal/engine/eval_test.go new file mode 100644 index 0000000..55eae84 --- /dev/null +++ b/internal/engine/eval_test.go @@ -0,0 +1,113 @@ +package engine + +import ( + "sort" + "strings" + "testing" + + "github.com/dylhunn/dragontoothmg" +) + +// mirrorFEN returns the colour-swapped vertical mirror of a position: White and +// Black exchange roles and every rank is reflected. A correct evaluation must +// score the mirror exactly opposite (and equal once the side-to-move sign is +// applied), so Evaluate(pos) must equal Evaluate(mirror(pos)). +func mirrorFEN(fen string) string { + f := strings.Fields(fen) + + ranks := strings.Split(f[0], "/") + for i, j := 0, len(ranks)-1; i < j; i, j = i+1, j-1 { + ranks[i], ranks[j] = ranks[j], ranks[i] + } + for i := range ranks { + ranks[i] = swapCase(ranks[i]) + } + f[0] = strings.Join(ranks, "/") + + if f[1] == "w" { + f[1] = "b" + } else { + f[1] = "w" + } + + if f[2] != "-" { + cr := []byte(swapCase(f[2])) + sort.Slice(cr, func(i, j int) bool { return castleOrder(cr[i]) < castleOrder(cr[j]) }) + f[2] = string(cr) + } + + if f[3] != "-" { + switch f[3][1] { + case '3': + f[3] = string(f[3][0]) + "6" + case '6': + f[3] = string(f[3][0]) + "3" + } + } + return strings.Join(f, " ") +} + +func swapCase(s string) string { + b := []byte(s) + for i, c := range b { + switch { + case c >= 'a' && c <= 'z': + b[i] = c - 32 + case c >= 'A' && c <= 'Z': + b[i] = c + 32 + } + } + return string(b) +} + +func castleOrder(c byte) int { + return strings.IndexByte("KQkq", c) +} + +func TestEvaluateIsColourSymmetric(t *testing.T) { + fens := []string{ + dragontoothmg.Startpos, + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "8/5k2/8/8/3P4/8/5K2/8 w - - 0 1", + "6k1/5ppp/8/8/8/8/5PPP/6K1 b - - 0 1", + } + for _, fen := range fens { + orig := dragontoothmg.ParseFen(fen) + mir := dragontoothmg.ParseFen(mirrorFEN(fen)) + if got, want := Evaluate(&mir), Evaluate(&orig); got != want { + t.Errorf("asymmetric eval for %s: got %d, mirror %d", fen, want, got) + } + } +} + +func TestGamePhaseBounds(t *testing.T) { + start := dragontoothmg.ParseFen(dragontoothmg.Startpos) + if p := gamePhase(&start); p != maxPhase { + t.Errorf("start position phase = %d, want %d", p, maxPhase) + } + bare := dragontoothmg.ParseFen("4k3/pppppppp/8/8/8/8/PPPPPPPP/4K3 w - - 0 1") + if p := gamePhase(&bare); p != 0 { + t.Errorf("king-and-pawns phase = %d, want 0", p) + } +} + +func TestStartPositionIsRoughlyBalanced(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + if got := Evaluate(&b); got < 0 || got > 2*tempo { + t.Errorf("start position eval = %d, want it within [0, %d]", got, 2*tempo) + } +} + +func TestPassedPawnBeatsBlockadedPawn(t *testing.T) { + // White pawn on e6, no black pawns able to stop it. + passed := dragontoothmg.ParseFen("4k3/8/4P3/8/8/8/8/4K3 w - - 0 1") + // Same pawn, but a black pawn on e7 sits in front of it. + blockaded := dragontoothmg.ParseFen("4k3/4p3/4P3/8/8/8/8/4K3 w - - 0 1") + if Evaluate(&passed) <= Evaluate(&blockaded) { + t.Errorf("passed pawn (%d) did not beat blockaded pawn (%d)", + Evaluate(&passed), Evaluate(&blockaded)) + } +} diff --git a/internal/engine/polyglot.go b/internal/engine/polyglot.go new file mode 100644 index 0000000..5efc369 --- /dev/null +++ b/internal/engine/polyglot.go @@ -0,0 +1,171 @@ +package engine + +import ( + "encoding/binary" + "fmt" + "math/bits" + "os" + "sort" + "strings" + + "github.com/dylhunn/dragontoothmg" +) + +// Polyglot opening book support: the Zobrist key defined by the Polyglot ".bin" +// format (which differs from dragontoothmg's own hash) and a reader for the +// 16-byte-per-entry book files those keys index. + +// PolyglotKey returns the Polyglot Zobrist key for b. See +// http://hgm.nubati.net/book_format.html. +func PolyglotKey(b *dragontoothmg.Board) uint64 { + var key uint64 + + for pt := dragontoothmg.Pawn; pt <= dragontoothmg.King; pt++ { + white := pieceBoard(&b.White, pt) + black := pieceBoard(&b.Black, pt) + // Polyglot "kind_of_piece": black pawn 0, white pawn 1, black knight 2, … + wKind := 2*(pt-1) + 1 + bKind := 2 * (pt - 1) + for x := white; x != 0; x &= x - 1 { + key ^= polyglotRandom[64*wKind+bits.TrailingZeros64(x)] + } + for x := black; x != 0; x &= x - 1 { + key ^= polyglotRandom[64*bKind+bits.TrailingZeros64(x)] + } + } + + f := strings.Fields(b.ToFen()) + for _, c := range f[2] { + switch c { + case 'K': + key ^= polyglotRandom[768] + case 'Q': + key ^= polyglotRandom[769] + case 'k': + key ^= polyglotRandom[770] + case 'q': + key ^= polyglotRandom[771] + } + } + + // En passant only counts when a pawn of the side to move can actually make + // the capture — matching Polyglot, not FEN. + if f[3] != "-" { + if ts, err := dragontoothmg.AlgebraicToIndex(f[3]); err == nil { + file := int(ts) % 8 + var capSq int + var ourPawns uint64 + if b.Wtomove { + capSq, ourPawns = int(ts)-8, b.White.Pawns + } else { + capSq, ourPawns = int(ts)+8, b.Black.Pawns + } + canCapture := (file > 0 && ourPawns&(1<= key }) + + best := -1 + for ; i < len(bk.entries) && bk.entries[i].key == key; i++ { + if best < 0 || bk.entries[i].weight > bk.entries[best].weight { + best = i + } + } + if best < 0 { + return 0, false + } + return decodePolyglotMove(bk.entries[best].move, b) +} + +// decodePolyglotMove converts a Polyglot-encoded move to a dragontoothmg move, +// translating the "king onto its own rook" castling encoding, and only returns +// ok when the result is legal in b. +func decodePolyglotMove(pm uint16, b *dragontoothmg.Board) (dragontoothmg.Move, bool) { + from := int((pm>>9)&7)*8 + int((pm>>6)&7) + to := int((pm>>3)&7)*8 + int(pm&7) + + var mv dragontoothmg.Move + mv.Setfrom(dragontoothmg.Square(from)) + mv.Setto(dragontoothmg.Square(to)) + switch (pm >> 12) & 7 { + case 1: + mv.Setpromote(dragontoothmg.Knight) + case 2: + mv.Setpromote(dragontoothmg.Bishop) + case 3: + mv.Setpromote(dragontoothmg.Rook) + case 4: + mv.Setpromote(dragontoothmg.Queen) + } + + if (b.White.Kings|b.Black.Kings)&(1<= len(s.pv) { + s.pvLen[ply] = 1 + return + } + n := copy(s.pv[ply][1:], s.pv[ply+1][:s.pvLen[ply+1]]) + s.pvLen[ply] = n + 1 } func (s *searcher) timeUp() bool { if s.stop != nil && s.stop.Load() { return true } + if s.extStop != nil && s.extStop.Load() { + return true + } + if s.ponder { + // Pondering: ignore the clock until the opponent plays the expected + // move, then start the budget from now. + if s.ponderHit == nil || !s.ponderHit.Load() { + return false + } + s.ponder = false + if s.budget > 0 { + s.deadline = time.Now().Add(s.budget) + } + } if s.deadline.IsZero() { return false } @@ -108,28 +193,59 @@ func Search(b *dragontoothmg.Board, p SearchParams) SearchResult { return res } - var deadline time.Time - if p.MoveTime > 0 { - deadline = start.Add(p.MoveTime) + opts := searchOpts{ + tt: tt, + stop: new(atomic.Bool), + extStop: p.Stop, + budget: p.MoveTime, + ponder: p.Ponder, + ponderHit: p.PonderHit, + start: start, + info: p.Info, + } + if p.MoveTime > 0 && !p.Ponder { + opts.deadline = start.Add(p.MoveTime) } - stop := new(atomic.Bool) + tt.NewSearch() if threads > 1 { - res = searchLazySMP(b, p.MaxDepth, deadline, stop, tt, threads) + res = searchLazySMP(b, p.MaxDepth, threads, opts) } else { - s := &searcher{tt: tt, stop: stop, deadline: deadline} - res = s.runIterativeDeepening(b, p.MaxDepth, 1) + // Search a copy so a concurrent caller (the UCI read loop) may keep + // using its own board while this search runs. + board := *b + res = newSearcher(0, opts, true).runIterativeDeepening(&board, p.MaxDepth, 1) } res.Elapsed = time.Since(start) return res } +// newSearcher builds a worker from the shared options. reporting is true for the +// single worker that streams Info output. +func newSearcher(id int, opts searchOpts, reporting bool) *searcher { + s := &searcher{ + tt: opts.tt, + stop: opts.stop, + extStop: opts.extStop, + deadline: opts.deadline, + budget: opts.budget, + ponder: opts.ponder, + ponderHit: opts.ponderHit, + start: opts.start, + id: id, + } + if reporting { + s.info = opts.info + } + return s +} + // searchLazySMP runs `threads` workers over one shared transposition table. Each // worker deepens independently on its own copy of the board; workers seeded with // a different start depth diverge into different subtrees, and the shared TT // lets every worker profit from what the others have already searched. The // deepest completed result wins. See https://www.chessprogramming.org/Lazy_SMP. -func searchLazySMP(b *dragontoothmg.Board, maxDepth int, deadline time.Time, stop *atomic.Bool, tt *TT, threads int) SearchResult { +func searchLazySMP(b *dragontoothmg.Board, maxDepth, threads int, opts searchOpts) SearchResult { results := make([]SearchResult, threads) var wg sync.WaitGroup for i := 0; i < threads; i++ { @@ -143,8 +259,7 @@ func searchLazySMP(b *dragontoothmg.Board, maxDepth int, deadline time.Time, sto if startDepth > maxDepth { startDepth = maxDepth } - s := &searcher{tt: tt, stop: stop, deadline: deadline, id: id} - results[id] = s.runIterativeDeepening(&board, maxDepth, startDepth) + results[id] = newSearcher(id, opts, id == 0).runIterativeDeepening(&board, maxDepth, startDepth) }(i) } wg.Wait() @@ -174,12 +289,24 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start } res.BestMove = root[0] + prevScore := 0 for depth := startDepth; depth <= maxDepth; depth++ { - score, move, ok := s.searchRoot(b, depth) + score, move, ok := s.searchDepth(b, depth, prevScore) if !ok { break // out of time: keep the previous completed depth } res.BestMove, res.Score, res.Depth = move, score, depth + res.PV = append(res.PV[:0], s.pv[0][:s.pvLen[0]]...) + prevScore = score + if s.info != nil { + s.info(SearchInfo{ + Depth: depth, + Score: score, + Nodes: s.nodes, + Elapsed: time.Since(s.start), + PV: res.PV, + }) + } if score >= mateThreshold || score <= -mateThreshold { if s.stop != nil { s.stop.Store(true) // forced mate: let the other workers stop too @@ -194,16 +321,91 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start return res } -func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, best dragontoothmg.Move, ok bool) { +// searchDepth runs one iterative-deepening iteration for depth. From aspMinDepth +// it wraps searchRoot in an aspiration window centred on the previous score, +// widening the failing side until the score lands inside; shallow depths and +// positions with a known mate score search the full window directly. +func (s *searcher) searchDepth(b *dragontoothmg.Board, depth, prevScore int) (int, dragontoothmg.Move, bool) { + if depth < aspMinDepth || prevScore >= mateThreshold || prevScore <= -mateThreshold { + return s.searchRoot(b, depth, -infinity, infinity) + } + + alpha, beta, delta := s.aspWindow(prevScore) + for { + score, move, ok := s.searchRoot(b, depth, alpha, beta) + if !ok { + return 0, dragontoothmg.Move(0), false + } + switch { + case score <= alpha: + alpha = max(alpha-delta, -infinity) + delta *= 2 + case score >= beta: + beta = min(beta+delta, infinity) + delta *= 2 + default: + return score, move, true + } + if delta > aspMaxDelta { + alpha, beta = -infinity, infinity + } + } +} + +// aspWindow returns the initial aspiration window and delta for this searcher. +// Lazy-SMP helpers (id > 0) widen the delta and skew the window asymmetrically +// so they hit fail-high / fail-low boundaries at different points than worker 0, +// diverging onto different parts of the tree. +func (s *searcher) aspWindow(prev int) (alpha, beta, delta int) { + delta = aspBaseDelta + 10*s.id + lo, hi := delta, delta + switch { + case s.id == 0: + case s.id&1 == 1: + lo = 2 * delta + default: + hi = 2 * delta + } + return max(prev-lo, -infinity), min(prev+hi, infinity), delta +} + +// skewRootMoves perturbs the root move order for Lazy-SMP helpers: the k-th +// helper swaps a different later move into the second slot, so helpers scout a +// different alternative to the hash move first. Worker 0 and the single searcher +// leave the order untouched. +func (s *searcher) skewRootMoves(moves []dragontoothmg.Move) { + if s.id <= 0 || len(moves) < 3 { + return + } + j := 1 + (s.id-1)%(len(moves)-1) + moves[1], moves[j] = moves[j], moves[1] +} + +// searchRoot searches every legal move at the root inside the window [alpha, +// beta] and returns the (fail-soft) score of the best one. The first move gets +// the full window; the rest are scouted with a null window and re-searched only +// when the scout lands inside. A score at or above beta means a move failed high +// and the remaining moves were skipped — the caller must widen and retry. +func (s *searcher) searchRoot(b *dragontoothmg.Board, depth, alpha, beta int) (score int, best dragontoothmg.Move, ok bool) { + alphaOrig := alpha key := b.Hash() _, ttMove, _ := s.tt.probe(key, depth, -infinity, infinity, 0) moves := s.orderMoves(b, b.GenerateLegalMoves(), ttMove, 0) - alpha, beta := -infinity, infinity + s.skewRootMoves(moves) + s.pvLen[0] = 0 bestScore := -infinity - for _, m := range moves { + for i, m := range moves { unapply := b.Apply(m) - v := -s.negamax(b, depth-1, -beta, -alpha, 1, true) + var v int + if i == 0 { + v = -s.negamax(b, depth-1, -beta, -alpha, 1, true) + } else { + v = -s.negamax(b, depth-1, -alpha-1, -alpha, 1, true) + if v > alpha && v < beta { + v = -s.negamax(b, depth-1, -beta, -alpha, 1, true) + } + } unapply() if s.stopped { return 0, dragontoothmg.Move(0), false @@ -213,9 +415,21 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes } if v > alpha { alpha = v + s.setPV(0, m) + } + if alpha >= beta { + break // fail-high: caller widens beta and re-searches } } - s.tt.store(key, depth, bestScore, boundExact, best, 0) + + bound := boundExact + switch { + case bestScore <= alphaOrig: + bound = boundUpper + case bestScore >= beta: + bound = boundLower + } + s.tt.store(key, depth, bestScore, bound, best, 0) return bestScore, best, true } @@ -254,6 +468,9 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, if v, done := s.terminalScore(b, depth, alpha, beta, ply); done { return v } + if ply <= maxPly { + s.pvLen[ply] = 0 + } alphaOrig := alpha key := b.Hash() @@ -287,6 +504,9 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, } if v > alpha { alpha = v + if ply < maxPly { + s.setPV(ply, m) + } } if alpha >= beta { if isQuiet(b, m) { @@ -308,9 +528,10 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, } // 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. +// from the current side's point of view. This is principal variation search: the +// first move gets a full window at full depth; every later move is first probed +// with a null window (and, for late quiets, a reduced depth — LMR), and only +// re-searched at full depth / full window when that probe beats alpha. 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) @@ -318,17 +539,29 @@ func (s *searcher) searchMove(b *dragontoothmg.Board, m dragontoothmg.Move, move givesCheck := b.OurKingInCheck() newDepth := depth - 1 + if moveIdx == 0 { + return -s.negamax(b, newDepth, -beta, -alpha, ply+1, true) + } + + red := 0 if depth >= lmrMinDepth && moveIdx >= lmrMinMove && quiet && !inCheck && !givesCheck { - red := 1 + 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) + + v := -s.negamax(b, newDepth-red, -alpha-1, -alpha, ply+1, true) + if v > alpha && red > 0 { + // The reduction was too aggressive: retry at full depth, still scouting. + v = -s.negamax(b, newDepth, -alpha-1, -alpha, ply+1, true) + } + if v > alpha && v < beta { + // Scout landed inside the window: this move may be part of the PV, so + // resolve its true score with the full window. + v = -s.negamax(b, newDepth, -beta, -alpha, ply+1, true) + } + return v } // tryNullMove implements null-move pruning: if handing the opponent a free move diff --git a/internal/engine/search_internal_test.go b/internal/engine/search_internal_test.go index b5bb0ad..b501711 100644 --- a/internal/engine/search_internal_test.go +++ b/internal/engine/search_internal_test.go @@ -224,6 +224,40 @@ func TestNegamaxLeavesBoardUnmodified(t *testing.T) { } } +func TestAspirationWindowMatchesFullWindow(t *testing.T) { + fens := []string{ + dragontoothmg.Startpos, + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + } + const depth = 5 + for _, fen := range fens { + b := dragontoothmg.ParseFen(fen) + + // No deadline: both searches are depth-bounded, so they always finish — + // this keeps the comparison meaningful on a slow -race CI runner. + full := &searcher{tt: NewTT(8)} + wantScore, wantMove, ok := full.searchRoot(&b, depth, -infinity, infinity) + if !ok { + t.Fatalf("%s: full-window searchRoot did not complete", fen) + } + + // Feed searchDepth the true score as the previous iteration's guess so the + // aspiration window is tight — the hardest case for it to get right. + asp := &searcher{tt: NewTT(8)} + gotScore, gotMove, ok := asp.searchDepth(&b, depth, wantScore) + if !ok { + t.Fatalf("%s: searchDepth did not complete", fen) + } + if gotScore != wantScore { + t.Errorf("%s: aspiration score %d, full-window score %d", fen, gotScore, wantScore) + } + if gotMove != wantMove { + t.Errorf("%s: aspiration move %s, full-window move %s", fen, gotMove.String(), wantMove.String()) + } + } +} + // field returns the i-th space-separated field of a FEN string. func field(fen string, i int) string { start, n := 0, 0 diff --git a/internal/engine/search_test.go b/internal/engine/search_test.go index 519c070..f3225ee 100644 --- a/internal/engine/search_test.go +++ b/internal/engine/search_test.go @@ -167,6 +167,40 @@ func TestSearchPrunedResultMatchesPlainOnTactics(t *testing.T) { } } +func TestSearchReportsPrincipalVariation(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + res := engine.Search(&b, engine.SearchParams{MaxDepth: 6}) + if len(res.PV) < 2 { + t.Fatalf("PV = %v, want at least two moves deep", res.PV) + } + if res.PV[0] != res.BestMove { + t.Errorf("PV[0] = %s, want it to equal BestMove %s", res.PV[0].String(), res.BestMove.String()) + } + // Every PV move must be legal in sequence. + bb := dragontoothmg.ParseFen(dragontoothmg.Startpos) + for i, m := range res.PV { + legal := false + for _, lm := range bb.GenerateLegalMoves() { + if lm == m { + legal = true + break + } + } + if !legal { + t.Fatalf("PV move %d (%s) is not legal in the line", i, m.String()) + } + bb.Apply(m) + } +} + +func TestSearchMateInOnePVIsOneMove(t *testing.T) { + b := dragontoothmg.ParseFen("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1") + res := engine.Search(&b, engine.SearchParams{MaxDepth: 3}) + if len(res.PV) != 1 || res.PV[0].String() != "a1a8" { + t.Fatalf("PV = %v, want exactly [a1a8]", res.PV) + } +} + const ( mateThreshold = 1_000_000 - 64 maxDepthUnbounded = 64 diff --git a/internal/engine/transposition.go b/internal/engine/transposition.go index 81ba735..55879ee 100644 --- a/internal/engine/transposition.go +++ b/internal/engine/transposition.go @@ -37,7 +37,8 @@ type ttEntry struct { // bits 16..47 score, int32 two's-complement (mate scores need > 16 bits) // bits 48..55 depth, uint8 // bits 56..57 bound flag -func packTT(move dragontoothmg.Move, score, depth, bound int) uint64 { +// bits 58..63 generation (6-bit search counter, for ageing) +func packTT(move dragontoothmg.Move, score, depth, bound, gen int) uint64 { switch { case depth < 0: depth = 0 @@ -49,14 +50,16 @@ func packTT(move dragontoothmg.Move, score, depth, bound int) uint64 { return uint64(uint16(move)) | (uint64(uint32(score)) << 16) | //nolint:gosec // deliberate low-32-bit pack; unpackTT restores the sign (uint64(depth&0xff) << 48) | - (uint64(bound&3) << 56) + (uint64(bound&3) << 56) | + (uint64(gen&0x3f) << 58) } -func unpackTT(data uint64) (move dragontoothmg.Move, score, depth, bound int) { +func unpackTT(data uint64) (move dragontoothmg.Move, score, depth, bound, gen int) { move = dragontoothmg.Move(data & 0xffff) score = int(int32(data >> 16)) //nolint:gosec // sign-extend the packed int32 score depth = int((data >> 48) & 0xff) bound = int((data >> 56) & 3) + gen = int((data >> 58) & 0x3f) return } @@ -66,6 +69,10 @@ func unpackTT(data uint64) (move dragontoothmg.Move, score, depth, bound int) { type TT struct { entries []ttEntry mask uint64 + // gen is bumped once per search (NewSearch). store treats entries from an + // older generation as free space, so a new search reclaims the previous + // search's table instead of being blocked by its deeper entries. + gen atomic.Uint32 } // NewTT returns a table that occupies about mb megabytes, rounded down to the @@ -82,6 +89,13 @@ func NewTT(mb int) *TT { return &TT{entries: make([]ttEntry, size), mask: size - 1} } +// NewSearch advances the table's generation. Call it once at the start of every +// search so entries left by the previous search become preferentially +// replaceable (see store). +func (t *TT) NewSearch() { + t.gen.Add(1) +} + // Clear empties every slot. Call it between games (UCI "ucinewgame"); stale // entries from an unrelated position are otherwise indistinguishable from live // ones once their key happens to collide. @@ -103,7 +117,7 @@ func (t *TT) probe(key uint64, depth, alpha, beta, ply int) (score int, move dra if data == 0 || lock^data != key { return 0, 0, false } - m, eScore, eDepth, bound := unpackTT(data) + m, eScore, eDepth, bound, _ := unpackTT(data) if eDepth < depth { return 0, m, false } @@ -123,16 +137,19 @@ func (t *TT) probe(key uint64, depth, alpha, beta, ply int) (score int, move dra return 0, m, false } -// store records a result for key. A shallower existing entry for the same key is -// overwritten; a deeper one is kept. +// store records a result for key. An existing entry is kept only when it is for +// the same key, from the current generation, and searched deeper — so within one +// search the table is depth-preferred, but a new search (NewSearch) overwrites +// the previous one's entries regardless of their depth. func (t *TT) store(key uint64, depth, score, bound int, move dragontoothmg.Move, ply int) { + gen := int(t.gen.Load() & 0x3f) e := &t.entries[key&t.mask] if old := e.data.Load(); old != 0 && e.lock.Load()^old == key { - if _, _, oldDepth, _ := unpackTT(old); oldDepth > depth { + if _, _, oldDepth, _, oldGen := unpackTT(old); oldGen == gen && oldDepth > depth { return } } - data := packTT(move, ttStoreScore(score, ply), depth, bound) + data := packTT(move, ttStoreScore(score, ply), depth, bound, gen) e.lock.Store(key ^ data) e.data.Store(data) } diff --git a/internal/engine/transposition_test.go b/internal/engine/transposition_test.go index 52abf42..d126d6e 100644 --- a/internal/engine/transposition_test.go +++ b/internal/engine/transposition_test.go @@ -84,6 +84,27 @@ func TestTTMateScoreIsRebasedByPly(t *testing.T) { } } +func TestTTAgeingReclaimsPreviousSearch(t *testing.T) { + tt := NewTT(1) + key := uint64(0x1234) + deep, _ := dragontoothmg.ParseMove("e2e4") + shallow, _ := dragontoothmg.ParseMove("d2d4") + + // Within one generation, a deeper entry is kept over a shallower store. + tt.store(key, 12, 5, boundExact, deep, 0) + tt.store(key, 4, 9, boundExact, shallow, 0) + if _, got, _ := tt.probe(key, 4, -100, 100, 0); got != deep { + t.Errorf("same-generation deeper entry was overwritten: got %s", got.String()) + } + + // A new search must overwrite it even with a shallower result. + tt.NewSearch() + tt.store(key, 4, 9, boundExact, shallow, 0) + if _, got, _ := tt.probe(key, 4, -100, 100, 0); got != shallow { + t.Errorf("stale-generation entry not reclaimed: got %s, want %s", got.String(), shallow.String()) + } +} + func TestTTMissOnKeyMismatch(t *testing.T) { tt := NewTT(1) tt.store(1, 5, 10, boundExact, 0, 0) diff --git a/internal/uci/uci.go b/internal/uci/uci.go index 9801e27..6cb8dfe 100644 --- a/internal/uci/uci.go +++ b/internal/uci/uci.go @@ -10,6 +10,8 @@ import ( "runtime" "strconv" "strings" + "sync" + "sync/atomic" "time" "github.com/dylhunn/dragontoothmg" @@ -35,11 +37,30 @@ const ( type session struct { board dragontoothmg.Board out io.Writer + mu sync.Mutex // guards writes to out and the search field tt *engine.TT hashMB int threads int + ponder bool // the "Ponder" option; a GUI only sends "go ponder" when set + ownBook bool + book *engine.Book + search *activeSearch } +// activeSearch tracks the goroutine running the current search so "stop", +// "ponderhit" and "quit" can reach it. +type activeSearch struct { + stop *atomic.Bool + ponderHit *atomic.Bool + bounded bool // has a depth or movetime limit and is not pondering + hasBudget bool // a movetime / clock budget was given + release chan struct{} // closed on ponderhit or stop; gates the bestmove + relOnce sync.Once + done chan struct{} // closed once bestmove has been emitted +} + +func (as *activeSearch) signalRelease() { as.relOnce.Do(func() { close(as.release) }) } + // Run reads UCI commands from r and writes responses to w until "quit" or EOF. // version is reported in the "id name" line. func Run(r io.Reader, w io.Writer, version string) error { @@ -59,36 +80,53 @@ func Run(r io.Reader, w io.Writer, version string) error { } switch fields[0] { case "uci": - fmt.Fprintf(w, "id name %s %s\n", engineName, version) - fmt.Fprintf(w, "id author %s\n", engineAuthor) - fmt.Fprintf(w, "option name Hash type spin default %d min %d max %d\n", defaultHashMB, minHashMB, maxHashMB) - fmt.Fprintf(w, "option name Threads type spin default %d min 1 max %d\n", defaultThreads, maxThreads) - fmt.Fprintln(w, "uciok") + s.emit("id name %s %s\n", engineName, version) + s.emit("id author %s\n", engineAuthor) + s.emit("option name Hash type spin default %d min %d max %d\n", defaultHashMB, minHashMB, maxHashMB) + s.emit("option name Threads type spin default %d min 1 max %d\n", defaultThreads, maxThreads) + s.emit("option name Ponder type check default false\n") + s.emit("option name OwnBook type check default false\n") + s.emit("option name BookFile type string default\n") + s.emit("uciok\n") case "isready": s.ensureTT() - fmt.Fprintln(w, "readyok") + s.emit("readyok\n") case "setoption": s.handleSetOption(fields[1:]) case "ucinewgame": + s.stopSearch() s.board = dragontoothmg.ParseFen(dragontoothmg.Startpos) if s.tt != nil { s.tt.Clear() } case "position": + s.stopSearch() s.handlePosition(fields[1:]) case "go": s.handleGo(fields[1:]) case "stop": - // Search is synchronous, so there is nothing to interrupt. + s.stopSearch() + case "ponderhit": + s.ponderHit() case "d": - fmt.Fprintln(w, s.board.ToFen()) + s.emit("%s\n", s.board.ToFen()) case "quit": + s.endSearch(true) return nil } } + s.endSearch(true) return sc.Err() } +// emit writes one formatted line to out under the mutex, so the search goroutine +// and the command loop never interleave output. +func (s *session) emit(format string, args ...any) { + s.mu.Lock() + defer s.mu.Unlock() + fmt.Fprintf(s.out, format, args...) +} + // ensureTT lazily allocates the transposition table at the configured size. func (s *session) ensureTT() { if s.tt == nil { @@ -96,6 +134,43 @@ func (s *session) ensureTT() { } } +// stopSearch aborts the running search (if any) and blocks until its bestmove +// has been emitted. +func (s *session) stopSearch() { s.endSearch(false) } + +// endSearch blocks until the running search (if any) has emitted its bestmove. +// It aborts the search unless graceful is set and the search is guaranteed to +// finish on its own soon (a fixed depth / movetime search a GUI is waiting on, +// or a pondering search that already got its ponderhit and has a budget). A +// "quit" or EOF must never hang on an infinite or still-pondering search. +func (s *session) endSearch(graceful bool) { + s.mu.Lock() + as := s.search + s.mu.Unlock() + if as == nil { + return + } + willFinish := as.bounded || (as.hasBudget && as.ponderHit.Load()) + if !graceful || !willFinish { + as.stop.Store(true) + } + as.signalRelease() + <-as.done +} + +// ponderhit tells a pondering search that the opponent played the expected move, +// so it should start spending its time budget and report as normal. +func (s *session) ponderHit() { + s.mu.Lock() + as := s.search + s.mu.Unlock() + if as == nil { + return + } + as.ponderHit.Store(true) + as.signalRelease() +} + // handleSetOption parses "setoption name value " for the options // advertised in the "uci" reply. Unknown options are ignored, as the protocol // requires. @@ -119,6 +194,7 @@ func (s *session) handleSetOption(args []string) { switch strings.ToLower(name) { case "hash": if n, err := strconv.Atoi(value); err == nil { + s.stopSearch() s.hashMB = clamp(n, minHashMB, maxHashMB) s.tt = engine.NewTT(s.hashMB) // resize now, before the next search } @@ -126,7 +202,29 @@ func (s *session) handleSetOption(args []string) { if n, err := strconv.Atoi(value); err == nil { s.threads = clamp(n, 1, maxThreads) } + case "ponder": + s.ponder = strings.EqualFold(value, "true") + case "ownbook": + s.ownBook = strings.EqualFold(value, "true") + case "bookfile": + s.loadBook(strings.TrimSpace(value)) + } +} + +// loadBook (re)loads the Polyglot book. An empty path clears it. +func (s *session) loadBook(path string) { + if path == "" { + s.book = nil + return } + bk, err := engine.OpenBook(path) + if err != nil { + s.book = nil + s.emit("info string book load failed: %v\n", err) + return + } + s.book = bk + s.emit("info string book loaded: %d entries\n", bk.Len()) } func clamp(v, lo, hi int) int { @@ -169,37 +267,76 @@ func (s *session) handlePosition(args []string) { } } -func (s *session) handleGo(args []string) { - params := engine.SearchParams{} - var wtime, btime, movetime time.Duration +// goLimits is the parsed subset of a "go" command's arguments. +type goLimits struct { + maxDepth int + movetime time.Duration + ponder bool +} + +// parseGoArgs reads the "go" keywords goChess supports. Unknown keywords +// (movestogo, mate, searchmoves, …) are ignored. whiteToMove selects which +// side's clock feeds the time budget. +func parseGoArgs(args []string, whiteToMove bool) goLimits { + var g goLimits + var wtime, btime, winc, binc time.Duration - // Scan for the keywords we support, each followed by an integer argument. - // Unknown keywords (movestogo, winc, ponder, ...) are ignored. - for i := 0; i < len(args)-1; i++ { + for i := 0; i < len(args); i++ { + switch args[i] { + case "ponder": + g.ponder = true + case "infinite": + g.maxDepth = 0 + } + if i+1 >= len(args) { + break + } switch args[i] { case "depth": if d, err := strconv.Atoi(args[i+1]); err == nil { - params.MaxDepth = d + g.maxDepth = d } case "movetime": - movetime = millis(args[i+1]) + g.movetime = millis(args[i+1]) case "wtime": wtime = millis(args[i+1]) case "btime": btime = millis(args[i+1]) + case "winc": + winc = millis(args[i+1]) + case "binc": + binc = millis(args[i+1]) } } - if movetime == 0 { - remaining := btime - if s.board.Wtomove { - remaining = wtime + if g.movetime == 0 { + remaining, inc := btime, binc + if whiteToMove { + remaining, inc = wtime, winc } if remaining > 0 { - movetime = remaining / clockDivisor + g.movetime = remaining/clockDivisor + inc*3/4 + } + } + return g +} + +func (s *session) handleGo(args []string) { + s.stopSearch() + + g := parseGoArgs(args, s.board.Wtomove) + ponder := g.ponder + params := engine.SearchParams{MaxDepth: g.maxDepth, MoveTime: g.movetime} + + // An in-book move short-circuits the search entirely (but never while + // pondering — there is nothing to ponder on a book move). + if s.ownBook && s.book != nil && !ponder { + if mv, ok := s.book.Probe(&s.board); ok { + s.emit("info depth 0 score cp 0 pv %s\n", mv.String()) + s.emit("bestmove %s\n", mv.String()) + return } } - params.MoveTime = movetime s.ensureTT() params.TT = s.tt @@ -208,17 +345,67 @@ func (s *session) handleGo(args []string) { params.Threads = n // never spawn more workers than the machine has cores } - res := engine.Search(&s.board, params) - if res.Depth > 0 { - fmt.Fprintf(s.out, "info depth %d score %s nodes %d time %d pv %s\n", - res.Depth, scoreString(res.Score), res.Nodes, res.Elapsed.Milliseconds(), res.BestMove.String()) + as := &activeSearch{ + stop: new(atomic.Bool), + ponderHit: new(atomic.Bool), + bounded: !ponder && (params.MaxDepth > 0 || params.MoveTime > 0), + hasBudget: params.MoveTime > 0, + release: make(chan struct{}), + done: make(chan struct{}), + } + if !ponder { + as.signalRelease() // no ponder handshake: emit the bestmove as soon as it's ready + } + params.Stop = as.stop + params.Ponder = ponder + params.PonderHit = as.ponderHit + params.Info = s.infoPrinter() + + s.mu.Lock() + s.search = as + s.mu.Unlock() + + board := s.board + go func() { + res := engine.Search(&board, params) + <-as.release // wait for ponderhit / stop before moving + s.emitBestMove(res) + s.mu.Lock() + s.search = nil + s.mu.Unlock() + close(as.done) + }() +} + +// infoPrinter returns the per-iteration callback that streams "info" lines. +func (s *session) infoPrinter() func(engine.SearchInfo) { + return func(in engine.SearchInfo) { + var pv strings.Builder + for i, m := range in.PV { + if i > 0 { + pv.WriteByte(' ') + } + pv.WriteString(m.String()) + } + var nps int64 + if in.Elapsed > 0 { + nps = in.Nodes * int64(time.Second) / int64(in.Elapsed) + } + s.emit("info depth %d score %s nodes %d nps %d time %d pv %s\n", + in.Depth, scoreString(in.Score), in.Nodes, nps, in.Elapsed.Milliseconds(), pv.String()) + } +} + +func (s *session) emitBestMove(res engine.SearchResult) { + if res.BestMove == 0 || res.BestMove.String() == "0000" { + s.emit("bestmove (none)\n") + return } - best := res.BestMove.String() - if best == "0000" { - fmt.Fprintln(s.out, "bestmove (none)") + if len(res.PV) >= 2 { + s.emit("bestmove %s ponder %s\n", res.BestMove.String(), res.PV[1].String()) return } - fmt.Fprintf(s.out, "bestmove %s\n", best) + s.emit("bestmove %s\n", res.BestMove.String()) } func millis(s string) time.Duration { diff --git a/internal/uci/uci_test.go b/internal/uci/uci_test.go index 1f50cbd..66ecccd 100644 --- a/internal/uci/uci_test.go +++ b/internal/uci/uci_test.go @@ -2,9 +2,16 @@ package uci_test import ( "bytes" + "encoding/binary" + "os" + "path/filepath" "strings" "testing" + "time" + "github.com/dylhunn/dragontoothmg" + + "github.com/cjunius/goChess/internal/engine" "github.com/cjunius/goChess/internal/uci" ) @@ -125,3 +132,108 @@ func TestStopAndUnknownGoArgsAreHarmless(t *testing.T) { t.Errorf("expected a bestmove:\n%s", out) } } + +func TestUciAdvertisesPonderOption(t *testing.T) { + out := run(t, "uci\nquit\n") + if !strings.Contains(out, "option name Ponder type check default false") { + t.Errorf("missing Ponder option:\n%s", out) + } +} + +func TestInfiniteSearchStopsOnStop(t *testing.T) { + start := time.Now() + out := run(t, "position startpos\ngo infinite\nstop\nquit\n") + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("infinite search took %s to stop", elapsed) + } + if n := strings.Count(out, "bestmove"); n != 1 { + t.Fatalf("want exactly one bestmove, got %d:\n%s", n, out) + } +} + +func TestPonderHitReleasesBestMove(t *testing.T) { + // White has mate in one (a1a8). Pondering, then ponderhit lets the search + // spend its budget, find the mate and report it. + out := run(t, strings.Join([]string{ + "position fen 6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1", + "go ponder movetime 2000", + "ponderhit", + "quit", + "", + }, "\n")) + if n := strings.Count(out, "bestmove"); n != 1 { + t.Fatalf("want exactly one bestmove, got %d:\n%s", n, out) + } + if !strings.Contains(out, "bestmove a1a8") { + t.Errorf("want 'bestmove a1a8' after ponderhit:\n%s", out) + } +} + +func TestPonderStopReleasesBestMoveWithoutHit(t *testing.T) { + start := time.Now() + out := run(t, strings.Join([]string{ + "position startpos moves e2e4", + "go ponder wtime 60000 btime 60000", + "stop", + "quit", + "", + }, "\n")) + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("pondering search took %s to stop after 'stop'", elapsed) + } + if n := strings.Count(out, "bestmove"); n != 1 { + t.Fatalf("want exactly one bestmove, got %d:\n%s", n, out) + } +} + +func TestOpeningBookShortCircuitsSearch(t *testing.T) { + start := dragontoothmg.ParseFen(dragontoothmg.Startpos) + key := engine.PolyglotKey(&start) + + // One entry: start position -> g1f3 (from file 6 row 0, to file 5 row 2). + move := uint16(5 | 2<<3 | 6<<6 | 0<<9) + var rec [16]byte + binary.BigEndian.PutUint64(rec[0:], key) + binary.BigEndian.PutUint16(rec[8:], move) + binary.BigEndian.PutUint16(rec[10:], 1) + + path := filepath.Join(t.TempDir(), "book.bin") + if err := os.WriteFile(path, rec[:], 0o600); err != nil { + t.Fatal(err) + } + + start2 := time.Now() + out := run(t, strings.Join([]string{ + "setoption name BookFile value " + path, + "setoption name OwnBook value true", + "position startpos", + "go depth 20", + "quit", + "", + }, "\n")) + if elapsed := time.Since(start2); elapsed > time.Second { + t.Fatalf("book move took %s — it should skip the search", elapsed) + } + if !strings.Contains(out, "book loaded: 1 entries") { + t.Errorf("book was not loaded:\n%s", out) + } + if !strings.Contains(out, "bestmove g1f3") { + t.Errorf("want the book move g1f3:\n%s", out) + } +} + +func TestBestMoveCarriesPonderMove(t *testing.T) { + out := run(t, "position startpos\ngo depth 6\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Fatalf("no bestmove:\n%s", out) + } + line := "" + for _, l := range strings.Split(out, "\n") { + if strings.HasPrefix(l, "bestmove ") { + line = l + } + } + if !strings.Contains(line, " ponder ") { + t.Errorf("bestmove line has no ponder move: %q", line) + } +}