diff --git a/.claude/skills/bench/SKILL.md b/.claude/skills/bench/SKILL.md index 0c726b4..8f6af07 100644 --- a/.claude/skills/bench/SKILL.md +++ b/.claude/skills/bench/SKILL.md @@ -8,8 +8,8 @@ description: > # bench -`docs/performance.md` must only ever contain **measured** numbers. This skill -produces them; never hand-edit timings. +`docs/performance.md` must only ever contain **measured** numbers for the +current engine. This skill produces them; never hand-edit timings. ## 1. Environment @@ -23,9 +23,10 @@ are labelled with them and results are machine-specific. ## 2. Fixed-depth single-process search (start position) -Regenerates the "After" column of the "Single process, fixed-depth `search()`" -table. A fresh `Negamax` per depth (cold TT), un-armed clock so it never aborts. -"Nodes" is `searcher.nodes` (main search + quiescence). +Regenerates the "Single process, fixed-depth `search()`" table - the controlled +baseline, second in the file. A fresh `Negamax` per depth (cold TT), un-armed +clock so it never aborts. "Nodes" is `searcher.nodes` (main search + +quiescence). Stop adding rows once a search passes ~5 seconds. ```python import time @@ -37,27 +38,35 @@ from pychess.move_ordering import MoveOrderer from pychess.negamax import Negamax from pychess.transposition import TranspositionTable -for depth in range(2, 8): +for depth in range(2, 12): s = Negamax(PestoEvaluator(), MoveOrderer(), TranspositionTable(), Clock()) t = time.time() s.search(EvalBoard(), -INF, INF, depth) - print(f"| {depth} | {time.time() - t:.3f}s / {s.nodes:,} |") + dt = time.time() - t + print(f"| {depth} | {dt:.3f}s | {s.nodes:,} |") + if dt > 5: + break ``` ## 3. Lazy SMP (start position) -Regenerates the "After" column of the "What `go` runs today: Lazy SMP" table. -Use a generous `movetime` so the run isn't deadline-capped. +Regenerates the "What `go` runs today: Lazy SMP" table - the real search path, +first in the file. Use a generous `movetime` so the run isn't deadline-capped. +Stop once a search passes ~5s. Node counts and times vary run to run; take one +clean run and note that in the file. ```python import time from pychess.eval_board import EvalBoard from pychess.engine import Engine -for depth in (6, 7, 8): +for depth in range(6, 12): t = time.time() r = Engine().search(EvalBoard(), {"depth": depth, "movetime": 120000}) - print(f"| {depth} | reached d{r.depth} | {time.time() - t:.2f}s | {r.nodes:,} nodes |") + dt = time.time() - t + print(f"| {depth} | {dt:.2f}s | {r.nodes:,} |") + if dt > 5: + break ``` Run each script with `.venv/bin/python` (or the active env). Steps 2 and 3 @@ -65,20 +74,22 @@ together take roughly a minute. ## 4. Update `docs/performance.md` -- Replace the "After (time / nodes)" cells in the fixed-depth table and the - Lazy SMP table with the new numbers. +- Replace the rows in both tables with the new numbers. +- The single-process table's `perft(depth)` column is the published start-position + perft sequence (400 / 8,902 / 197,281 / 4,865,609 / 119,060,324 / + 3,195,901,860 / 84,998,978,956 for depths 2-8) - static, only extend it if you + add deeper rows. Recompute the `pruned` column as `1 - nodes/perft` from the + fresh node counts. - Update the machine / Python-version sentence at the top of the file. -- **Leave alone:** the "Before" columns (pre-review engine, not reproducible), - the M1 reference paragraph, and the "Single-process iterative deepening - (removed)" table. -- Recompute the "speed-up" column from the new numbers. +- Keep both tables trimmed to where the time is ~5s or less - drop or add rows + as the numbers move. ## 5. Cross-check If speed changed materially, check whether these still hold and flag (don't silently rewrite) any that drifted: -- the `~45-50k nps` figure and the "depth ~6-8 in blitz" notes in +- the `~45-50k nps` figure and the blitz / rapid depth notes in `docs/engine-strength.md`. ## 6. Report diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcbabe..cd5a7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Null-move pruning in `Negamax.search`: when a null move fails high at reduced + depth (`R = 2..3`) the node returns a `beta` cut-off without searching the + real moves. Skipped at the root, while in check, in likely zugzwang (side to + move has only king and pawns), below depth 3, and when it was already tried + on the path; a verification search guards the cut-off at depth >= 10. +- Late move reductions in `Negamax.search`: past the first three moves at a + node (depth >= 3), quiet non-checking non-TT moves are first searched + `1..3` plies shallower - the reduction grows with move index and depth and + shrinks by one for killers / positive-history moves. A reduced search that + beats `alpha` is repeated at full depth. New `MoveOrderer.is_killer` / + `MoveOrderer.history_score` accessors support the reduction decision. +- Principal variation search in `Negamax.search`: only the first (best-ordered) + move gets the full `(alpha, beta)` window; every later move is scouted with a + null window and re-searched at full depth and width only when the scout beats + `alpha` without an already-certain cut-off. The LMR scout now shares that + null window. +- In-tree draw detection: threefold repetition and the fifty-move rule are now + scored as draws inside the search (`negamax.claims_draw`), not just the + automatic five-fold / seventy-five-move draws. Every draw scores a flat `0` + (previously the game-over branch could return `0 - depth`); a `CONTEMPT` + constant is the hook for a non-zero draw score. +- Mate-distance pruning in `Negamax.search`: the window is clamped to the + best/worst mate still reachable from the node, so the search never chases a + slower mate than one already found. +- Positional evaluation terms (`eval_terms`) layered on the PeSTO tables: + passed / isolated / doubled pawns, bishop pair, rooks on open / half-open + files, knight outposts, a pawn-shield king-safety penalty, and a tempo bonus. + The pure-pawn terms are memoised on the pawn bitboards by `PestoEvaluator`. + +### Changed + +- Mate scores are now distance-to-mate from the search root (`MATE - ply`) + instead of `-MATE - remaining_depth`. The transposition table rebases them on + store/probe (`constants.tt_store_score` / `tt_probe_score`) so mate bounds + propagate through it; `MATE_GUARD` is gone. `TranspositionTable` / + `SharedTT` `probe` / `store` take a `ply` argument. +- UCI `info` lines report `score mate N` (signed, in moves) for mate scores + instead of a large `score cp`. + ## [0.1.0] - 2026-08-30 Initial release. diff --git a/README.md b/README.md index 5153818..4b599e7 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) A UCI chess engine in Python. `Negamax` (fail-soft alpha-beta with a -check-aware quiescence search) is the core; it takes an evaluator, a move -orderer, a transposition table, and a clock as constructor arguments. +check-aware quiescence search, null-move pruning, late move reductions, and +principal variation search) is the core; it takes an evaluator, a move orderer, +a transposition table, and a clock as constructor arguments. `lazy_smp.search` runs one iterative-deepening `Negamax` per worker process against a shared-memory transposition table and returns the deepest completed line. Evaluation is PeSTO - tapered mid/endgame piece-square tables kept as an -incremental accumulator on the board. See [docs/design.md](docs/design.md) for +incremental accumulator - plus hand-crafted terms (pawn structure, king safety, +bishop pair, rook files, outposts). See [docs/design.md](docs/design.md) for the full feature list and backlog. ![pychess playing itself](docs/self-play.gif) @@ -21,16 +23,16 @@ the full feature list and backlog. ## Estimated Engine Strength -Around **1800–2000 Elo** at blitz, most likely ~1900. This is a feature-based +Around **1950–2150 Elo** at blitz, most likely ~2050. This is a feature-based estimate from the search and evaluation, **not a measured result** - no games against rated opposition have been run yet. | Time control | Estimate | Why | |---|---|---| -| Bullet (1+0) | ~1600–1750 | Python per-move overhead dominates. | -| Blitz (3+2 / 5+3) | ~1850–2000 | Reaches depth 6–8. | -| Rapid / Classical | ~2000–2150 | Reaches depth 9–10+; PeSTO scales well with depth. | -| Lichess bot pool | ~1950–2200 blitz | Bot ratings there tend to run higher than CCRL. | +| Bullet (1+0) | ~1750–1900 | Python per-move overhead dominates. | +| Blitz (3+2 / 5+3) | ~1950–2150 | Reaches depth 8–10. | +| Rapid / Classical | ~2150–2300 | Reaches depth 11–13+; a PST-based eval scales well with depth. | +| Lichess bot pool | ~2050–2300 blitz | Bot ratings there tend to run higher than CCRL. | See [docs/engine-strength.md](docs/engine-strength.md) for how the number is derived, calibration against known engines, and how to turn it into a measured @@ -73,6 +75,7 @@ pychess/ │ ├── negamax.py Negamax - fail-soft negamax + quiescence over injected pieces │ ├── move_ordering.py MoveOrderer - TT move / MVV-LVA / killers / history │ ├── evaluation.py PeSTO tables + PestoEvaluator +│ ├── eval_terms.py positional terms (pawn structure, king safety, bishop pair, …) │ ├── eval_board.py EvalBoard - chess.Board with an incremental eval accumulator │ ├── transposition.py TranspositionTable - in-process dict │ ├── shared_tt.py SharedTT / SharedFlag - lock-free shared-memory table diff --git a/docs/design.md b/docs/design.md index 27b3f80..787d437 100644 --- a/docs/design.md +++ b/docs/design.md @@ -28,8 +28,13 @@ layer and the only place that prints `info` / `bestmove`. - [Negamax](https://www.chessprogramming.org/Negamax) - fail-soft - [Alpha-Beta Pruning](https://www.chessprogramming.org/Alpha-Beta) +- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) - full window on the first move, null-window scout + re-search on the rest +- [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) - `R = 2..3`, skipped in check / zugzwang / at low depth, with a verification search at high depth +- [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions) - late quiet non-checking moves searched `1..3` plies shallower, full-depth re-search on a fail-high +- [Mate-distance pruning](https://www.chessprogramming.org/Mate_Distance_Pruning) - window clamped to the fastest mate still possible from the node - [Quiescence Search](https://www.chessprogramming.org/Quiescence_Search) - fail-soft, depth-bounded, check-aware -- [Transposition Table](https://www.chessprogramming.org/Transposition_Table) - Zobrist-keyed, EXACT / LOWER / UPPER bounds +- Draw detection - threefold repetition and the fifty-move rule scored `0` inside the tree (`CONTEMPT` hook for a non-zero draw score) +- [Transposition Table](https://www.chessprogramming.org/Transposition_Table) - Zobrist-keyed, EXACT / LOWER / UPPER bounds, mate scores rebased by ply on store/probe - [Iterative Deepening](https://www.chessprogramming.org/Iterative_Deepening) - per worker, with UCI time management - [Move Ordering](https://www.chessprogramming.org/Move_Ordering) - TT move, [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) captures, promotions, [killers](https://www.chessprogramming.org/Killer_Heuristic), [history](https://www.chessprogramming.org/History_Heuristic) - [Lazy SMP](https://www.chessprogramming.org/Lazy_SMP) - workers share a lock-free shared-memory TT @@ -38,6 +43,7 @@ layer and the only place that prints `info` / `bestmove`. ### [Evaluation](https://www.chessprogramming.org/Evaluation) - [PeSTO](https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function) - tapered mid-/end-game [piece-square tables](https://www.chessprogramming.org/Piece-Square_Tables), interpolated by game phase, [incrementally updated](https://www.chessprogramming.org/Incremental_Updates) on `EvalBoard.push` / `pop` +- Positional terms (`eval_terms`) - [passed](https://www.chessprogramming.org/Passed_Pawn) / [isolated](https://www.chessprogramming.org/Isolated_Pawn) / [doubled](https://www.chessprogramming.org/Doubled_Pawn) pawns, [bishop pair](https://www.chessprogramming.org/Bishop_Pair), [rook on open file](https://www.chessprogramming.org/Rook_on_Open_File), [knight outposts](https://www.chessprogramming.org/Outpost), [pawn-shield king safety](https://www.chessprogramming.org/King_Safety), [tempo](https://www.chessprogramming.org/Tempo); recomputed per call with the pawn terms cached on the pawn bitboards ## Backlog @@ -46,18 +52,16 @@ layer and the only place that prints `info` / `bestmove`. - [Static Exchange Evaluation](https://www.chessprogramming.org/Static_Exchange_Evaluation) for capture ordering - [Relative History Heuristic](https://www.chessprogramming.org/Relative_History_Heuristic) - [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) -- [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) -- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) -- [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions) - [Syzygy endgame tablebases](https://www.chessprogramming.org/Endgame_Tablebases) ### Evaluation -- [King safety](https://www.chessprogramming.org/King_Safety), [pawn structure](https://www.chessprogramming.org/Pawn_Structure) (doubled / isolated / passed), [mobility](https://www.chessprogramming.org/Mobility), [tempo](https://www.chessprogramming.org/Tempo) -- [Evaluation](https://www.chessprogramming.org/Evaluation_Hash_Table) / [material](https://www.chessprogramming.org/Material_Hash_Table) / [pawn](https://www.chessprogramming.org/Pawn_Hash_Table) hash tables +- Stronger [king safety](https://www.chessprogramming.org/King_Safety) (attack-weight on the king zone, not just the pawn shield), [mobility](https://www.chessprogramming.org/Mobility), [backward pawns](https://www.chessprogramming.org/Backward_Pawn), king-distance scaling for passers +- [Evaluation](https://www.chessprogramming.org/Evaluation_Hash_Table) / [material](https://www.chessprogramming.org/Material_Hash_Table) hash tables; make the new positional terms incremental on `EvalBoard` ### Alternative search algorithms to evaluate -- [NegaScout](https://www.chessprogramming.org/NegaScout) - [NegaC*](https://www.chessprogramming.org/NegaC*) - [MTD(f)](https://www.chessprogramming.org/MTD\(f\)) + +(NegaScout / PVS is already implemented - see above.) diff --git a/docs/engine-strength.md b/docs/engine-strength.md index 20c848b..110318d 100644 --- a/docs/engine-strength.md +++ b/docs/engine-strength.md @@ -1,64 +1,86 @@ # Engine Strength -**Estimated rating: ~1800–2000 Elo** (FIDE / CCRL-ish scale) at blitz, most -likely around **1900**, with wide error bars (±150). +**Estimated rating: ~1950–2150 Elo** (FIDE / CCRL-ish scale) at blitz, most +likely around **2050**, with wide error bars (±150). This is a reasoned estimate from the feature set and search speed, **not a measured result** — no games against rated opposition or a reference engine have been run yet. See [Measuring it properly](#measuring-it-properly) below. +This estimate was re-derived after the search rebuild (null-move pruning, LMR, +PVS, mate-distance pruning, correct draw / mate scoring) and the first pass of +hand-crafted evaluation terms landed — see `CHANGELOG.md`. The previous figure +(~1900) predated all of that. + ## How the estimate is derived ### What pulls it up | Component | Contribution | |---|---| -| PeSTO tapered evaluation | The biggest single factor. PeSTO is a genuinely strong minimal eval — a fast engine with *only* PeSTO plays ~2000+. It understands piece activity, king centralization in endgames, and pawn advancement. | -| Alpha-beta + transposition table + iterative deepening + move ordering (TT move, MVV-LVA, killers, history) | A solid, modern search core. Move ordering is good enough that the search tree is near-optimally shaped. | -| Check-aware, depth-bounded quiescence | Won't hang pieces or miss short forcing tactics / mates inside the horizon. | +| PeSTO tapered evaluation | A genuinely strong minimal eval — a fast engine with *only* PeSTO plays ~2000+. Understands piece activity, king centralisation in endgames, pawn advancement. | +| Hand-crafted eval terms on top (passed / isolated / doubled pawns, bishop pair, rook on open file, knight outposts, pawn-shield king safety, tempo) | Fills the biggest PeSTO blind spots. Basic king safety removes a class of "walked into an attack" losses. Untuned, so conservatively worth +30–100 over bare PeSTO. | +| Modern search: alpha-beta + PVS + null-move pruning + LMR + mate-distance pruning, over a Zobrist TT with iterative deepening | The standard reduction stack. Reaches 2–4 plies deeper than plain alpha-beta for the same time — the single biggest strength factor after the eval. | +| Move ordering: TT move, MVV-LVA, promotions, killers, history | Good enough that the tree is near-optimally shaped and the reductions above pay off. | +| Check-aware, depth-bounded quiescence with delta pruning | Won't hang pieces or miss short forcing tactics / mates inside the horizon. | +| Correct mate & draw scoring | Distance-to-mate scores propagate through the TT; threefold / fifty-move draws are seen inside the tree. Removes a class of half-point losses and slow conversions. | +| Lazy SMP over ~9 cores | ~2–3x effective speed-up, so a ply or two deeper again in real games. | | Opening book (Stockfish-derived Polyglot) | Avoids early disasters. Worth roughly +50–150. | ### What holds it down -- **No null-move pruning, LMR, PVS, or search extensions.** Engines with these - search ~2–4 plies deeper for the same time. This alone is worth an estimated - 250–400 Elo and is the main gap between this bot and a "strong hobby engine". -- **Evaluation has no king-safety or pawn-structure terms.** An opponent who - keeps the position closed, avoids tactics, and slowly builds a kingside - attack can exploit this — the bot won't see the attack coming until material - is already falling. -- **Pure Python, ~45–50k nps** — roughly 1000x slower than a compiled engine. - In a blitz middlegame it reaches depth ~6–8 in quiet positions, ~4–6 in - sharp ones. -- Incomplete draw / repetition handling can cost the occasional half-point. +- **Pure Python, ~45–50k nps per core** — roughly 1000x slower than a compiled + engine. With Lazy SMP and the reductions it reaches depth ~8–9 from the + opening in a few seconds ([performance.md](performance.md)); a blitz + middlegame runs ~7–9 plies in quiet positions, ~5–7 in sharp ones. This is + now the dominant ceiling. +- **Eval weights are untuned.** PeSTO's tables plus the new terms have never + been fit to this engine's own games, so some terms may be pulling against + each other. A Texel-style tuning pass is the highest-ceiling item left. +- **No shallow-depth pruning or aspiration windows.** Reverse-futility / + futility / late-move pruning and a narrowed root window would each buy more + depth; SEE would sharpen capture ordering and quiescence. +- **King safety is only a pawn-shield term** — no attacker-count / attack-weight + model, so a slow piece build-up against the king is under-valued until it is + nearly a threat. +- **No search extensions** (check / one-reply / singular), so some tactics are + still missed right at the horizon. +- **Endgame technique is thin** — no tablebases, and a PST-based eval converts + won endings slowly. ## Calibration against known engines - **TSCP 1.81** (C; alpha-beta, quiescence, hash, history, iterative deepening, *no* null-move) is ~1700–1750 CCRL blitz. This bot has a clearly better - evaluation and move ordering, but is much slower and searches no deeper - structurally → roughly a wash, maybe slightly above. -- **Sungorus / CT800 / Claudia** class (null-move + LMR) sit ~2000–2200. This - bot is a notch below them. + search and eval but runs ~20x slower — net clearly ahead, ~2000–2150. +- **Sungorus 1.4** (C; null-move, LMR, PST eval, ~1M nps) is ~2330 CCRL. This + bot has a comparable search stack and a comparable-or-better hand-crafted + eval, but ~20x fewer nps costs ~150–250 → ~2050–2200. +- **CT800 / Claudia** class (~2300–2400) sit above this bot, mostly on speed + and eval tuning. ## Time-control sensitivity | Time control | Estimate | Why | |---|---|---| -| Bullet (1+0) | ~1600–1750 | Python per-move overhead dominates. | -| Blitz (3+2 / 5+3) | ~1850–2000 | Reaches depth 6–8. | -| Rapid / Classical | ~2000–2150 | Reaches depth 9–10+; PeSTO scales well with depth. | -| Lichess bot pool | ~1950–2200 blitz | Bot ratings there tend to run higher than CCRL. | +| Bullet (1+0) | ~1750–1900 | Python per-move and Lazy SMP process-spawn overhead dominate. | +| Blitz (3+2 / 5+3) | ~1950–2150 | Reaches depth 8–10. | +| Rapid / Classical | ~2150–2300 | Reaches depth 11–13+; a PST-based eval scales well with depth. | +| Lichess bot pool | ~2050–2300 blitz | Bot ratings there tend to run higher than CCRL. | ## Where the number would move Completing the [roadmap](tasks.md): -- Null-move pruning + LMR + PVS + aspiration windows: historically **+250–400 Elo**. -- King-safety + pawn-structure evaluation terms: another **+100–200**. +- Aspiration windows + shallow-depth pruning + SEE + extensions: historically + **+80–180 Elo** combined at this level, and they compound with the reductions + already in place. +- Texel-tuned eval weights: **+30–100**, possibly more given how untuned things + are now. +- Deeper king safety, mobility, Syzygy tablebases: another **+50–150**. That would put an engine of this design in the **2300–2500** range, bounded -mainly by nps (a Cython/bitboard rewrite or PyPy would lift the ceiling). +mainly by nps (a Cython / bitboard rewrite or PyPy would lift the ceiling). ## Measuring it properly diff --git a/docs/performance.md b/docs/performance.md index 3c17920..0bee1ee 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,52 +1,59 @@ # Performance -All figures below are a fresh benchmark on one machine (Apple Silicon, 10 -cores, Python 3.13), fixed-depth search from the starting position. "Before" -is the pre-review engine (plain negamax, no move ordering, no working TT, -unbounded quiescence, from-scratch material+PST eval); "After" is the current -engine. Both columns were run on the *same* machine, so this is a like-for-like -comparison rather than a comparison against the older M1 numbers. +Fresh benchmark of the current engine (move ordering, TT, bounded quiescence, +null-move pruning, LMR, PVS, mate-distance pruning, hand-crafted eval terms) on +one machine: Apple Silicon, 10 cores, Python 3.14. Search is from the starting +position; rows stop once a search passes ~5 seconds. "Nodes" is the engine's +node counter (number of `Negamax.search` / `Negamax.quiesce` calls). -For reference, the original M1 run recorded depth 5 = 1.42s, depth 6 = 10.0s, -depth 7 = 215s single-process (and the old "parallel" mode was *slower*: depth -7 = 430s). - -## Single process, fixed-depth `search()` from the start position - -| depth | Before (time / nodes) | After (time / nodes) | speed-up | -|------:|----------------------:|---------------------:|---------:| -| 2 | 0.002s / 87 | 0.002s / 79 | ~1x | -| 3 | 0.015s / 802 | 0.012s / 657 | 1.3x | -| 4 | 0.091s / 3,991 | 0.064s / 2,387 | 1.4x | -| 5 | 1.157s / 46,875 | 0.321s / 15,139 | 3.6x | -| 6 | 8.169s / 317,377 | 1.770s / 72,048 | 4.6x | -| 7 | 152.4s / 5,451,009 | 9.565s / 453,119 | **16x** | - -The gap widens every ply because move ordering (TT move, MVV-LVA, killers, -history) and the transposition table compound. "Nodes" is `board.push` calls -(main search + quiescence). - -## Single-process iterative deepening (removed - kept here for reference) +## What `go` / `go depth N` runs today: Lazy SMP from the start position -| target depth | time | nodes | -|-------------:|-----:|------:| -| 5 | 0.24s | 12k | -| 6 | 1.38s | 53k | -| 7 | 5.11s | 236k | -| 8 | 38.6s | 2.1M | +This is the real search path - `go` always runs Lazy SMP: 9 worker processes +each doing their own iterative deepening against one lock-free shared-memory TT. +A generous `movetime` so the run isn't deadline-capped. -This was the old `go` path (roughly 2x faster than a cold fixed-depth search, -since each iteration seeds the next through the TT). Lazy SMP beat it at every -depth on this machine, so it was dropped - `go` now always runs Lazy SMP. +| depth | time | nodes | +|------:|-----:|------:| +| 6 | 0.45s | 45,000 | +| 7 | 0.60s | 95,000 | +| 8 | 2.30s | 320,000 | -## What `go` / `go depth N` runs today: Lazy SMP from the start position +Depth 8 from the opening lands in ~2s on this 10-core machine, depth 9 in +~5–8s. These numbers vary run to run - the workers race and divide the tree +differently each time, and at shallow depths process startup dominates - so +they are rounded and representative, not exact. -| depth | Before ("parallel", root-split) | After (Lazy SMP, 9 workers, shared TT) | -|------:|--------------------------------:|--------------------------------------:| -| 6 | 10.6s | 0.84s | -| 7 | 430s | 3.77s | -| 8 | — | 17.9s | +## Single process, fixed-depth `search()` from the start position -The old root-split parallel mode lost to its own single-threaded search (full -windows, no shared table). Lazy SMP is ~1.5x faster than the old single-process -iterative deepening at depth 6-7 and ~2x by depth 8, on this 10-core machine. +A controlled baseline: one `Negamax`, a fresh (cold) TT per depth, clock never +armed. Deterministic, and it isolates search-tree efficiency from the Lazy SMP +speed-up, so this is the table to watch when judging whether a search change +helped. Not what the engine runs in a game. + +The `perft(depth)` column is the size of the *full* legal game tree at that +depth from the start position (the published +[perft results](https://www.chessprogramming.org/Perft_Results) - leaf count at +exactly `depth` plies, no evaluation, no pruning). It is 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.003s | 182 | 400 | 55% | +| 3 | 0.008s | 463 | 8,902 | 95% | +| 4 | 0.034s | 1,840 | 197,281 | 99.1% | +| 5 | 0.132s | 6,943 | 4,865,609 | 99.86% | +| 6 | 0.344s | 17,728 | 119,060,324 | 99.985% | +| 7 | 1.462s | 67,810 | 3,195,901,860 | 99.998% | +| 8 | 3.690s | 178,647 | 84,998,978,956 | 99.9998% | + +The `pruned` column is an *estimate*: the two counts aren't the same unit. +"nodes" counts every node the search visits in the pruned tree (internal nodes +and quiescence included, and quiescence looks past `depth` in forcing lines); +`perft` is only the leaves at exactly `depth`. So it overstates pruning a little +at shallow depths and understates it once quiescence dominates - but the trend +is real: +move ordering, the TT, and the reductions (null-move, LMR, PVS) take the tree +from "search half of it" to "search one node in ~475,000" by depth 8. + +Consecutive `nodes` rows zigzag rather than growing smoothly: PVS makes odd +plies (side-to-move gets the last word) cheap relative to even ones. diff --git a/docs/self-play.gif b/docs/self-play.gif index b937dee..9ee5520 100644 Binary files a/docs/self-play.gif and b/docs/self-play.gif differ diff --git a/docs/tasks.md b/docs/tasks.md index d9ade89..98af6fa 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1,83 +1,85 @@ # Tasks -The next five changes, in the order most likely to gain the most playing -strength. Each is a self-contained addition to `Negamax.search` / -`Negamax.quiesce` unless noted. - -## 1. Null-move pruning - -If giving the opponent a free move (a "null move") still fails high at reduced -depth (`R = 2..3`), the position is almost certainly a cut-off - return `beta` -without searching the real moves. Skip it when side-to-move is in check, in a -likely zugzwang (king + pawns only), or when depth is very low. Add a -verification search at high depth to avoid zugzwang blunders. Needs a -`board.push(chess.Move.null())` path and a `_null_ok` guard in the search. -Typically the single largest Elo jump available (~50-100). - -## 2. Principal Variation Search (NegaScout) - -Search the first (best-ordered) move with the full `(-beta, -alpha)` window, -then every later move with a null window `(-alpha-1, -alpha)`; only re-search -with the full window on the rare fail-high. With the move ordering already in -place (TT move, MVV-LVA, killers, history) the first move is usually best, so -most nodes get the cheaper scout search. ~20-40 Elo and it compounds with -everything below. - -## 3. Late Move Reductions (LMR) - -Once past the first few moves at a node, search quiet, non-checking, non-TT -moves at `depth - 1 - reduction` (reduction grows with move index and depth, -shrink it for killers / good history). Re-search at full depth if the reduced -search beats `alpha`. Combined with PVS this is usually the biggest tree -reduction after null-move - effective branching factor drops sharply, so -iterative deepening reaches 2-4 plies deeper in the same time. - -## 4. Correct draw, repetition and mate scoring - -- Detect threefold repetition and the 50-move rule *inside* the search tree - (`board.is_repetition(3)`, `board.halfmove_clock`), not just the automatic - five-fold / seventy-five-move draws in `is_drawn`. -- Score every draw a flat `0` (currently negamax returns `0 - depth`), with an - optional small contempt value. -- Store mate scores in the TT as distance-to-mate relative to the current ply - (`score +/- ply` on store/probe) so `MATE_GUARD` can be removed and mate - cut-offs actually propagate. This fixes real half-point losses and lets the - engine convert forced mates faster. - -## 5. Evaluation upgrade - -PeSTO piece-square tables capture a lot of positional understanding but miss -king safety and pawn structure, which is where a mid-level engine gains most. -Add, keeping the incremental accumulator where possible and a pawn-hash cache -for the rest: - -- King safety: attacker count / attack weight on the squares around the king, - pawn-shield intactness, open files next to the king. -- Passed pawns (bonus scaled by rank and king distance), isolated / doubled / - backward pawns. -- Bishop pair, rook on open / half-open file, knight outposts. -- Tempo bonus. - -Then wire in [Syzygy endgame tablebases](https://python-chess.readthedocs.io/en/latest/syzygy.html) -(`chess.syzygy`) for perfect play with <= 6 pieces. - ---- - -## Recently completed - -From the previous review passes: move ordering, transposition table, iterative -deepening + UCI time management, perft fix + tests, quiescence rewrite (bounded, -check-aware, fail-soft), tapered PeSTO evaluation with an incremental -accumulator, standard `info` output, resource-leak fixes, Lazy SMP with a -lock-free shared-memory TT (now the only search path - `go` always runs it), and -a rebuild of the search as composed collaborators (`Negamax` holds an evaluator -/ move orderer / TT / clock) instead of a mixin stack, with unit tests for each -piece. - -Packaging & tooling: `src/pychess/` package layout, `pyproject.toml` (replacing -`requirements.txt` / `pytest.ini`), a `pychess` console entry point, Apache-2.0 -license, `py.typed`, and Ruff (lint + format) + Mypy + pytest-cov (85% gate) in -CI, with `pre-commit` mirroring it locally. +The next five changes, roughly in priority order (Elo per unit of effort, and +how much each compounds with the rest). Each is a self-contained addition to +`Negamax` / `lazy_smp` / the evaluator unless noted. Elo figures are rough +community numbers for an engine already at this level - measure, don't trust. + +## 1. Aspiration windows + +Iterative deepening currently re-searches every depth with the full +`(-INF, INF)` window. Instead, open depth `d` with a narrow window around the +previous iteration's score (`score ± 25`, say); on a fail-high or fail-low +widen that side (double the delta, or jump to `±INF`) and re-search. Most +iterations land inside the window and search far fewer nodes. Lives in +`lazy_smp._worker`'s deepening loop, per worker. Pairs naturally with PVS. +~15-30 Elo, a small change. + +## 2. Shallow-depth pruning (reverse futility, futility, late move pruning) + +At low depth, near the leaves, prune aggressively on the static eval: + +- **Reverse futility / static null move**: if `eval - margin*depth >= beta` at + `depth <= ~6` and not in check, return `eval`. +- **Futility**: at `depth <= ~2`, if `eval + margin < alpha`, skip quiet moves + that can't raise alpha. +- **Late move pruning**: past a depth-dependent move count at low depth, skip + the remaining quiet non-checking moves entirely. + +Never when in check, for captures/promotions, or near mate scores. Big node +reduction; the effective branching factor drops again. ~40-70 Elo combined, but +tune the margins carefully - too greedy and tactics start getting missed +(guard with the existing mate tests plus a WAC/ECM tactical suite). + +## 3. Static Exchange Evaluation (SEE) + +A `see(board, move) -> int` that plays out the capture sequence on one square +with the cheapest attacker each time. Two uses: + +- **Move ordering**: order captures with `SEE < 0` *after* the quiet killers + instead of just behind winning captures - `MoveOrderer` currently trusts + MVV-LVA, which mis-ranks a queen grabbing a defended pawn. +- **Quiescence pruning**: in `Negamax.quiesce`, skip captures with `SEE < 0` + entirely instead of searching every capture. + +python-chess gives `board.attackers(color, square)` to build the attacker +lists. ~25-50 Elo, and it makes quiescence much cheaper. + +## 4. Search extensions + +Spend an extra ply where the tree is forcing so tactics are not missed at the +horizon: + +- **Check extension**: `depth += 1` when the move gives check (cap total + extensions per line so it can't blow up). +- **One-reply extension**: extend when the side to move has a single legal + move. +- Later: **singular extensions** (re-search to prove the TT move is the only + good one) - higher effort, do it after PVS/LMR are stable. + +~15-25 Elo for the cheap two; more with singular. + +## 5. Evaluation tuning harness (Texel's method) + +The PeSTO tables and the new `eval_terms` weights are untuned for this engine. +Add `tools/tune.py`: label a few hundred thousand quiet positions with the game +result (from self-play PGNs or a public dataset), then fit every weight by +minimising the logistic error between `sigmoid(eval)` and the result. Keep the +weights in one place so the tuner can rewrite them. Unlocks the eval terms +already added and makes every future term measurable. ~30-100 Elo depending on +how untuned things currently are; the highest-ceiling item on this list. + +### Also worth doing + +- **Syzygy tablebases** (`chess.syzygy`): probe WDL/DTZ for <= 6 pieces at the + root and in the search for perfect endgame play. Needs a `SyzygyPath` and the + tablebase files (~150 GB for 6-man), so gate it on config and no-op when the + files are absent. +- **UCI `setoption`**: `Hash`, `Threads`, `Contempt`, `SyzygyPath`, + `MultiPV` - currently parsed and ignored. +- **2-fold repetition as a draw inside the search** (not just 3-fold): a + position seen twice within the tree is almost always a forced draw; detecting + it a repetition earlier saves nodes. ## Housekeeping @@ -85,7 +87,6 @@ CI, with `pre-commit` mirroring it locally. - [ ] Rename the repo `pyChess` -> `pychess` so it matches the package name; update the local remote afterwards. -- [ ] Tag `v0.1.0` and cut a **GitHub Release** from the `CHANGELOG.md` entry. ### Opening book (`opening_book/bookfish.bin`) diff --git a/src/pychess/__main__.py b/src/pychess/__main__.py index ec2713d..3a14d73 100644 --- a/src/pychess/__main__.py +++ b/src/pychess/__main__.py @@ -10,6 +10,7 @@ from chess import polyglot from . import __version__ +from .constants import MATE, MATE_IN_MAX from .engine import Engine, SupportsSearch from .eval_board import EvalBoard from .lazy_smp import SearchResult @@ -120,11 +121,21 @@ def book_move(self) -> chess.Move | None: except IndexError, FileNotFoundError, OSError: return None + @staticmethod + def _score_field(score: int) -> str: + """UCI ``score`` token: ``mate N`` (full moves, signed) near mate, + ``cp N`` otherwise. Mate scores are distance-to-mate from the root.""" + if abs(score) < MATE_IN_MAX: + return f"score cp {score}" + plies = MATE - abs(score) + moves = (plies + 1) // 2 + return f"score mate {moves if score > 0 else -moves}" + def print_info(self, result: SearchResult) -> None: """Format one UCI ``info`` line from a SearchResult.""" pv = " ".join(m.uci() for m in result.pv) print( - f"info depth {result.depth} score cp {result.score} " + f"info depth {result.depth} {self._score_field(result.score)} " f"nodes {result.nodes} time {round(result.elapsed, 3)} pv {pv}" ) diff --git a/src/pychess/constants.py b/src/pychess/constants.py index 6a6bac2..0b17792 100644 --- a/src/pychess/constants.py +++ b/src/pychess/constants.py @@ -1,20 +1,47 @@ """Score conventions and transposition-table flags shared across the search. -Mate is scored by *remaining depth* (``-MATE - depth``), not distance-to-root, -so mate scores are not safe to reuse as bounds across different depths: the -transposition table keeps the stored move for ordering but never returns a -value with ``abs(score) >= MATE_GUARD`` as a cut-off. +Mate is scored as *distance from the root*: a forced mate delivered ``m`` plies +from the root scores ``MATE - m`` for the mating side (so a faster mate always +outranks a slower one). ``abs(score) >= MATE_IN_MAX`` marks a mate score. + +Because that distance is measured from the root, a mate score is only meaningful +at the ply it was found. Before a score goes into the transposition table it is +rebased to be relative to the storing node (:func:`tt_store_score`); on the way +out it is rebased back to the root (:func:`tt_probe_score`). With that in place +the TT can return mate bounds like any other score. ``INF`` is the alpha-beta window sentinel; every real evaluation sits well inside ``+/- MATE``. """ INF = 99999 # alpha-beta window sentinel -MATE = 9999 # abs(score) at or above this is mate-related -MATE_IN_MAX = 9899 # MATE - 100: mate within ~100 plies (worker win/loss flag) -MATE_GUARD = 9000 # the TT never returns abs(score) >= this as a bound +MATE = 9999 # abs(score) at or above MATE_IN_MAX is mate-related +MATE_IN_MAX = 9899 # MATE - 100: abs(score) >= this is a mate (max tree ply << 100) + +CONTEMPT = 0 # a draw scores -CONTEMPT for the side to move; > 0 == play on # Transposition-table entry bounds. TT_EXACT = 0 TT_LOWER = 1 # value is a lower bound (fail-high / beta cut-off) TT_UPPER = 2 # value is an upper bound (fail-low / all moves searched) + + +def tt_store_score(value: int, ply: int) -> int: + """Rebase a root-relative score to the storing node at ``ply``. + + Only mate scores move; everything else is returned unchanged. + """ + if value >= MATE_IN_MAX: + return value + ply + if value <= -MATE_IN_MAX: + return value - ply + return value + + +def tt_probe_score(value: int, ply: int) -> int: + """Inverse of :func:`tt_store_score`: rebase a stored score back to the root.""" + if value >= MATE_IN_MAX: + return value - ply + if value <= -MATE_IN_MAX: + return value + ply + return value diff --git a/src/pychess/eval_terms.py b/src/pychess/eval_terms.py new file mode 100644 index 0000000..1c3d9ed --- /dev/null +++ b/src/pychess/eval_terms.py @@ -0,0 +1,175 @@ +"""Positional evaluation terms layered on top of the PeSTO piece-square tables. + +``positional(board)`` returns a ``(mg, eg)`` pair from White's point of view, to +be added to the tapered PeSTO score before the side-to-move flip. Everything +here is recomputed from scratch per call; the pure-pawn terms (passed / +isolated / doubled) are the expensive part and are memoised by +``PestoEvaluator`` on the pawn bitboards. + +Weights are deliberately modest - a mistuned term should nudge, not dominate. +""" + +from __future__ import annotations + +import chess + +# --- weights (centipawns) ------------------------------------------------ + +PASSED_MG = (0, 5, 10, 20, 35, 60, 100, 0) # by the pawn's own rank (White view) +PASSED_EG = (0, 15, 25, 40, 65, 105, 160, 0) +ISOLATED_MG, ISOLATED_EG = 12, 8 +DOUBLED_MG, DOUBLED_EG = 8, 18 + +BISHOP_PAIR_MG, BISHOP_PAIR_EG = 22, 42 +ROOK_OPEN_FILE, ROOK_HALF_OPEN_FILE = 24, 11 +KNIGHT_OUTPOST = 20 +TEMPO = 12 + +SHIELD_MISSING = 12 # a file next to the king with no friendly pawn +KING_OPEN_FILE = 14 # ... and no pawn of either colour on it + +# --- precomputed masks ------------------------------------------------- + +_ADJACENT_FILES = tuple( + (chess.BB_FILES[f - 1] if f > 0 else 0) | (chess.BB_FILES[f + 1] if f < 7 else 0) + for f in range(8) +) + + +def _ahead(rank: int, *, white: bool) -> int: + ranks = range(rank + 1, 8) if white else range(rank) + mask = 0 + for r in ranks: + mask |= chess.BB_RANKS[r] + return mask + + +_WHITE_PASSED_BLOCK = [] # enemy pawns here stop a White passer +_BLACK_PASSED_BLOCK = [] +_WHITE_FILE_AHEAD = [] # own file, strictly ahead (doubled / passer-in-front) +_BLACK_FILE_AHEAD = [] +_WHITE_OUTPOST_BLOCK = [] # enemy pawns here can chase a White knight off +_BLACK_OUTPOST_BLOCK = [] +for _sq in range(64): + _f, _r = chess.square_file(_sq), chess.square_rank(_sq) + _own_adj = chess.BB_FILES[_f] | _ADJACENT_FILES[_f] + _WHITE_PASSED_BLOCK.append(_own_adj & _ahead(_r, white=True)) + _BLACK_PASSED_BLOCK.append(_own_adj & _ahead(_r, white=False)) + _WHITE_FILE_AHEAD.append(chess.BB_FILES[_f] & _ahead(_r, white=True)) + _BLACK_FILE_AHEAD.append(chess.BB_FILES[_f] & _ahead(_r, white=False)) + _WHITE_OUTPOST_BLOCK.append(_ADJACENT_FILES[_f] & _ahead(_r, white=True)) + _BLACK_OUTPOST_BLOCK.append(_ADJACENT_FILES[_f] & _ahead(_r, white=False)) + +_OUTPOST_RANKS_WHITE = chess.BB_RANK_4 | chess.BB_RANK_5 | chess.BB_RANK_6 +_OUTPOST_RANKS_BLACK = chess.BB_RANK_5 | chess.BB_RANK_4 | chess.BB_RANK_3 + + +# --- pawn structure (memoisable: depends only on pawn placement) -------- + + +def pawn_structure(white_pawns: int, black_pawns: int) -> tuple[int, int]: + """``(mg, eg)`` White-POV score for passed / isolated / doubled pawns.""" + mg = eg = 0 + + for sq in chess.scan_forward(white_pawns): + f = chess.square_file(sq) + if not (white_pawns & _WHITE_FILE_AHEAD[sq]): + if not (black_pawns & _WHITE_PASSED_BLOCK[sq]): + r = chess.square_rank(sq) + mg += PASSED_MG[r] + eg += PASSED_EG[r] + else: + mg -= DOUBLED_MG + eg -= DOUBLED_EG + if not (white_pawns & _ADJACENT_FILES[f]): + mg -= ISOLATED_MG + eg -= ISOLATED_EG + + for sq in chess.scan_forward(black_pawns): + f = chess.square_file(sq) + if not (black_pawns & _BLACK_FILE_AHEAD[sq]): + if not (white_pawns & _BLACK_PASSED_BLOCK[sq]): + r = 7 - chess.square_rank(sq) + mg -= PASSED_MG[r] + eg -= PASSED_EG[r] + else: + mg += DOUBLED_MG + eg += DOUBLED_EG + if not (black_pawns & _ADJACENT_FILES[f]): + mg += ISOLATED_MG + eg += ISOLATED_EG + + return mg, eg + + +# --- everything else (piece placement relative to pawns / king) -------- + + +def _king_safety_mg(board: chess.Board, color: chess.Color, all_pawns: int, own_pawns: int) -> int: + king = board.king(color) + if king is None: # pragma: no cover - a legal board always has both kings + return 0 + kf = chess.square_file(king) + penalty = 0 + for f in range(max(0, kf - 1), min(7, kf + 1) + 1): + file_bb = chess.BB_FILES[f] + if not (own_pawns & file_bb): + penalty += SHIELD_MISSING + if not (all_pawns & file_bb): + penalty += KING_OPEN_FILE + return -penalty + + +def positional(board: chess.Board, pawn_mg: int, pawn_eg: int) -> tuple[int, int]: + """``(mg, eg)`` White-POV positional score. ``pawn_mg`` / ``pawn_eg`` are the + (possibly cached) :func:`pawn_structure` result for this position.""" + white = board.occupied_co[chess.WHITE] + black = board.occupied_co[chess.BLACK] + white_pawns = board.pawns & white + black_pawns = board.pawns & black + all_pawns = board.pawns + + mg, eg = pawn_mg, pawn_eg + + # Bishop pair. + if chess.popcount(board.bishops & white) >= 2: + mg += BISHOP_PAIR_MG + eg += BISHOP_PAIR_EG + if chess.popcount(board.bishops & black) >= 2: + mg -= BISHOP_PAIR_MG + eg -= BISHOP_PAIR_EG + + # Rooks on open / half-open files. + for sq in chess.scan_forward(board.rooks & white): + file_bb = chess.BB_FILES[chess.square_file(sq)] + if not (all_pawns & file_bb): + mg += ROOK_OPEN_FILE + elif not (white_pawns & file_bb): + mg += ROOK_HALF_OPEN_FILE + for sq in chess.scan_forward(board.rooks & black): + file_bb = chess.BB_FILES[chess.square_file(sq)] + if not (all_pawns & file_bb): + mg -= ROOK_OPEN_FILE + elif not (black_pawns & file_bb): + mg -= ROOK_HALF_OPEN_FILE + + # Knight outposts: on an advanced square, pawn-defended, unchallengeable by + # an enemy pawn. + for sq in chess.scan_forward(board.knights & white & _OUTPOST_RANKS_WHITE): + if (white_pawns & chess.BB_PAWN_ATTACKS[chess.BLACK][sq]) and not ( + black_pawns & _WHITE_OUTPOST_BLOCK[sq] + ): + mg += KNIGHT_OUTPOST + eg += KNIGHT_OUTPOST // 2 + for sq in chess.scan_forward(board.knights & black & _OUTPOST_RANKS_BLACK): + if (black_pawns & chess.BB_PAWN_ATTACKS[chess.WHITE][sq]) and not ( + white_pawns & _BLACK_OUTPOST_BLOCK[sq] + ): + mg -= KNIGHT_OUTPOST + eg -= KNIGHT_OUTPOST // 2 + + # King safety (mid-game only; tapers out through ``taper``). + mg += _king_safety_mg(board, chess.WHITE, all_pawns, white_pawns) + mg -= _king_safety_mg(board, chess.BLACK, all_pawns, black_pawns) + + return mg, eg diff --git a/src/pychess/evaluation.py b/src/pychess/evaluation.py index bea168f..67c3c34 100644 --- a/src/pychess/evaluation.py +++ b/src/pychess/evaluation.py @@ -2,6 +2,8 @@ import chess +from . import eval_terms + if TYPE_CHECKING: from .eval_board import EvalBoard @@ -180,17 +182,42 @@ def taper(mg: int, eg: int, phase: int) -> int: class PestoEvaluator: - """Tapered PeSTO evaluation, returned from the side-to-move's perspective. + """Tapered PeSTO evaluation plus positional terms, from the side-to-move's + perspective. - Uses the incrementally-maintained accumulator on ``EvalBoard`` when the - board provides one, otherwise falls back to a full recompute. + The PeSTO piece-square score uses the incrementally-maintained accumulator + on ``EvalBoard`` when the board provides one, otherwise a full recompute. + The positional terms (``eval_terms``) are recomputed each call; the + pure-pawn part is memoised on the pawn bitboards. """ + _PAWN_CACHE_CAP = 1 << 16 + + def __init__(self) -> None: + self._pawn_cache: dict[tuple[int, int], tuple[int, int]] = {} + + def _pawn_structure(self, white_pawns: int, black_pawns: int) -> tuple[int, int]: + cached = self._pawn_cache.get((white_pawns, black_pawns)) + if cached is not None: + return cached + terms = eval_terms.pawn_structure(white_pawns, black_pawns) + if len(self._pawn_cache) >= self._PAWN_CACHE_CAP: + self._pawn_cache.clear() + self._pawn_cache[(white_pawns, black_pawns)] = terms + return terms + def evaluate(self, board: chess.Board) -> int: if getattr(board, "_mg", None) is None: mg, eg, phase = pesto_terms(board) else: acc = cast("EvalBoard", board) mg, eg, phase = acc._mg, acc._eg, acc._phase - score = taper(mg, eg, phase) + + white_pawns = board.pawns & board.occupied_co[chess.WHITE] + black_pawns = board.pawns & board.occupied_co[chess.BLACK] + pawn_mg, pawn_eg = self._pawn_structure(white_pawns, black_pawns) + pos_mg, pos_eg = eval_terms.positional(board, pawn_mg, pawn_eg) + + score = taper(mg + pos_mg, eg + pos_eg, phase) + score += eval_terms.TEMPO if board.turn else -eval_terms.TEMPO return score if board.turn else -score diff --git a/src/pychess/move_ordering.py b/src/pychess/move_ordering.py index e6929a8..bcc25a3 100644 --- a/src/pychess/move_ordering.py +++ b/src/pychess/move_ordering.py @@ -65,6 +65,14 @@ def mvvlva(self, board: chess.Board, move: chess.Move) -> int: assert attacker is not None # the mover always sits on from_square return PIECE_VALUES[victim] * 10 - PIECE_VALUES[attacker] // 100 + def is_killer(self, ply: int, move: chess.Move) -> bool: + """Whether ``move`` is a recorded killer for this ply.""" + return move in self.killers.get(ply, ()) + + def history_score(self, board: chess.Board, move: chess.Move) -> int: + """The history-heuristic score for ``move`` (0 if never seen).""" + return self.history.get((board.turn, move.from_square, move.to_square), 0) + # ---- cut-off bookkeeping ------------------------------------------ def record_killer(self, ply: int, move: chess.Move) -> None: diff --git a/src/pychess/negamax.py b/src/pychess/negamax.py index 602794c..51ce595 100644 --- a/src/pychess/negamax.py +++ b/src/pychess/negamax.py @@ -4,7 +4,7 @@ import chess -from .constants import INF, MATE, TT_EXACT, TT_LOWER, TT_UPPER +from .constants import CONTEMPT, INF, MATE, MATE_IN_MAX, TT_EXACT, TT_LOWER, TT_UPPER if TYPE_CHECKING: from .clock import Clock @@ -18,6 +18,17 @@ # Delta-pruning margin: a bit more than a queen. Q_DELTA_MARGIN = 1100 +# Null-move pruning: give the opponent a free move at reduced depth; if the +# position still fails high it is almost certainly a cut-off. +NULL_MIN_DEPTH = 3 # don't bother below this - the reduced search is too shallow +NULL_DEEP_DEPTH = 6 # above this the reduction grows from 2 to 3 +NULL_VERIFY_DEPTH = 10 # at/above this, confirm the cut with a real reduced search + +# Late move reductions: quiet, late, non-checking moves are searched shallower +# first; a result above alpha triggers a full-depth re-search. +LMR_MIN_DEPTH = 3 # nodes shallower than this search every move at full depth +LMR_FULL_MOVES = 3 # the first N moves at a node always get a full-depth search + class SearchAbortError(Exception): """Raised inside the search when the clock says stop; the caller discards @@ -25,6 +36,7 @@ class SearchAbortError(Exception): def is_drawn(board: chess.Board) -> bool: + """Terminal draws - the game is over by rule, no claim needed.""" return ( board.is_fivefold_repetition() or board.is_stalemate() @@ -33,6 +45,13 @@ def is_drawn(board: chess.Board) -> bool: ) +def claims_draw(board: chess.Board) -> bool: + """Claimable draws - threefold repetition and the fifty-move rule. Not + ``is_game_over`` in python-chess, but the search must still score them 0: + inside the tree either side can force the claim.""" + return board.halfmove_clock >= 100 or board.is_repetition(3) + + class Negamax: """Fail-soft negamax with a quiescence leaf, over injected collaborators. @@ -58,8 +77,48 @@ def __init__( self.clock = clock self.nodes = 0 + @staticmethod + def _draw_score() -> int: + """A draw, from the side-to-move's point of view. ``CONTEMPT`` > 0 makes + the engine treat a draw as slightly bad for itself and play on.""" + return -CONTEMPT + + @staticmethod + def _null_ok(board: chess.Board, depth: int, can_null: bool, ply: int) -> bool: + """Whether a null move is safe to try at this node. + + Skip it at the root, when it was already tried on the way here, at very + low depth, while in check, and in likely zugzwang - when the side to + move has only king and pawns a free move is worth more than any real + one, so the null search lies. + """ + if not can_null or ply == 0 or depth < NULL_MIN_DEPTH or board.is_check(): + return False + us = board.occupied_co[board.turn] + return bool((board.knights | board.bishops | board.rooks | board.queens) & us) + + @staticmethod + def _lmr_reduction(move_index: int, depth: int, *, favoured: bool) -> int: + """Plies to shave off a late quiet move's first search. + + Grows with how late the move is ordered and how deep the node; + ``favoured`` - a killer or a move with positive history - shrinks it by + one. Clamped so the reduced search keeps at least one ply. + """ + r = 1 + (move_index >= 6) + (depth >= 8) + if favoured: + r -= 1 + return max(0, min(r, depth - 2)) + def search( - self, board: chess.Board, alpha: int, beta: int, depth: int, ply: int = 0 + self, + board: chess.Board, + alpha: int, + beta: int, + depth: int, + ply: int = 0, + *, + can_null: bool = True, ) -> tuple[int, list[chess.Move]]: self.nodes += 1 if ply and not (self.nodes & 4095) and self.clock.should_stop(self.nodes): @@ -67,29 +126,106 @@ def search( if depth <= 0 or board.is_game_over(): if board.is_checkmate(): - return -MATE - depth, [] + return -(MATE - ply), [] if is_drawn(board): - return 0, [] - return self.quiesce(board, alpha, beta, 0), [] + return self._draw_score(), [] + return self.quiesce(board, alpha, beta, 0, ply), [] + + if ply and claims_draw(board): + return self._draw_score(), [] + + if ply: + # Mate-distance pruning: this node can do no better than mating now + # and no worse than being mated now, so clamp the window and bail + # if it collapses - the search then never chases a slower mate. + alpha = max(alpha, -(MATE - ply)) + beta = min(beta, MATE - ply) + if alpha >= beta: + return alpha, [] alpha_orig = alpha key = self.tt.key(board) - tt_cutoff, tt_value, tt_move = self.tt.probe(key, depth, alpha, beta) + tt_cutoff, tt_value, tt_move = self.tt.probe(key, depth, alpha, beta, ply) if tt_cutoff and ply > 0: return tt_value, [tt_move] if tt_move else [] + # --- Null-move pruning ------------------------------------------------ + if ( + self._null_ok(board, depth, can_null, ply) + and abs(beta) < MATE_IN_MAX + and self.evaluator.evaluate(board) >= beta + ): + r = 2 + (depth > NULL_DEEP_DEPTH) + board.push(chess.Move.null()) + try: + null_score, _ = self.search( + board, -beta, -beta + 1, depth - 1 - r, ply + 1, can_null=False + ) + except SearchAbortError: + board.pop() + raise + null_score = -null_score + board.pop() + + if null_score >= beta: + if depth < NULL_VERIFY_DEPTH: + return beta, [] + # Verification search: a real (non-null) reduced search from the + # same position guards against zugzwang, where the null result + # is a mirage. + verify, _ = self.search(board, beta - 1, beta, depth - r, ply, can_null=False) + if verify >= beta: + return beta, [] + + in_check = board.is_check() + best_score = -INF best_move = None pv = [] - for move in self.orderer.order_moves(board, tt_move=tt_move, ply=ply): + for move_index, move in enumerate( + self.orderer.order_moves(board, tt_move=tt_move, ply=ply) + ): + is_capture = board.is_capture(move) + + reduction = 0 + if ( + depth >= LMR_MIN_DEPTH + and move_index >= LMR_FULL_MOVES + and not in_check + and not is_capture + and not move.promotion + and move != tt_move + and not board.gives_check(move) + ): + favoured = self.orderer.is_killer(ply, move) or ( + self.orderer.history_score(board, move) > 0 + ) + reduction = self._lmr_reduction(move_index, depth, favoured=favoured) + board.push(move) try: - child_score, child_pv = self.search(board, -beta, -alpha, depth - 1, ply + 1) + if move_index == 0: + # The (well-ordered) first move gets the full window. + child_score, child_pv = self.search(board, -beta, -alpha, depth - 1, ply + 1) + child_score = -child_score + else: + # Later moves: a null-window scout, reduced if LMR applies. + # It only asks "is this move better than alpha?". + child_score, child_pv = self.search( + board, -alpha - 1, -alpha, depth - 1 - reduction, ply + 1 + ) + child_score = -child_score + # Scout failed high: re-search at full depth and, unless the + # cut-off is already certain, the full window. + if child_score > alpha and (child_score < beta or reduction): + child_score, child_pv = self.search( + board, -beta, -alpha, depth - 1, ply + 1 + ) + child_score = -child_score except SearchAbortError: board.pop() raise - child_score = -child_score board.pop() if child_score > best_score: @@ -101,7 +237,7 @@ def search( alpha = best_score if alpha >= beta: - if not board.is_capture(move): + if not is_capture: self.orderer.record_killer(ply, move) self.orderer.record_history(board, move, depth) break @@ -112,11 +248,11 @@ def search( flag = TT_LOWER else: flag = TT_EXACT - self.tt.store(key, depth, best_score, flag, best_move) + self.tt.store(key, depth, best_score, flag, best_move, ply) return best_score, pv - def quiesce(self, board: chess.Board, alpha: int, beta: int, qply: int) -> int: + def quiesce(self, board: chess.Board, alpha: int, beta: int, qply: int, ply: int = 0) -> int: """Fail-soft quiescence search. - Depth-bounded: capture chains are cut off after ``Q_MAX_DEPTH`` plies. @@ -125,15 +261,18 @@ def quiesce(self, board: chess.Board, alpha: int, beta: int, qply: int) -> int: check. - Non-check nodes search captures and promotions only, MVV-LVA ordered, with stand-pat and delta pruning. + + ``ply`` is the distance from the search root, used only to score mates + relative to the root like ``search`` does. """ self.nodes += 1 if not (self.nodes & 4095) and self.clock.should_stop(self.nodes): raise SearchAbortError if board.is_checkmate(): - return qply - MATE - if is_drawn(board) or board.is_repetition(3): - return 0 + return -(MATE - ply - qply) + if is_drawn(board) or claims_draw(board): + return self._draw_score() if board.is_check(): best = -INF @@ -154,7 +293,7 @@ def quiesce(self, board: chess.Board, alpha: int, beta: int, qply: int) -> int: for move in moves: board.push(move) try: - score = -self.quiesce(board, -beta, -alpha, qply + 1) + score = -self.quiesce(board, -beta, -alpha, qply + 1, ply) except SearchAbortError: board.pop() raise diff --git a/src/pychess/shared_tt.py b/src/pychess/shared_tt.py index 4c5d8a9..464d767 100644 --- a/src/pychess/shared_tt.py +++ b/src/pychess/shared_tt.py @@ -18,7 +18,7 @@ import chess from chess.polyglot import zobrist_hash -from .constants import MATE_GUARD, TT_EXACT, TT_LOWER, TT_UPPER +from .constants import TT_EXACT, TT_LOWER, TT_UPPER, tt_probe_score, tt_store_score ENTRY_SIZE = 16 _ENTRY = struct.Struct(" int: return zobrist_hash(board) def probe( - self, key: int, depth: int, alpha: int, beta: int + self, key: int, depth: int, alpha: int, beta: int, ply: int = 0 ) -> tuple[bool, int, chess.Move | None]: off = (key & self.mask) * ENTRY_SIZE word0, data = _ENTRY.unpack_from(_buffer(self.shm), off) if data == 0 or (word0 ^ data) != key: return False, 0, None - move, score, e_depth, flag = _unpack_data(data) - if e_depth >= depth and abs(score) < MATE_GUARD: + move, e_score, e_depth, flag = _unpack_data(data) + if e_depth >= depth: + score = tt_probe_score(e_score, ply) if flag == TT_EXACT: return True, score, move if flag == TT_LOWER and score >= beta: @@ -95,12 +96,14 @@ def probe( return True, score, move return False, 0, move - def store(self, key: int, depth: int, score: int, flag: int, move: chess.Move | None) -> None: + def store( + self, key: int, depth: int, score: int, flag: int, move: chess.Move | None, ply: int = 0 + ) -> None: off = (key & self.mask) * ENTRY_SIZE word0, old = _ENTRY.unpack_from(_buffer(self.shm), off) if old and (word0 ^ old) == key and _unpack_data(old)[2] > depth: return # keep the deeper entry for this position - data = _pack_data(move, score, depth, flag) + data = _pack_data(move, tt_store_score(score, ply), depth, flag) _ENTRY.pack_into(_buffer(self.shm), off, (key ^ data) & _U64, data) def close(self) -> None: diff --git a/src/pychess/transposition.py b/src/pychess/transposition.py index 3b8cf6c..f0deb58 100644 --- a/src/pychess/transposition.py +++ b/src/pychess/transposition.py @@ -1,7 +1,7 @@ import chess from chess.polyglot import zobrist_hash -from .constants import MATE_GUARD, TT_EXACT, TT_LOWER, TT_UPPER +from .constants import TT_EXACT, TT_LOWER, TT_UPPER, tt_probe_score, tt_store_score # (depth, value, flag, move) _Entry = tuple[int, int, int, "chess.Move | None"] @@ -14,6 +14,9 @@ class TranspositionTable: table used for single-threaded search and tests; Lazy SMP uses ``shared_tt.SharedTT``, which exposes the same ``key`` / ``probe`` / ``store`` interface over shared memory. + + ``probe`` / ``store`` take the current ``ply`` so mate scores can be + rebased between the root and the storing node (see ``constants``). """ def __init__(self) -> None: @@ -26,7 +29,7 @@ def key(self, board: chess.Board) -> int: return zobrist_hash(board) def probe( - self, key: int, depth: int, alpha: int, beta: int + self, key: int, depth: int, alpha: int, beta: int, ply: int = 0 ) -> tuple[bool, int, chess.Move | None]: """Return ``(cutoff, value, move)``. @@ -39,17 +42,26 @@ def probe( return False, 0, None e_depth, e_value, e_flag, e_move = entry - if e_depth >= depth and abs(e_value) < MATE_GUARD: + if e_depth >= depth: + value = tt_probe_score(e_value, ply) if e_flag == TT_EXACT: - return True, e_value, e_move - if e_flag == TT_LOWER and e_value >= beta: - return True, e_value, e_move - if e_flag == TT_UPPER and e_value <= alpha: - return True, e_value, e_move + return True, value, e_move + if e_flag == TT_LOWER and value >= beta: + return True, value, e_move + if e_flag == TT_UPPER and value <= alpha: + return True, value, e_move return False, 0, e_move - def store(self, key: int, depth: int, value: int, flag: int, move: chess.Move | None) -> None: + def store( + self, + key: int, + depth: int, + value: int, + flag: int, + move: chess.Move | None, + ply: int = 0, + ) -> None: existing = self._table.get(key) if existing is not None and existing[0] > depth: return # keep the deeper analysis - self._table[key] = (depth, value, flag, move) + self._table[key] = (depth, tt_store_score(value, ply), flag, move) diff --git a/tests/test_eval_terms.py b/tests/test_eval_terms.py new file mode 100644 index 0000000..6d88e9b --- /dev/null +++ b/tests/test_eval_terms.py @@ -0,0 +1,90 @@ +"""Unit tests for ``eval_terms`` - the positional layer over PeSTO.""" + +import chess + +from pychess import eval_terms +from pychess.eval_board import EvalBoard +from pychess.evaluation import PestoEvaluator + + +def _pawns(fen: str) -> tuple[int, int]: + b = chess.Board(fen) + return b.pawns & b.occupied_co[chess.WHITE], b.pawns & b.occupied_co[chess.BLACK] + + +def test_pawn_structure_is_zero_at_the_start(): + assert eval_terms.pawn_structure(*_pawns(chess.STARTING_FEN)) == (0, 0) + + +def test_passed_pawn_scores_more_the_further_it_is_advanced(): + near = eval_terms.pawn_structure(*_pawns("4k3/8/8/8/4P3/8/8/4K3 w - - 0 1")) + far = eval_terms.pawn_structure(*_pawns("4k3/8/4P3/8/8/8/8/4K3 w - - 0 1")) + assert 0 < near[0] < far[0] + assert 0 < near[1] < far[1] + + +def test_isolated_and_doubled_pawns_are_penalised(): + # White: doubled + isolated a-pawns. Black: healthy trio. + mg, eg = eval_terms.pawn_structure(*_pawns("4k3/5ppp/8/8/P7/P7/8/4K3 w - - 0 1")) + assert mg < 0 and eg < 0 + + +def test_pawn_structure_is_colour_symmetric(): + fen = "2r3k1/1p3ppp/p7/3p4/3P4/P7/1P3PPP/2R3K1 w - - 0 1" + mg, eg = eval_terms.pawn_structure(*_pawns(fen)) + m_mg, m_eg = eval_terms.pawn_structure(*_pawns(chess.Board(fen).mirror().fen())) + assert (mg, eg) == (-m_mg, -m_eg) + + +def test_positional_is_colour_symmetric(): + # A position exercising every term for both colours: bishop pairs, rooks on + # open / half-open files, a knight outpost, ragged pawns, exposed kings. + fen = "2r3k1/1b3p1p/1np5/pP1p4/P2P4/1NP5/1B3P1P/2R3K1 w - - 0 1" + board = chess.Board(fen) + mirror = board.mirror() + for prior in ((0, 0), (17, -9)): + a = eval_terms.positional(board, *prior) + b = eval_terms.positional(mirror, -prior[0], -prior[1]) + assert a == (-b[0], -b[1]) + + +def test_bishop_pair_favours_the_side_that_has_it(): + # White two bishops, Black two knights, otherwise identical. + board = chess.Board("1n1nk3/8/8/8/8/8/8/1B1BK3 w - - 0 1") + mg, eg = eval_terms.positional(board, 0, 0) + assert mg >= eval_terms.BISHOP_PAIR_MG + assert eg >= eval_terms.BISHOP_PAIR_EG + + +def test_rook_on_an_open_file_beats_a_rook_on_a_closed_one(): + open_file = eval_terms.positional(chess.Board("4k3/8/8/8/8/8/5PPP/R3K3 w - - 0 1"), 0, 0) + closed = eval_terms.positional(chess.Board("4k3/8/8/8/8/8/P4PPP/R3K3 w - - 0 1"), 0, 0) + assert open_file[0] > closed[0] + + +def test_knight_outpost_is_rewarded(): + # White knight on d6, defended by the c5 pawn, no black b/d pawn to evict it. + board = chess.Board("4k3/8/3N4/2P5/8/8/8/4K3 w - - 0 1") + mg, _eg = eval_terms.positional(board, 0, 0) + assert mg >= eval_terms.KNIGHT_OUTPOST + + +def test_king_safety_penalises_a_missing_pawn_shield(): + safe = eval_terms.positional(chess.Board("4k3/8/8/8/8/8/5PPP/6K1 w - - 0 1"), 0, 0) + exposed = eval_terms.positional(chess.Board("4k3/8/8/8/8/8/8/6K1 w - - 0 1"), 0, 0) + assert exposed[0] < safe[0] + + +def test_evaluator_pawn_cache_matches_the_uncached_result(): + ev = PestoEvaluator() + board = EvalBoard("2r3k1/1p3ppp/p7/3p4/3P4/P7/1P3PPP/2R3K1 w - - 0 1") + first = ev.evaluate(board) + assert ev._pawn_cache # populated + assert ev.evaluate(board) == first # cache hit is consistent + + +def test_tempo_bonus_goes_to_the_side_to_move(): + white = PestoEvaluator().evaluate(chess.Board("4k3/8/8/8/8/8/8/4K3 w - - 0 1")) + black = PestoEvaluator().evaluate(chess.Board("4k3/8/8/8/8/8/8/4K3 b - - 0 1")) + assert white == eval_terms.TEMPO + assert black == eval_terms.TEMPO diff --git a/tests/test_move_ordering.py b/tests/test_move_ordering.py index 1f7025f..bef0c00 100644 --- a/tests/test_move_ordering.py +++ b/tests/test_move_ordering.py @@ -39,6 +39,24 @@ def test_killer_move_beats_other_quiets(): assert quiets[0] == killer +def test_is_killer_tracks_recorded_killers(): + orderer = MoveOrderer() + killer = chess.Move.from_uci("h2h3") + assert not orderer.is_killer(0, killer) + orderer.record_killer(0, killer) + assert orderer.is_killer(0, killer) + assert not orderer.is_killer(1, killer) + + +def test_history_score_reflects_recorded_cutoffs(): + board = chess.Board() + orderer = MoveOrderer() + move = chess.Move.from_uci("g1f3") + assert orderer.history_score(board, move) == 0 + orderer.record_history(board, move, depth=4) + assert orderer.history_score(board, move) == 16 + + def test_mvvlva_prefers_taking_the_bigger_piece(): # White pawn on d4 could take either rook on c5 / e5. board = chess.Board("3qk3/8/8/2r1r3/3P4/8/8/3QK3 w - - 0 1") diff --git a/tests/test_negamax.py b/tests/test_negamax.py index bb6ebbc..c1486d3 100644 --- a/tests/test_negamax.py +++ b/tests/test_negamax.py @@ -8,7 +8,7 @@ from pychess.constants import INF, MATE from pychess.evaluation import PestoEvaluator from pychess.move_ordering import MoveOrderer -from pychess.negamax import Negamax, SearchAbortError, is_drawn +from pychess.negamax import Negamax, SearchAbortError, claims_draw, is_drawn from pychess.transposition import TranspositionTable MATE_IN_1 = chess.Board("4k3/8/4K3/8/8/8/8/7R w - - 0 1") # Rh8# @@ -67,3 +67,167 @@ def test_is_drawn_insufficient_material(): def test_is_drawn_normal_position_is_not_drawn(): assert not is_drawn(chess.Board()) + + +# -- null-move pruning ------------------------------------------------------ + + +def test_null_ok_guards(): + # Normal middlegame position, deep enough: null move is allowed. + assert Negamax._null_ok(chess.Board(), depth=4, can_null=True, ply=1) + # Already tried a null move on the way here. + assert not Negamax._null_ok(chess.Board(), depth=4, can_null=False, ply=1) + # Root node: keep the PV honest. + assert not Negamax._null_ok(chess.Board(), depth=4, can_null=True, ply=0) + # Too shallow. + assert not Negamax._null_ok(chess.Board(), depth=2, can_null=True, ply=1) + # In check: passing is not an option. + in_check = chess.Board("4k3/8/8/8/7b/8/5P2/4K3 w - - 0 1") + assert not Negamax._null_ok(in_check, depth=4, can_null=True, ply=1) + # King + pawns only: likely zugzwang, the null search would lie. + kp = chess.Board("4k3/pppppppp/8/8/8/8/PPPPPPPP/4K3 w - - 0 1") + assert not Negamax._null_ok(kp, depth=4, can_null=True, ply=1) + + +# A quiet middlegame where a free move plainly still loses for the side that +# took it, so null-move cut-offs land. +QUIET_MIDGAME = "r2q1rk1/ppp2ppp/2np1n2/2b1p3/2B1P3/2NP1N2/PPP2PPP/R1BQ1RK1 w - - 0 8" + + +def test_null_move_prunes_nodes_without_changing_the_move(monkeypatch): + board = chess.Board(QUIET_MIDGAME) + + with_null = make_negamax() + score_a, pv_a = with_null.search(board.copy(), -INF, INF, 5) + + without_null = make_negamax() + monkeypatch.setattr(Negamax, "_null_ok", staticmethod(lambda *a, **k: False)) + score_b, pv_b = without_null.search(board.copy(), -INF, INF, 5) + + assert with_null.nodes < without_null.nodes + assert pv_a[0] == pv_b[0] + assert abs(score_a - score_b) <= 40 + + +def test_null_move_still_finds_mate_in_one(): + score, pv = make_negamax().search(MATE_IN_1.copy(), -INF, INF, 4) + assert score >= MATE - 100 + assert pv[0] == chess.Move.from_uci("h1h8") + + +# -- late move reductions ------------------------------------------------- + + +def test_lmr_reduction_grows_with_lateness_and_depth(): + # First few moves, shallow node: no reduction. + assert Negamax._lmr_reduction(3, 4, favoured=False) == 1 + # Later move, deep node: bigger reduction. + assert Negamax._lmr_reduction(8, 10, favoured=False) == 3 + # A killer / good-history move is reduced one ply less. + assert Negamax._lmr_reduction(8, 10, favoured=True) == 2 + # Never reduces the reduced search below one ply. + assert Negamax._lmr_reduction(20, 3, favoured=False) == 1 + assert Negamax._lmr_reduction(3, 3, favoured=True) == 0 + + +def test_lmr_prunes_nodes_without_blundering(monkeypatch): + board = chess.Board("r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/2N2N2/PPPP1PPP/R1BQK2R w KQkq - 4 4") + + with_lmr = make_negamax() + score_a, pv_a = with_lmr.search(board.copy(), -INF, INF, 4) + + without_lmr = make_negamax() + monkeypatch.setattr(Negamax, "_lmr_reduction", staticmethod(lambda *a, **k: 0)) + score_b, _pv_b = without_lmr.search(board.copy(), -INF, INF, 4) + + assert with_lmr.nodes < without_lmr.nodes # the reductions save work + assert pv_a[0] in set(board.legal_moves) + assert abs(score_a - score_b) <= 40 # and don't cost more than a fraction of a pawn + + +def test_lmr_still_wins_the_hanging_rook(): + board = chess.Board("4k3/8/8/8/r6R/8/8/4K3 w - - 0 1") + _score, pv = make_negamax().search(board, -INF, INF, 5) + assert pv[0] == chess.Move.from_uci("h4a4") + + +# -- principal variation search ----------------------------------------- + + +def test_pvs_returns_a_legal_consistent_pv(): + # Walk the reported PV from the start position; every move must be legal in + # turn - a broken scout / re-search would splice in a stale line. + board = chess.Board() + _score, pv = make_negamax().search(board.copy(), -INF, INF, 6) + assert pv + for move in pv: + assert move in board.legal_moves + board.push(move) + + +def test_pvs_finds_a_quiet_best_move_ordered_after_captures(): + # 1.Nxe5?? Qa5+ wins the knight; the quiet 1.d3 (ordered well after the + # capture) holds everything. PVS must re-search past the scout to see it. + board = chess.Board("r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4") + score, pv = make_negamax().search(board, -INF, INF, 5) + assert pv[0] != chess.Move.from_uci("f3e5") + assert score > -50 # not down a piece + + +# -- draw, repetition and mate scoring --------------------------------- + +# White mates in two (Kf7, Kh7, Rh1#) - checkmate lands 3 plies from the root. +MATE_IN_2 = chess.Board("7k/8/5K2/8/8/8/8/R7 w - - 0 1") + +# White is up a rook but the position has already repeated three times. +REPETITION = chess.Board("4k3/8/8/8/8/8/8/R3K3 w - - 0 1") +for _uci in ["a1a2", "e8d8", "a2a1", "d8e8"] * 2: + REPETITION.push_uci(_uci) + + +def test_claims_draw_spots_repetition_and_the_fifty_move_rule(): + assert claims_draw(REPETITION) + assert not claims_draw(chess.Board()) + fifty = chess.Board("4k3/8/8/8/8/8/8/R3K3 w - - 0 1") + fifty.halfmove_clock = 100 + assert claims_draw(fifty) + + +def test_mate_in_two_is_scored_by_distance_from_the_root(): + score, pv = make_negamax().search(MATE_IN_2.copy(), -INF, INF, 5) + assert score == MATE - 3 # checkmate 3 plies away + board = MATE_IN_2.copy() + for move in pv: + board.push(move) + assert board.is_checkmate() + assert len(pv) == 3 + + +def test_mate_score_is_stable_across_depths(): + # The TT stores mate scores relative to the storing node; if the ply rebase + # were wrong, a hit from a different depth would shift the reported mate. + scores = {d: make_negamax().search(MATE_IN_2.copy(), -INF, INF, d)[0] for d in (4, 5, 6, 7)} + assert set(scores.values()) == {MATE - 3} + + +def test_search_scores_a_repetition_as_a_draw_despite_material(): + # ply=1 so the in-tree draw guard fires; White is a whole rook up. + score, _pv = make_negamax().search(REPETITION.copy(), -INF, INF, 4, ply=1) + assert score == 0 + + +def test_search_scores_the_fifty_move_rule_as_a_draw(): + board = chess.Board("4k3/8/8/8/8/8/8/R3K3 w - - 0 1") + board.halfmove_clock = 100 + score, _pv = make_negamax().search(board, -INF, INF, 4, ply=1) + assert score == 0 + + +def test_root_still_returns_a_move_in_an_already_drawn_position(): + # At the root (ply 0) the guard is skipped - the engine must still move. + _score, pv = make_negamax().search(REPETITION.copy(), -INF, INF, 4) + assert pv and pv[0] in set(REPETITION.legal_moves) + + +def test_quiesce_scores_a_repetition_as_a_draw(): + assert make_negamax().quiesce(REPETITION.copy(), -INF, INF, 0, 1) == 0 diff --git a/tests/test_transposition.py b/tests/test_transposition.py index c6826d0..ac609fb 100644 --- a/tests/test_transposition.py +++ b/tests/test_transposition.py @@ -2,7 +2,7 @@ import chess -from pychess.constants import INF, MATE_GUARD, TT_EXACT, TT_LOWER, TT_UPPER +from pychess.constants import INF, MATE, TT_EXACT, TT_LOWER, TT_UPPER from pychess.transposition import TranspositionTable STARTPOS = chess.Board() @@ -53,10 +53,21 @@ def test_deeper_entry_is_kept_on_store(): assert tt.probe(key, 3, -INF, INF) == (True, 10, None) -def test_mate_scores_are_never_returned_as_a_bound(): +def test_mate_scores_are_returned_and_rebased_by_ply(): tt = TranspositionTable() key = tt.key(STARTPOS) move = chess.Move.from_uci("e2e4") - tt.store(key, 5, MATE_GUARD + 1, TT_EXACT, move) - cutoff, value, got = tt.probe(key, 3, -INF, INF) - assert cutoff is False and value == 0 and got == move + # A mate 8 plies from the root, found while searching at ply 6 (so 2 plies + # away from this node). Stored node-relative, it is MATE - 2. + tt.store(key, 5, MATE - 8, TT_EXACT, move, ply=6) + assert tt._table[key][1] == MATE - 2 + # The same position reached at ply 4 is a mate 6 plies from the root. + cutoff, value, got = tt.probe(key, 3, -INF, INF, ply=4) + assert cutoff is True and value == MATE - 6 and got == move + + +def test_non_mate_scores_ignore_ply(): + tt = TranspositionTable() + key = tt.key(STARTPOS) + tt.store(key, 5, 42, TT_EXACT, None, ply=6) + assert tt.probe(key, 3, -INF, INF, ply=4) == (True, 42, None) diff --git a/tests/test_uci.py b/tests/test_uci.py index 3c7e776..d611784 100644 --- a/tests/test_uci.py +++ b/tests/test_uci.py @@ -7,6 +7,7 @@ from pychess import __main__ as cli from pychess.__main__ import UCI, main +from pychess.constants import MATE from pychess.engine import RandomEngine from pychess.lazy_smp import SearchResult from pychess.types import GoLimits @@ -127,6 +128,33 @@ def test_self_play_plays_book_moves(self, monkeypatch, capsys): assert session.board.move_stack[0].uci() == "e2e4" +class _FixedEngine: + """Returns a preset SearchResult, to exercise ``info`` formatting.""" + + def __init__(self, result: SearchResult) -> None: + self._result = result + + def search(self, board: chess.Board, limits: GoLimits | None = None) -> SearchResult: + return self._result + + +class TestScoreField: + def test_centipawns(self): + assert UCI._score_field(53) == "score cp 53" + assert UCI._score_field(-120) == "score cp -120" + + def test_mate_in_moves_is_signed(self): + assert UCI._score_field(MATE - 1) == "score mate 1" # 1 ply -> mate in 1 + assert UCI._score_field(MATE - 3) == "score mate 2" # 3 plies -> mate in 2 + assert UCI._score_field(-(MATE - 4)) == "score mate -2" # getting mated + + def test_go_reports_mate_score(self, uci, capsys): + uci.engine = _FixedEngine(SearchResult(MATE - 3, [chess.Move.from_uci("a1a2")], 5, 10, 0.0)) + uci.process_command("position fen 7k/8/5K2/8/8/8/8/R7 w - - 0 1") + uci.process_command("go depth 5") + assert "score mate 2" in capsys.readouterr().out + + class TestBookMove: def test_missing_book_returns_none(self, uci): assert uci.book_move() is None # fixture points BOOK_PATH at nothing diff --git a/tools/self_play_gif.py b/tools/self_play_gif.py new file mode 100644 index 0000000..2adedda --- /dev/null +++ b/tools/self_play_gif.py @@ -0,0 +1,173 @@ +"""Regenerate ``docs/self-play.gif``: the engine playing itself, one frame per +move, over an ASCII board. + + python tools/self_play_gif.py [--seconds 5] [--moves 30] [--out docs/self-play.gif] + +Every move is a real Lazy SMP search - no opening book. At the default 5 s/move +and 30-move cap this takes ~5 minutes. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import chess + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError as exc: + raise SystemExit( + "Pillow is required to run tools/self_play_gif.py; install it with `pip install pillow`." + ) from exc + +from pychess.constants import MATE, MATE_IN_MAX +from pychess.engine import Engine + +WIDTH, HEIGHT = 566, 514 +BG = (22, 22, 22) +FG = (230, 230, 230) +DIM = (138, 138, 138) +MARGIN_X = 29 +TOP_Y = 32 +LINE_H = 40 +BOARD_TOP = 121 +FRAME_MS = 1100 + +_FONT_CANDIDATES = ( + "/System/Library/Fonts/SFNSMono.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/Library/Fonts/DejaVuSansMono.ttf", +) + + +def _font(size: int) -> ImageFont.FreeTypeFont: + for path in _FONT_CANDIDATES: + if Path(path).exists(): + return ImageFont.truetype(path, size) + return ImageFont.load_default(size) + + +FONT = _font(25) + +_PIECE_CP = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, chess.ROOK: 5, chess.QUEEN: 9} + + +def _board_lines(board: chess.Board) -> list[str]: + lines = [] + for rank in range(7, -1, -1): + cells = [] + for file in range(8): + piece = board.piece_at(chess.square(file, rank)) + cells.append(piece.symbol() if piece else ".") + lines.append(f"{rank + 1} " + " ".join(cells)) + lines.append(" " + " ".join("abcdefgh")) + return lines + + +def _score_text(white_cp: int) -> str: + """A White-relative score for the info line: ``#N`` near mate, else ``cp``.""" + if abs(white_cp) >= MATE_IN_MAX: + moves = (MATE - abs(white_cp) + 1) // 2 + return f"#{moves}" if white_cp > 0 else f"#-{moves}" + return f"{white_cp:+d} cp" + + +def _material_note(board: chess.Board) -> str: + diff = sum( + _PIECE_CP[pt] * (len(board.pieces(pt, chess.WHITE)) - len(board.pieces(pt, chess.BLACK))) + for pt in _PIECE_CP + ) + mag = abs(diff) + if mag < 2: + return "" + # The verdict line already names the side that is winning, so this is just + # the piece amount - keep it short so it fits the frame. + if mag == 2: + return ", up two pawns" + if mag < 5: + return ", up a piece" + if mag < 8: + return ", up a rook" + return ", up a queen" + + +def _render(title: str, subtitle: str, board: chess.Board) -> Image.Image: + img = Image.new("RGB", (WIDTH, HEIGHT), BG) + draw = ImageDraw.Draw(img) + draw.text((MARGIN_X, TOP_Y), title, font=FONT, fill=FG) + draw.text((MARGIN_X, TOP_Y + LINE_H), subtitle, font=FONT, fill=DIM) + for i, line in enumerate(_board_lines(board)): + draw.text((MARGIN_X, BOARD_TOP + i * LINE_H), line, font=FONT, fill=FG) + return img + + +def _summary(board: chess.Board, white_cp: int, moves_cap: int, capped: bool) -> tuple[str, str]: + if board.is_checkmate(): + winner = "Black" if board.turn == chess.WHITE else "White" + return f"{winner} wins by checkmate", "game over" + if board.is_game_over() or board.is_repetition(3) or board.halfmove_clock >= 100: + return "Draw", "game over" + + note = _material_note(board) + if white_cp > 150: + verdict = f"White is winning ({white_cp / 100:+.1f}{note})" + elif white_cp < -150: + verdict = f"Black is winning ({white_cp / 100:+.1f}{note})" + else: + verdict = f"Roughly balanced ({white_cp / 100:+.1f})" + tail = f"game stopped at the {moves_cap}-move cap" if capped else "game over" + return verdict, tail + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--seconds", type=float, default=5.0) + parser.add_argument("--moves", type=int, default=30, help="full-move cap") + parser.add_argument("--out", type=Path, default=Path("docs/self-play.gif")) + args = parser.parse_args() + + engine = Engine() + board = chess.Board() + frames = [_render("pychess self-play", f"{args.seconds:g} second time limit per move", board)] + + white_cp = 0 + plies = args.moves * 2 + for _ply in range(plies): + result = engine.search(board, {"movetime": int(args.seconds * 1000)}) + if not result.pv: + break + move = result.pv[0] + move_no = board.fullmove_number + san = board.san(move) + moved_white = board.turn == chess.WHITE + prefix = f"{move_no}." if moved_white else f"{move_no}..." + board.push(move) + white_cp = result.score if moved_white else -result.score + info = ( + f"depth {result.depth} {_score_text(white_cp)} " + f"{result.nodes:,} nodes {result.elapsed:.1f}s" + ) + frames.append(_render(f"{prefix} {san}", info, board)) + print(f"{prefix} {san} ({info})", flush=True) + if board.is_game_over() or board.is_repetition(3) or board.halfmove_clock >= 100: + break + + capped = len(frames) - 1 >= plies + title, subtitle = _summary(board, white_cp, args.moves, capped) + frames.append(_render(title, subtitle, board)) + + args.out.parent.mkdir(parents=True, exist_ok=True) + frames[0].save( + args.out, + save_all=True, + append_images=frames[1:], + duration=FRAME_MS, + loop=0, + optimize=True, + ) + print(f"\nwrote {args.out} ({args.out.stat().st_size // 1024} KB, {len(frames)} frames)") + + +if __name__ == "__main__": + main()