Instructions to use shubhxho/sable-chess-net with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use shubhxho/sable-chess-net with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] huggingface-cli download --local-dir sable-chess-net shubhxho/sable-chess-net
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
- Sable โ a 60 KB standalone chess evaluation network
Sable โ a 60 KB standalone chess evaluation network
The complete evaluation function for the Sable chess engine, distilled from that engine's own search and trained with MLX on Apple silicon.
60,976 bytes. It is the entire evaluation โ there is no hand-crafted term underneath it, and no framework needed to run it.
934 features -> 64 hidden (per perspective, shared) -> clipped ReLU -> 1 of 8 output buckets
The engine around it plays at roughly 2800 Elo, anchored against Stockfish's
UCI_Elo settings. That anchor is worth about ยฑ40, for reasons set out under
Playing strength.
If you read one section, make it the output gain. A single constant multiplying the output layer โ which cannot change which position the network prefers โ was worth about 60 Elo, and getting it wrong had been poisoning every architecture comparison in this project for months.
What this is, in one paragraph
It's the evaluation function out of a chess engine I wrote. The engine searched
its own games, and this network was trained to guess what that search would have
said without doing the search. It's 60 KB of int8 weights, it runs on integer
SIMD with no framework underneath it, and it is not a PyTorch model โ you can't
from_pretrained it. If you want to use it, you want
the engine; if you want to read it, the
format section is complete enough to parse net.bin in twenty lines
of NumPy, which is included below.
- Developed by: @shubhxho
- Model type: quantised int8 feedforward evaluation network (NNUE-style), distilled from tree search
- Inputs: 934 sparse binary features per side, computed by the engine
- Output: one scalar, centipawns, from the side to move's point of view
- Trained with: MLX on Apple silicon
- License: MIT
- Repository: https://github.com/shubhxho/sable
What it's for, and what it isn't
Use it for: running the Sable engine; reading a small, complete, honestly documented example of a quantisation-aware distilled evaluation; lifting the format or the training loop for your own engine. The whole thing is MIT and I'd be glad to see it reused.
Don't expect it to: work as a general chess model, produce moves on its own, or load into a transformers pipeline. It has no notion of a legal move. Hand it a position and it returns a number; everything that makes that number useful โ move generation, search, pruning, time management โ lives in the engine, and the number is close to meaningless without it. The gain section below is a long argument for exactly that point: the same weights are worth a hundred Elo more or less depending on the search wrapped around them.
Bias and risk, honestly: it's a chess evaluator. The realistic harm is someone cheating at online chess with it, which is true of every engine ever published and which this one is far too weak to be attractive for. The more interesting caveat is epistemic: it was distilled entirely from its own search, so it has inherited that search's blind spots and there is no external teacher anywhere in the loop to catch them.
The input set is the whole story
The obvious design uses the standard NNUE input: 768 binary features, one per (piece, colour, square). I built that first. At this size it played 165 Elo worse than the hand-crafted evaluator it was supposed to replace, which was a memorable afternoon.
The instinct is that it's too small. It isn't. Sweeping the hidden layer from 16 to 128 neurons โ an 8x range โ barely moves the fit against the teacher; r sits near 0.93 the whole way. That flatness is the finding: capacity was not the binding constraint.
It was not nothing, either, and it took a later result to separate the two. Every width comparison here predates the output gain below, so each one measured a wide network against a differently-scaled narrow one. Held at a fixed gain, 64 neurons beat 32 by +12.5 Elo [+0.1, +24.9] over 3000 games and 128 beat 64 by nothing at all. The plateau is real; it starts one doubling later than this sweep said, and the fit numbers never showed the difference.
Here's the actual problem. Piece-square features describe where pieces are, and almost everything that decides a chess position is about where they can go. A knight on d5 is worth wildly different amounts depending on what it attacks. A rook is worth much more on an open file. Neither fact is recoverable from a one-hot square index, no matter how wide you make the layer behind it โ the information simply isn't in the input.
So the budget went into the input instead of the hidden layer. Alongside the 768 piece-square planes sit 166 rows encoding mobility, passed pawns by rank, isolated and doubled pawns, rooks on open and half-open files, the bishop pair, king attackers and king shelter โ all computed from the board by the engine and looked up in the same embedding table. Each row costs 64 bytes.
| Input set | Size | r vs teacher | MAE | RMSE |
|---|---|---|---|---|
| Hand-crafted evaluation (baseline) | โ | 0.937 | 96.3 cp | 191.6 cp |
| 768 piece-square features | 24.6 KB | 0.955 | 90.3 cp | 161.3 cp |
| 934 features, with mobility and structure | 29.8 KB | 0.970 | 79.8 cp | 130.1 cp |
Same 32 neurons, same optimiser, same data. Five kilobytes of extra input beat four times the hidden width.
Playing strength
Everything here is measured from randomised openings with colours swapped on every pair. Fixed node counts are the default, because they don't move with machine load; where a clock is used it says so.
This network against the one it replaces
| Conditions | Games | Result |
|---|---|---|
| 20,000 nodes/move, three independent opening sets | 3000 | +62.6 ยฑ 12.6 |
| 100ms/move | 800 | +42.3 ยฑ 24.3 |
| 300ms/move | 400 | +51.6 ยฑ 34.4 |
The three fixed-node sets were +62.9, +65.7 and +59.3, which agree far more closely than most results in this project โ that is what an effect well clear of the noise floor looks like. The clock figures are lower because this network is 8% slower per node and a node-limited match hides that by construction; about twenty of the sixty-three Elo is the harness being generous.
Across search budgets
The output gain that produces most of that win was tuned at 20,000 nodes, so the obvious worry is that it only pays there. 1000 games at each budget, same opponent:
| Nodes/move | Result |
|---|---|
| 5,000 | +27.5 ยฑ 21.6 |
| 10,000 | +55.4 ยฑ 21.8 |
| 20,000 | +62.6 ยฑ 12.6 |
| 50,000 | +63.2 ยฑ 21.9 |
| 100,000 | +62.5 ยฑ 21.9 |
| 200,000 | +55.0 ยฑ 21.8 |
Flat from 20k out to 200k, ten times past the tuning point. What falls away is the shallow end, which is the right direction: a shallower search prunes less and consults the static evaluation less often.
Against every older build
600 games each at 20,000 nodes against the historical binaries, plus a 400-game self-play control to check the harness. The previous-release row is the pooled 3000-game result from above, not a 600-game match:
| Opponent | Result |
|---|---|
| the same binary, both sides (control) | +5.2 ยฑ 34.1 |
| the previous release | +62.6 ยฑ 12.6 |
sable-new |
+112.7 ยฑ 29.3 |
sable-old, sable-std |
+130.3 ยฑ 29.8 |
sable-net |
+132.9 ยฑ 29.9 |
sable-net-v1 |
+150.7 ยฑ 30.5 |
sable-hce, the hand-crafted evaluator |
+156.2 ยฑ 30.7 |
The control is the row that makes the others readable โ zero sits comfortably
inside its interval, so colour swapping and pair ordering aren't leaking an
advantage. sable-old and sable-std return byte-identical scores because they
evaluate every position identically and therefore play identical games.
On an outside scale
Against Stockfish under UCI_LimitStrength, 300 games at each of five
settings, 100ms a move:
Stockfish UCI_Elo |
Score | Implied |
|---|---|---|
| 2600 | 0.772 | 2812 |
| 2700 | 0.638 | 2799 |
| 2800 | 0.472 | 2780 |
| 2900 | 0.410 | 2837 |
| 3000 | 0.328 | 2876 |
Call it 2800, and mean it loosely. A maximum-likelihood fit over all 1500 games says 2819 ยฑ 19; the point where the score actually crosses 0.5 says 2783. Quote the range, not either end โ ยฑ40 is honest, ยฑ19 is not.
They disagree for a reason worth knowing if you ever calibrate anything this
way. The one-parameter fit leaves residuals that drift monotonically with the
setting (-0.008, -0.027, -0.056, +0.024, +0.067), meaning this engine loses less
to Stockfish's strongest settings than the logistic model predicts. Letting the
slope float fits it at 0.83 โ a hundred of Stockfish's nominal points behave like
roughly eighty-three real ones across this range. UCI_LimitStrength hits its
target by degrading play in discrete internal steps, so its scale has no
particular reason to be linear, and measured here it isn't.
That is why the 0.5 crossover is the defensible number: two engines scoring 0.5
against each other are equal by definition, and that point doesn't depend on the
slope being correct. This locates the engine on someone else's approximate scale
rather than rating it, and it is not a CCRL or FIDE number.
tests/calibrate.py reproduces all of it.
Older results, kept for the record
These decided the shape of the network and are not measurements of what ships now. Match lengths were much shorter, which is why the intervals are so wide:
| Matchup | Result |
|---|---|
| 768-feature net replacing hand-crafted eval | โ165 ยฑ 69 Elo (200 games) |
| 768-feature net correcting hand-crafted eval | +57 ยฑ 28 Elo (600 games) |
| 934-feature standalone net vs hand-crafted eval | +35 ยฑ 34 Elo (400 games) |
| 934-feature standalone vs the 768-feature hybrid | โ3 ยฑ 34 Elo (400 games) |
| the rescaled 32-neuron net vs the previous release | +59.6 ยฑ 21.9, +52.2 ยฑ 21.8 (2000 games) |
| this 64-neuron network vs that | +12.5 [+0.1, +24.9] (3000 games) |
The fourth row is the one that decided the architecture: the standalone network was statistically indistinguishable from the hybrid while carrying no hand-crafted evaluation at all. Worth being straight about โ it fit the teacher much better (RMSE 130 vs 161 cp) without out-playing it, and 400 games could never have resolved a difference that small.
The output gain
This is the part worth reading even if nothing else here interests you.
A network distilled from a search learns to reproduce that search's score, and that includes reproducing its spread. Measured over 20,000 positions, the previous release evaluated with a standard deviation of 549 centipawns where its teacher sat at 654 โ it had been quietly understating every position for its whole life. Retraining the same architecture on the same data fixed that, landing at 642, and improved every fit statistic: r from 0.9794 to 0.9811, mean error from 102cp to 82cp.
That better network lost by 38.0 ยฑ 21.7 over 1000 games.
Multiplying its output layer by a constant is the only thing that then separates the two. It cannot reorder the network's preferences โ r does not move โ it only changes how loud the evaluation is. Swept at 1000 games each against the previous release: gain 1.00 gives -38.0, 0.90 gives +7.0, 0.80 gives +43.3, 0.70 gives +59.6, 0.60 gives +58.6, 0.55 gives +47.9.
A hundred Elo across that curve, with the network knowing exactly the same things at every point on it. The mechanism is that a search never consumes a static evaluation alone โ it compares it against margins, in centipawns, for reverse futility, razoring, null-move verification and late-move reductions. Those margins were tuned against an evaluation that happened to speak quietly. Fix the network's calibration without fixing them and every threshold fires in the wrong place.
That is testable, and tested. Taking the natural-scale network and widening all five margins together recovers 56 Elo of the 83 it otherwise loses (-82.8 at the tuned values, -45.1 at 1.43x, -26.5 at 2x, 1000 games each). So the margins are about two-thirds of the effect. The rest is presumably the eval-scale quantities that scaling five constants doesn't reach โ the aspiration window, the correction-history tables, and every static evaluation stored in the transposition table โ all of which one constant on the network fixes at once.
It ships at OUT_SCALE = 0.70, applied to the output layer at export rather than
to the score in the engine, so this file stays the single description of what the
engine computes. Rerunning the sweep against the 64-neuron network put 0.55
through 0.80 all within noise of 0.70 across another 5000 games: the plateau is
wide and did not move with the architecture. The 60 Elo comes from not being at
1.00, not from finding a precise value.
Architecture
- Perspective pairing: features are built twice per position, once from each side's point of view, with squares mirrored and colours relabelled so block 0 is always "mine". One weight matrix serves both sides, so the network learns a single function of "my position" rather than two of "white's position".
- Weights: int8 feature transformer (
QA = 127), int16 biases, int8 output layer (QB = 64), output scaled to centipawns bySCALE = 400. - Output buckets: 8 output layers selected by remaining material. The feature transformer stays shared โ what changes across a game is how the same signals should be weighed, not what they are.
- Inference: ARM NEON intrinsics (
vmovl_s8,vmlal_s16,vaddvq_s32).
| Tensor | Shape | Type | Bytes |
|---|---|---|---|
ft_w |
934 x 64 | int8 | 59,776 |
ft_b |
64 | int16 | 128 |
out_w |
8 x 128 | int8 | 1,024 |
out_b |
8 | int32 | 32 |
| header | magic, inputs, hidden, buckets | uint32 | 16 |
| total | 60,976 |
Feature-space layout
| Rows | Block | Meaning |
|---|---|---|
| 0โ767 | piece-square | (relative_colour, piece_type, square) |
| 768โ863 | mobility | (relative_colour, N/B/R/Q, moves 0..11), one per piece |
| 864โ879 | passed pawns | (relative_colour, rank), one per passed pawn |
| 880โ887 | isolated pawns | (relative_colour, count 0..3) |
| 888โ895 | doubled pawns | (relative_colour, count 0..3) |
| 896โ901 | rooks, open file | (relative_colour, count 0..2) |
| 902โ907 | rooks, half-open | (relative_colour, count 0..2) |
| 908โ909 | bishop pair | (relative_colour) |
| 910โ925 | king attackers | (relative_colour, attackers 0..7) |
| 926โ933 | king shelter | (relative_colour, pawns 0..3) |
Output bucket, which must be reproduced exactly, integer division included:
bucket = min((max(pieces_on_board - 1, 0) * 8) // 32, 7)
Training
The teacher is the engine's own alpha-beta search โ the distillation principle behind DeepMind's searchless grandmaster-level chess, at a size that fits in L1 cache rather than a TPU pod. The student never searches.
Data: 10.2M positions from engine self-play out of randomised openings of 8 to 16 plies, labelled at 3k to 6k nodes/move, deduplicated by FEN across every generation run ever made. The first two plies of real play are skipped โ those are the engine repairing whatever the random opening did.
This supersedes the previous release, which trained on 3.4M positions from a single generation on the theory that one teacher beats an average of several. Measured over 3000 games, that theory is worth -15.5 Elo: training on everything, older labels included, beats training on the newest shard alone by +15.5 with 95% confidence [+5.6, +25.5]. The older labels are weaker but they are not noise, and there are seven million of them.
Filtering: positions are dropped when the side to move is in check or the best move is a capture. There the tactic decides the game, not the static evaluation, and training on them only teaches the network to imitate search โ which it has no mechanism to do.
Objective: MSE in win-probability space,
sigmoid(net / 400)against0.9 * sigmoid(search / 400) + 0.1 * result.Optimiser: AdamW, batch 16384, lr 1e-2 with one warmup epoch then cosine decay over 15 epochs. 5% of positions are held out; the exported network is the epoch that did best on them, not the last one.
Output gain: the exported output layer is multiplied by
OUT_SCALE, 0.70. This is not part of the objective and it does not change which position the network prefers; it only makes every evaluation quieter by a constant. A network trained to reproduce a search's score reproduces its spread as well, and the search plays substantially worse when handed one. The same network exported at gain 1.00 loses 38.0 ยฑ 21.7 to the previous release; at 0.70 it wins by 59.6 ยฑ 21.9. See README.md for the full sweep.
Data volume is not the constraint either: retraining on the full 3.36M against 2M moves the fit by nothing worth reporting (r 0.970 -> 0.968, RMSE 130.1 -> 130.8 cp). Between that and the width sweep, the feature set was the only thing that ever mattered.
A second iteration of the same idea did not pay off. Four million fresh positions, labelled by the network below and the search that ships with it, produced a network that lost to its own teacher by 20.0 +/- 24.1 over 800 games and 24.4 +/- 21.6 over another 1000 โ about 22 Elo down across 1800 games, twice in a row. Mixing those shards with the previous round's (6M positions in total) landed at +4.3 +/- 24.1, and doing the same with bucket-balanced sample weights at +4.9 +/- 21.5: nothing, either way.
The overlap between rounds was the missing piece. Self-play deduplicates within a generation run but not across them, so a mixed set grades the shared openings twice, with the older and weaker teacher's label surviving. Deduplicating across shards and keeping the newer label on the overlap gives +12.9 +/- 21.5 over 1000 games and +11.9 +/- 19.7 over 1200 โ about +12 across 2200 โ and that is the network described here.
Weighting older shards down as well (SHARD_DECAY below 1) loses 24.0 +/- 21.6
and stays off by default. The old positions carry their weight; only their
labels were stale. One round of relabelling against a
stronger search was worth about 23 Elo and the next round was worth zero, so
the gain came from the teacher's jump in strength rather than from iterating,
and there is no free ladder here.
What did move: the teacher. Relabelling from scratch with a search roughly 30 Elo stronger, at 6k nodes instead of 5k and with duplicates removed, produced a network that beats the one it replaces by +23.5 ยฑ 24.1 Elo over 800 games, and by +23.0 ยฑ 21.6 over a further 1000 โ the same margin twice. Its fit numbers against that harder, less repetitive data (r 0.974, MAE 85.1, RMSE 137.7 cp) are not comparable to the table above, which was measured on the old shards โ a better teacher gives you harder targets, so a bigger residual against a better opponent is the expected shape of an improvement.
Features come from the engine, never from the trainer
The trainer doesn't compute features. It asks the engine for them, through a
featdump command that dumps the active indices for each position, and reads
them back.
This is worth the awkwardness. Two implementations of one feature map is a bug
class where the trainer and the engine quietly disagree about what feature 431
means, and what you get is a network that loads cleanly, runs at full speed, and
plays slightly badly for reasons nothing will point you at. I would rather pipe
a gigabyte of indices through a subprocess than debug that. src/net.rs is the
single source of truth for both sides.
Quantisation-aware by construction
Weights are projected back into the int8 box after every optimiser step, never rounded at the end:
model.ft = mx.clip(model.ft, -127.0 / QA, 127.0 / QA)
model.out = mx.clip(model.out, -127.0 / QB, 127.0 / QB)
So the exported network computes the function the trainer actually converged to, rather than a rounded-off approximation of it.
Verified rather than asserted: net.bin gets replayed through an independent
NumPy reference that reproduces the Rust inference operation for operation, and
the two agree on 80/80 test positions. The only disagreement that check has ever
turned up was Python's floor division against Rust's truncation on negative
scores โ which was a bug in the reference, not the engine, and exactly the kind
of thing the check exists to find.
How to get started
The fastest path is the engine itself:
git clone https://github.com/shubhxho/sable && cd sable
cargo build --release
./target/release/sable # then speak UCI, or type `bench 13`, `eval`, `d`
net.bin is baked into the binary with include_bytes!, so the build already
contains this network โ there's nothing to download at runtime. To read the
weights directly instead, see the NumPy snippet under Format.
Format
Little-endian, tightly packed, no framework dependency:
magic u32 0x334C4253 ("SBL3")
inputs u32 934
hidden u32 64
buckets u32 8
ft_w i8[934 * 64] row-major [feature][neuron]
ft_b i16[64]
out_w i8[8 * 128] row-major [bucket][neuron];
within a bucket, first 64 = side to move,
last 64 = opponent
out_b i32[8]
The header carries inputs, hidden and buckets, so read those rather than
hardcoding them โ this network was 32 hidden neurons until recently and the
loader rejects a file whose header disagrees with the build rather than
misreading it.
import struct, numpy as np
b = open("net.bin", "rb").read()
magic, IN, H, B = struct.unpack("<IIII", b[:16]); o = 16
ft_w = np.frombuffer(b[o:o+IN*H], np.int8).reshape(IN, H); o += IN*H
ft_b = np.frombuffer(b[o:o+2*H], np.int16); o += 2*H
out_w = np.frombuffer(b[o:o+B*2*H], np.int8).reshape(B, 2*H); o += B*2*H
out_b = np.frombuffer(b[o:o+4*B], np.int32)
# given active feature indices per perspective and the piece count
acc = lambda idx: np.clip(ft_b.astype(np.int32) + ft_w[idx].sum(0), 0, 127)
k = min(max(pieces - 1, 0) * B // 32, B - 1)
total = int((np.concatenate([acc(us), acc(them)]) * out_w[k]).sum()) + int(out_b[k])
centipawns = int(total * 400 / (127 * 64)) # truncate toward zero
Reproducing
cargo build --release
for i in $(seq 1 9); do
./target/release/sable <<< "datagen 400000 5000 $((i*7919))" > data/shard$i.txt &
done; wait
python train.py 10220706 15 # dumps features via the engine, writes net.bin
# NET_H=64 and OUT_SCALE=0.70 are the defaults
cargo build --release # net.bin is include_bytes!'d into the binary
cp target/release/sable sable-std
# The network is embedded at compile time, so "no network" means building with
# a header the loader rejects; it then falls back to the hand-crafted eval.
cp net.bin /tmp/net.keep
printf '\0\0\0\0\0\0\0\0' > net.bin
cargo build --release && cp target/release/sable sable-hce
cp /tmp/net.keep net.bin && cargo build --release
python arena.py ./sable-std ./sable-hce 400 "nodes 20000" 9
The two comparison binaries are build artefacts, not repository contents โ
.gitignore covers sable-* precisely so a stale one cannot be mistaken for
the current engine.
Limitations
- Distilled from itself. The ceiling is the engine's own search quality rather than a stronger reference. Stockfish appears in this repository only as a measuring stick; nothing it plays has ever been trained on.
- The 2800 figure is an anchor, not a rating. Five Stockfish settings imply
ratings spread across 96 Elo, and Stockfish's own
UCI_Elocalibration is approximate and fitted at longer time controls than the 100ms used here. - Computing mobility and king-attacker features costs throughput: the engine
runs about 3.3 Mnps at
bench 13on one M-series core, and widening the hidden layer to 64 neurons cost 8% per node on its own. A direct-mapped cache of finished evaluations did most of it: the search asks about the same position often enough (transpositions, re-searches, null-move verification) that a good deal of the feature extraction was repeat work. The rest came from answering the pawn-structure questions for the whole board with file fills instead of pawn by pawn. The network build being the faster of the two is not a claim that a network is cheaper than a hand-crafted evaluator; it is that the cache and the extraction rewrite between them now more than cover the difference. - Accumulators are refreshed in full rather than updated incrementally. At 64 neurons a matrix row is eight NEON registers, and most of the 166 non-piece- square rows change on almost every move anyway, so an incremental update would only cover the piece-square part. The eval cache took the easy half of that win for a fraction of the complexity, and the refresh itself now keeps both perspectives in registers for the whole feature list rather than storing the accumulator back to memory once per row.
Environmental impact
Rounding to something honest: about six minutes of Apple M-series GPU time per training run, on hardware that draws roughly 20W doing this. Call it 1.4 gCO2eq โ a gram and a half, less than boiling a mug of water. The 10.1M-position dataset it trains on took considerably longer to generate than the network takes to train, and the arena matches behind the Elo figures in this card dwarf both: several tens of thousands of games at 20,000 nodes each. If you want the real carbon cost of this project, it's in the measurement, not the training.
Citation
@software{sable_chess_net,
author = {shubhxho},
title = {Sable: a 60 KB distilled chess evaluation network},
year = {2026},
url = {https://github.com/shubhxho/sable},
note = {Trained with MLX on Apple silicon; int8 quantisation-aware}
}
Contact
Issues and questions: https://github.com/shubhxho/sable/issues
License
MIT.
Evaluation results
- Elo at 20,000 nodes/move on Randomised 8-ply openings, colours swapped every pairself-reported62.600
- Elo at 100ms/move on Randomised 8-ply openings, colours swapped every pairself-reported42.300
- Elo at 300ms/move on Randomised 8-ply openings, colours swapped every pairself-reported51.600
- Implied Elo on Stockfish's UCI_Elo scale (0.5 crossover) on Stockfish with UCI_LimitStrength, five settings from 2600 to 3000self-reported2800.000
- Elo vs the hand-crafted evaluator it replaced on Every historical binary kept in the repositoryself-reported156.200
- Elo vs the first network release on Every historical binary kept in the repositoryself-reported150.700
- Self-play control, same binary both sides on Every historical binary kept in the repositoryself-reported5.200
- Correlation with teacher score (invariant under the output gain) on 20,000 held-out positions labelled by the engine's own searchself-reported0.980