Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 29 additions & 18 deletions .claude/skills/bench/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -37,48 +38,58 @@ 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
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
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.)
Loading