A standalone UCI chess engine that plays human-like moves using the Maia-2 neural network, written in Rust with ONNX Runtime for inference and optional Syzygy endgame tablebase support.
Maia-2 is a move-prediction model, not a search engine: given a position and the Elo ratings of the two players, it predicts the move a human of that strength would most likely play, plus a win probability. This project wraps the exported model behind a UCI interface so you can load it into any chess GUI (Cutechess, Arena, BanksiaGUI, en-croissant, …) or play against it from the command line.
Maia-2 was created by the CSSLab at the University of Toronto and presented at NeurIPS 2024. Original project: https://github.com/CSSLab/maia2 · Paper: https://arxiv.org/abs/2409.20553. This repository is an independent UCI front-end; it is not affiliated with the Maia authors.
- Faithful re-implementation of Maia-2's board encoding, move vocabulary, Elo bucketing and black-to-move mirroring in Rust, verified byte-for-byte and numerically against the reference Python package (see Testing).
- CPU and GPU inference. CPU works out of the box; build with
--features cudafor NVIDIA GPUs. - Both pretrained models bundled:
rapidandblitz. - Syzygy tablebases via Fathom (MIT) for perfect endgame play, with an optional "human blend" mode.
- Analysis output — MultiPV with multi-ply principal variations and centipawn scores (rolled out from the model), like Stockfish/Leela.
- Permissively licensed throughout — no GPL dependencies (no Stockfish, Lc0 or the original Maia engine code).
Maia-2 models a single human decision; it does not search a game tree, so there
is no alpha-beta, no node count and no search-thread parallelism. The one place
threads help is inside a single forward pass (the conv/matmul kernels), which
is exposed through the standard UCI Threads option and maps to ONNX Runtime's
intra-op thread pool.
Requires a Rust toolchain (this repo pins stable via rust-toolchain.toml,
because ort needs rustc ≥ 1.88). ONNX Runtime binaries are downloaded
automatically by the ort crate at build time.
# CPU (default)
cargo build --release
# NVIDIA CUDA
cargo build --release --features cuda
# NVIDIA TensorRT (implies CUDA)
cargo build --release --features tensorrtThe binary is target/release/maia2-uci.
To drop the engine into another app, build it and run the bundler:
cargo build --release --features cuda,embed-weights
./scripts/make-bundle.sh # -> dist/maia2-uci-gpu/With embed-weights the model weights are compiled into the binary, and the
ONNX Runtime core is statically linked, so the bare binary is fully
self-contained — copied on its own it runs the real engine on CPU. The bundle
adds the ONNX Runtime CUDA provider libs and GPU math libraries (cuDNN 9 /
cuBLAS / nvrtc / cudart / cufft) with $ORIGIN RPATHs baked in, so the GPU
works from the bare binary with no wrapper and no environment variables. Point
your app's engine path directly at maia2-uci; Device defaults to auto
(GPU if available, else CPU).
The cuda build uses ONNX Runtime 1.24, which needs CUDA 12 and cuDNN 9
on the library path at runtime (plus an NVIDIA driver). If you don't have cuDNN 9
system-wide, the simplest no-root way to get it is the NVIDIA pip wheels, then
source the helper that puts them on LD_LIBRARY_PATH:
pip install nvidia-cudnn-cu12 nvidia-cublas-cu12 nvidia-cuda-nvrtc-cu12
. scripts/cuda-env.sh # or: PYTHON=/path/to/python . scripts/cuda-env.sh
./target/release/maia2-uciSet Device to cuda (see options below). The engine reports the device it
actually used (info string loaded … on Cuda(0)). If the GPU stack can't be
initialised it automatically falls back to CPU and says so — it never silently
runs on the wrong device:
info string CUDA unavailable (...); falling back to CPU. The GPU build needs CUDA 12 + cuDNN 9 on the library path.
info string loaded models/maia2-rapid.onnx on Cpu
./target/release/maia2-uciThen speak UCI:
uci
setoption name Model value rapid
setoption name SelfElo value 1100
setoption name OppElo value 1900
position startpos moves e2e4 e7e5
go
go returns immediately with a bestmove. Time-control tokens (wtime,
movetime, …) are accepted and ignored — there is nothing to search.
| Option | Type | Default | Meaning |
|---|---|---|---|
Model |
combo | rapid |
Which model to use: rapid or blitz. |
ModelDir |
string | models |
Directory containing maia2-<model>.onnx. |
Device |
combo | auto |
auto (GPU if available, else CPU), cpu, or cuda/cuda:N. |
Threads |
spin | 1 |
ONNX Runtime intra-op threads (per forward pass). |
SelfElo |
spin | 1500 |
Elo of the side to move (the player Maia imitates). |
OppElo |
spin | 1500 |
Elo of the opponent. |
UCI_Elo |
spin | 1500 |
Alias for SelfElo (GUI strength control). |
MultiPV |
spin | 1 |
How many candidate lines to report (1–20). |
PVDepth |
spin | 8 |
Plies to roll out for each principal variation (1–20). |
SyzygyPath |
string | empty | Path(s) to Syzygy .rtbw/.rtbz files (;-separated). |
UseSyzygyAtRoot |
check | true |
Use tablebases at the root when in range. |
SyzygyHumanBlend |
check | false |
See below. |
Maia plays the side to move at SelfElo against an OppElo opponent. To make
it play like a 1100 against a 1900, set SelfElo 1100 and OppElo 1900.
UCI_Elo is an alias for SelfElo for GUIs with a strength slider.
Difficulty is SelfElo: lower plays more like a beginner, higher more like a
strong club player. Maia-2 uses discrete Elo buckets, so only these levels
are distinct: <1100, 1100–1199, 1200–1299, …, 1900–1999, ≥2000 — e.g.
1500 and 1550 behave identically.
When SyzygyPath is set and the position has few enough pieces:
- Default (
UseSyzygyAtRoot=true,SyzygyHumanBlend=false) — play the DTZ-optimal tablebase move. Guarantees correct conversion of won/drawn endgames. - Human blend (
SyzygyHumanBlend=true) — keep Maia-2's most human move, but only among the moves that preserve the game-theoretic result (win/draw/loss). Style is retained; obvious endgame blunders are filtered out. Note this does not guarantee the fastest mate, so very long wins could in principle brush the 50-move rule; prefer the default for guaranteed conversion. UseSyzygyAtRoot=false— ignore tablebases; pure Maia-2.
Although Maia-2 doesn't search, the engine still emits Stockfish/Leela-style
analysis output. Set MultiPV to see several candidate lines; each is a real
multi-ply principal variation built by greedily rolling out Maia-2's
most-human reply for both sides (PVDepth plies), and each carries a centipawn
score from a 1-ply lookahead (play the move, evaluate the reply, take the
side-to-move's win probability). Lines stay ordered most-human first, so
multipv 1 is the move actually played.
> setoption name MultiPV value 3
> position startpos moves e2e4 e7e5
> go
info depth 8 multipv 1 score cp 60 nodes 8 pv g1f3 b8c6 f1b5 d7d6 d2d4 e5d4 f3d4 c8d7 string move_prob 0.1651 winprob 0.5850
info depth 8 multipv 2 score cp 41 nodes 8 pv d1e2 d8e7 e2d1 e7d8 g1f3 b8c6 f1b5 d7d6 string move_prob 0.1305 winprob 0.5590
info depth 8 multipv 3 score cp 106 nodes 8 pv f1c4 g8f6 d2d3 f8c5 h2h3 e8g8 g1f3 d7d6 string move_prob 0.1300 winprob 0.6476
bestmove g1f3
The score cp is from the side-to-move's perspective; the string fields give
the model's probability of the first move and the line's win probability. The PV
is Maia-2's human continuation, not a proven best line.
- The position is encoded into Maia-2's
[18, 8, 8]tensor (12 piece planes, side to move, 4 castling planes, en-passant plane). When it is Black to move the board is mirrored so the model always sees White to move, exactly as in the reference implementation. - Inputs
(boards, elo_self, elo_oppo)are run through the ONNX model. - Illegal-move logits are masked out, the rest are soft-maxed, and the most probable legal move is played (after un-mirroring for Black).
- The value head gives a win probability, reported as a UCI centipawn score.
The pretrained weights are pinned in this repository under models/ via
Git LFS:
models/maia2-rapid.onnx,models/maia2-blitz.onnx— exported ONNX graphs (what the engine runs).models/rapid_model.pt,models/blitz_model.pt— the original PyTorch checkpoints, kept for provenance.models/all_moves.txt,models/elo_dict.json,models/config.yaml— the move vocabulary, Elo buckets and model config.
After cloning, fetch them with:
git lfs install
git lfs pullThe original weights are distributed by the Maia-2 authors:
https://github.com/CSSLab/maia2 (rapid/blitz checkpoints on Google Drive,
downloaded by model.from_pretrained).
python/export_onnx.py downloads the official checkpoints and re-exports the
ONNX graphs; python/gen_fixtures.py regenerates the test fixtures:
python -m venv .venv && . .venv/bin/activate
pip install "torch==2.4.0" maia2 onnx onnxruntime gdown "chess==1.10.0"
python python/export_onnx.py --type both
python python/gen_fixtures.py --type bothcargo testThe suite checks Rust against the Python reference:
- Encoding parity — the board tensor, Elo buckets and legal-move vocabulary indices match the reference exactly (no model weights needed).
- Inference parity — for a battery of positions (including castling, en passant, promotions, Black-to-move mirroring and endgames), the Rust ONNX pipeline reproduces the reference best move, win probability and per-move probability distribution. These are skipped automatically if the ONNX weights are not present.
Golden fixtures are generated from the original PyTorch model by
python/gen_fixtures.py and stored in tests/fixtures/.
This project is licensed under the MIT License.
Third-party components, all permissively licensed:
- Maia-2 model and weights — MIT (CSSLab, University of Toronto).
- Fathom Syzygy probing code (via
fathom-syzygy) — MIT. chessmove generation — MIT.ort/ ONNX Runtime — MIT / Apache-2.0.
No GPL-licensed engine code (Stockfish, Lc0, the original Maia engine) is used.
If you use Maia-2, please cite the original work:
@inproceedings{tang2024maia2,
title = {Maia-2: A Unified Model for Human-AI Alignment in Chess},
author = {Tang, Zhenwei and others},
booktitle = {Advances in Neural Information Processing Systems (NeurIPS)},
year = {2024}
}