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
83 changes: 83 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Rust CI

on:
push:
branches: [main, 'arena/**']
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Release test suite
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Compile all targets
shell: bash
run: |
set +e
cargo build --release --all-targets >build.log 2>&1
status=$?
cat build.log
if [ "$status" -ne 0 ]; then
# Keep compiler diagnostics retrievable through the Checks API when
# an environment cannot download the otherwise-standard raw log.
encoded="$(sed -e 's/%/%25/g' -e ':a;N;$!ba;s/\r/%0D/g;s/\n/%0A/g' build.log)"
echo "::error title=Rust compilation failed::$encoded"
exit "$status"
fi
- name: Run correctness tests, including full start-position perft(6)
# Release generation is fast enough for the 119,060,324-node reference
# count, so every push validates the complete required perft suite.
shell: bash
run: |
set +e
cargo test --release >test.log 2>&1
status=$?
cat test.log
if [ "$status" -ne 0 ]; then
encoded="$(sed -e 's/%/%25/g' -e ':a;N;$!ba;s/\r/%0D/g;s/\n/%0A/g' test.log)"
echo "::error title=Rust tests failed::$encoded"
exit "$status"
fi
- name: Exercise command-line perft
run: target/release/chess-engine perft --depth 3
- name: Exercise UCI handshake
run: |
output="$(printf 'uci\nisready\nquit\n' | timeout 10s target/release/chess-uci)"
printf '%s\n' "$output"
grep -qx 'uciok' <<<"$output"
grep -qx 'readyok' <<<"$output"
- name: Capture CLI, UCI, and benchmark examples
shell: bash
run: |
emit_notice() {
local title="$1"
local text="$2"
local encoded
encoded="$(printf '%s' "$text" | sed -e 's/%/%25/g' -e ':a;N;$!ba;s/\r/%0D/g;s/\n/%0A/g')"
echo "::notice title=$title::$encoded"
}
cli="$(target/release/chess-engine analyze --fen '7k/5K2/6Q1/8/8/8/8/8 w - - 0 1' --depth 3 --multipv 1 --hash 4)"
uci="$({ printf 'uci\nisready\nposition fen 7k/5K2/6Q1/8/8/8/8/8 w - - 0 1\ngo depth 3\n'; sleep 1; printf 'quit\n'; } | timeout 10s target/release/chess-uci)"
perft_bench="$(cargo bench --bench perft_bench)"
search_bench="$(cargo bench --bench search_bench)"
printf '%s\n%s\n%s\n%s\n' "$cli" "$uci" "$perft_bench" "$search_bench"
emit_notice 'Captured CLI output' "$cli"
emit_notice 'Captured UCI output' "$uci"
emit_notice 'Captured benchmarks' "$perft_bench\n$search_bench"

full-perft:
name: Full depth-6 perft validation
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --release start_position_perft_six -- --ignored
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/target
*.profraw
*.profdata
42 changes: 42 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[workspace]
members = ["."]
resolver = "2"

[package]
name = "chess-engine"
version = "0.1.0"
edition = "2021"
description = "A correctness-first, single-threaded Rust chess engine"
license = "MIT"
authors = ["TheronRadley contributors"]

[lib]
name = "chess_engine"
path = "src/lib.rs"

[[bin]]
name = "chess-engine"
path = "src/bin/cli.rs"

[[bin]]
name = "chess-uci"
path = "src/bin/uci_main.rs"

[profile.release]
# Native speed matters for alpha-beta depth. Keep one codegen unit for cross-module inlining.
lto = "thin"
codegen-units = 1
panic = "abort"

[[bench]]
name = "perft_bench"
harness = false

[[bench]]
name = "search_bench"
harness = false

[features]
default = []
# Kept for downstream users that want to call the deliberately slow reference generator.
reference-movegen = []
46 changes: 46 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Engineering decisions

## Determinism

Zobrist keys come from a fixed SplitMix64 seed, not process entropy. Given a position history, configuration, table contents, and limits, move ordering has deterministic tie breaking by encoded move. This is useful for regressions and avoids the common confusion of random keys changing a test result.

## Position mutation

Search uses one mutable `Position` plus compact `Undo` records, not a board clone per node. `Undo` restores captured piece/square, castling rights, en-passant square, clocks, key, and history length. The mailbox and all bitboards are changed only by paired add/remove helpers. The independent reference generator intentionally does use copy-make, but only in tests.

## Legal generation before optimization

The production generator enumerates piece-legal geometry/castling transit conditions and then checks the moving king after make/unmake. This is slower than specialized check-evasion/pin masks but gives one authoritative legality condition for normal moves, pins, double checks, and en passant discovery. It is not a placeholder: all returned moves are strict legal moves and canonical perft/reference comparison protects it. Direct pin-mask/checker generation remains a measured future optimization, not a speculative rewrite.

## Slider lookup

Sliding attacks are precomputed for every relevant occupancy subset and indexed by a portable software PEXT compression. This gives a constant table lookup attack result without magic-number generation or CPU feature dispatch. The table layout is an equivalent established sliding-bitboard technique; a BMI2 or magic index can replace only the compression function later.

## Hashing and repetition

The Zobrist key incrementally XORs pieces, side, the 16-combination castling key, and EP file. The position history includes the current key and is retained when UCI applies a move list. The FEN EP target is validated and included exactly as supplied/created by a double push. Fifty moves uses the FIDE 100-halfmove threshold.

## Draw material scope

Automatically dead material is limited to K/K, a lone bishop/knight versus king, and two bishops (one per side) on same-colour squares. This conservative rule avoids incorrectly claiming a draw in unusual multi-minor positions whose forced-mate status is non-obvious.

## Search choices and tunables

* Default TT: **16 MiB**, configurable 1–4096 MiB. One entry per index, depth-preferred with generation replacement.
* Aspiration half-window: **40 cp**. It doubles on a fail high/low.
* Null move: non-PV, not in check, depth >= **3**, and only with non-pawn material. Reduction is `2 + depth / 6`; pawn-only sides are excluded for zugzwang safety. A null fail-high is additionally verified at reduced depth with null moves disabled.
* LMR: quiet non-check moves from index 4 at depth >= **3**, reduction `1 + index / 12`; a fail-high gets full-depth re-search.
* Futility: non-PV quiets at depth <= **2** when static eval + `95 * depth` cannot reach alpha.
* Quiescence searches legal captures/promotions, and all evasions while checked. Delta pruning uses a deliberately generous **975 cp** queen swing.
* Check extension: +1 ply whenever the side to move is checked.
* MultiPV: every root move is full-window searched at each iteration, lines are score-sorted, and the requested top 1–5 roots are retained. It is slower than a single root PVS but prevents bound-only second/third lines and keeps MultiPV semantically simple.

The move-order priority is TT move, capture MVV-LVA/SEE classification, killer moves (two slots per ply), history, then deterministic UCI encoding. Exact legal SEE is used for captures in the upper two plies where it is valuable; deeper nodes retain MVV-LVA classification to avoid exchange-tree overhead.

## Time and interruption

A `movetime` is turned into a hard deadline two milliseconds early. Search checks it (and an atomic UCI stop flag) at least every 2048 nodes and at root. Iterative deepening only commits a fully completed depth; on interruption it returns that depth, with a legal fallback when no depth completes. Clock-based UCI allocation uses `remaining/30 + 3/4 increment`, capped below remaining clock, as a conservative single-threaded baseline.

## Evaluation

The values/PST/term weights in `src/eval/params.rs` are intentionally modest starting values, not alleged Elo tuning. Evaluation is recomputed at leaves to keep state handling auditable. Incremental material/PST cache is a candidate optimization after profiler evidence; no correctness-sensitive cached score is maintained today.
121 changes: 120 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,120 @@
# chess-engine
# Correctness First Rust Chess Engine

A single-threaded UCI chess engine written in Rust. It accepts strict FEN, produces only legal orthodox-chess moves, and searches with iterative deepening, alpha-beta/PVS, quiescence, a transposition table, and MultiPV.

## Why Rust

The engine is Rust rather than a prototype language because search depth is directly tied to native-code speed: optimized Rust is close to C++ for bitboard-heavy code. At the same time, ownership and bounds checking remove several serious engine failure modes common in C/C++—stale recursive board state, aliased hash-table storage, and array-indexing mistakes. Cargo provides a standard build/test/benchmark workflow without an extra build system. The representation and attack tables are ordinary Rust data, so future magic/PEXT, SIMD, NNUE, and parallel-search work has a natural path forward.

## Design overview

* **Position:** twelve `u64` piece bitboards, white/black/all occupancy bitboards, and a synchronized 64-square mailbox.
* **State:** side, four castling-right bits, en-passant target, clocks, deterministic incremental Zobrist key, and full known-position history.
* **Attacks:** precomputed pawn/knight/king tables plus precomputed occupancy-indexed sliding tables. The latter is a portable software-PEXT table lookup, an established magic-bitboard-equivalent technique without a BMI2 runtime requirement.
* **Move generation:** pseudo-legal candidates are make/unmade against the king attack invariant, so the public list is strictly legal—including pins, double check, discovered attacks, castling transit, and en passant discovered checks. A deliberately independent copy-make reference generator is exposed for test cross-checking. The make/unmake filter was selected for its auditable correctness; pin/check masks are a documented optimization opportunity once profiling shows it is needed.
* **Search:** iterative deepening with aspiration windows, PVS, quiescence, TT bounds/mate-score normalization, MVV-LVA plus legal SEE at upper nodes, killer/history ordering, guarded null move, LMR, shallow futility pruning, and check extensions.
* **Evaluation:** tapered classical material/PST, pawn structure and passers, king safety, mobility, rook files, bishop pair, space, and endgame king activity. Constants are in `src/eval/params.rs`.

All implementation parameters that are not chess rules are collected in `src/eval/params.rs`, `src/search/mod.rs`, and `DECISIONS.md`.

## Build

```sh
cargo build --release
cargo test
cargo test --release # includes the 119,060,324-node perft(6)
cargo bench
```

The release profile enables Thin LTO, one codegen unit, and aborting panics for the engine binaries. On a clean environment Cargo/Rust (edition 2021 compatible) is the only prerequisite—there are no third-party crate dependencies.

> **Verification note:** This Arena sandbox has no local `rustc`/`cargo`, so local execution is unavailable. The complete release build, tests (including start-position perft(6)), CLI/UCI smoke checks, and benchmarks were instead run in the repository's GitHub Actions Ubuntu runner. The successful captured run is [Rust CI #36160655147](https://github.com/TheronRadley/chess-engine/actions/runs/36160655147). The real outputs below are copied from that run, not invented examples.

## CLI

The `chess-engine` binary is a direct analysis/perft interface over the same library used by UCI:

```sh
cargo run --release --bin chess-engine -- analyze \
--fen "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" \
--depth 8 --multipv 3 --hash 64

cargo run --release --bin chess-engine -- perft --fen "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" --depth 4
```

`analyze` supports `--fen`, `--depth`, `--movetime` (milliseconds), `--nodes`, `--multipv` (clamped 1–5), `--hash` (MiB, 1–4096), and `--debug`. It prints UCI PV moves and SAN alongside them, a centipawn or mate score, depth, nodes, NPS, elapsed milliseconds, and UCI-style `hashfull` permille. `--debug` additionally reports cancellation state, TT hits, and selective depth. `perft --divide` prints each root move's count.

Actual output captured from the successful release CI run:

```text
$ target/release/chess-engine analyze --fen "7k/5K2/6Q1/8/8/8/8/8 w - - 0 1" --depth 3 --multipv 1 --hash 4
Position: 7k/5K2/6Q1/8/8/8/8/8 w - - 0 1
Depth 3, MultiPV 1
1. score mate 1 depth 3 pv g6h5 san Qh5#
bestmove g6h5
nodes 142 nps 1014778 time 0 hashfull 0

$ target/release/chess-engine perft --depth 3
nodes 8902
```

## UCI

Run:

```sh
cargo run --release --bin chess-uci
```

Implemented commands are `uci`, `isready`, `ucinewgame`, `position startpos [moves ...]`, `position fen <six fields> [moves ...]`, `go depth|movetime|nodes|wtime|btime|winc|binc|infinite`, `stop`, `quit`, and `setoption` for `Hash`, `MultiPV`, and `Threads`.

`Threads` is deliberately advertised with min=max=1: the engine is safely single-threaded in this release. This is a real transcript captured by release CI:

```text
uci
id name Correctness First Rust Chess
id author TheronRadley contributors
option name Hash type spin default 16 min 1 max 4096
option name MultiPV type spin default 1 min 1 max 5
option name Threads type spin default 1 min 1 max 1
uciok
isready
readyok
position fen 7k/5K2/6Q1/8/8/8/8/8 w - - 0 1
go depth 3
info string searching
info depth 3 seldepth 2 multipv 1 score mate 1 nodes 142 nps 905346 hashfull 0 time 0 pv g6h5
bestmove g6h5
```

Search runs on a worker thread so the protocol reader can process `stop`; it returns the last completed iteration (or a deterministic legal fallback if stopped before depth one).

## Validation

`tests/perft_tests.rs` contains the canonical start position through depth 6, Kiwipete, positions 3–5, promotion, en-passant, and production/reference-generator comparisons. The six-ply start-position test is ignored only in debug builds because it is 119M nodes; it runs in release validation. GitHub Actions runs `cargo build --release --all-targets` and the complete `cargo test --release` suite on every branch push; run #36160655147 passed.

`tests/rules_tests.rs` covers FEN strictness and round trips, movement/blocking, mate/stalemate, pin/discovery, castling conditions, legal/illegal en passant, draw rules, SAN/UCI, and repetition. `tests/search_tests.rs` covers mate scoring/PV replay, MultiPV ordering and uniqueness, time-budget overshoot, and TT mate normalization. The internal UCI layer rejects malformed protocol position/move text as `info string error` rather than guessing.

## Benchmarks

The repository has dependency-free `cargo bench` harnesses:

* `perft_bench`: start position perft depth 5 and raw nodes/sec.
* `search_bench`: a forced-mate tactical position at depth 12 and search nodes/sec.

Actual `cargo bench` output from GitHub Actions Ubuntu (`ubuntu-latest`, Rust stable, run #36160655147) was:

```text
perft startpos depth 5: 4865609 nodes in 199.365687ms (24405448 nps)
search tactical mate depth 12: 89381 nodes in 48.578587ms (1841146 nps), best Some("g6h5")
```

Hosted runner CPU allocation can vary, so these are a reproducible baseline for the recorded environment rather than a cross-machine performance guarantee.

## Known limitations and extension points

* No NNUE, Syzygy/tablebases, opening book, or contempt. Insufficient-material detection intentionally handles material-decidable K/K, K+B/K, K+N/K, and same-colour K+B/K+B only; see code comments.
* No multithreading yet. `Engine` owns mutable search heuristics and TT, deliberately avoiding globals so Lazy SMP can add worker-local state later.
* The portable slider table index is software PEXT. Native BMI2/PEXT or generated magic multipliers are a local replacement in `movegen` if profiling warrants it.
* Legal generation favours a shared make/unmake king-safety invariant over direct check/pin masks. It is strictly legal and test-oracle cross-checked, but direct pin-mask generation is the most valuable remaining move-generation optimization.
* The hand-tuned evaluation is intentionally classical and transparent; tuning and NNUE are future work, not silently approximated.
3 changes: 3 additions & 0 deletions benches/perft_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use std::time::Instant;
use chess_engine::{perft, Position, STARTPOS_FEN};
fn main() { let mut p = Position::from_fen(STARTPOS_FEN).expect("start FEN"); let start = Instant::now(); let nodes = perft::perft(&mut p, 5); let elapsed = start.elapsed(); println!("perft startpos depth 5: {nodes} nodes in {:?} ({:.0} nps)", elapsed, nodes as f64 / elapsed.as_secs_f64()); }
11 changes: 11 additions & 0 deletions benches/search_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use std::time::Instant;
use chess_engine::{Engine, Position, SearchLimits};

fn main() {
// A compact forced-mate tactical position verifies that iterative search can
// reach depth 12 without a broad opening-tree benchmark dominating CI time.
let mut p = Position::from_fen("7k/5K2/6Q1/8/8/8/8/8 w - - 0 1").expect("tactical FEN");
let mut e = Engine::new(64); let start = Instant::now();
let r = e.search(&mut p, SearchLimits { depth: Some(12), movetime: None, nodes: None, multipv: 1 }, None);
println!("search tactical mate depth {}: {} nodes in {:?} ({} nps), best {:?}", r.depth, r.nodes, start.elapsed(), r.nps, r.best_move().map(|m| m.to_uci()));
}
Loading
Loading