Add chess.chesstb: pure-Python prober for chesstb endgame tablebases - #1194
Open
noobpwnftw wants to merge 1 commit into
Open
Add chess.chesstb: pure-Python prober for chesstb endgame tablebases#1194noobpwnftw wants to merge 1 commit into
noobpwnftw wants to merge 1 commit into
Conversation
noobpwnftw
force-pushed
the
add-chesstb-tablebases
branch
7 times, most recently
from
July 1, 2026 01:08
a252d4c to
228bac6
Compare
noobpwnftw
force-pushed
the
add-chesstb-tablebases
branch
22 times, most recently
from
August 15, 2026 02:34
836a63f to
838e047
Compare
noobpwnftw
force-pushed
the
add-chesstb-tablebases
branch
from
August 16, 2026 16:17
838e047 to
661cc2e
Compare
noobpwnftw
force-pushed
the
add-chesstb-tablebases
branch
17 times, most recently
from
August 21, 2026 04:47
2d1fc4e to
8126129
Compare
chesstb tables store WDL, DTZ, DTC, DTM and DTM50 for endgame material configurations. This adds a pure-Python reader for the format, shaped like chess.syzygy and chess.gaviota: open_tablebase() on a directory tree, then probe() a board for every value at once, or probe_wdl / probe_dtz / probe_dtc / probe_dtm / probe_dtm50 for one at a time. DTZ measures what syzygy's does but is encoded differently and the two are not interchangeable: syzygy bases its cursed band off 100, so a DTZ magnitude carries the WDL class along with the count, while a chesstb DTZ is a plain distance in every class. The format, as read here: one table per material configuration, split by side to move and compressed in blocks (LZMA, or LZ4 with an optional dictionary prefix). Positions are addressed by a combinatorial index over king symmetry classes and groups of identical pieces. Configurations of equal strength share a single table, read through a rank-mirrored, colour-swapped view of the position; one holding an opposing pawn pair may also ship a smaller 'p' table, which is preferred when present, and whose index counts only the free pieces. A table may omit one side to move altogether, in which case its values are recovered by minimaxing over legal children. En passant is not indexed and is applied as a virtual-capture overlay at probe time. Two of the metrics ship as changepoint packs, which store a value per layer of a stack and mark a drawn layer with a hint bit rather than a distance, so the prober builds both the 256-position prefix index and the hint bitmap the layout leaves out. DTM50 layers its values by halfmove clock. DTC layers them by how many pawn pushes the winning side may still spend, and answers a pair -- the pushes it still owes before a capture or promotion, and the plies to the next zeroing move on the line that owes them -- so a DTC read decodes a whole record where a DTM50 read takes one layer of it. Each pack embeds an unbounded table as its first row and so makes it redundant: DTM comes out of DTM50's, DTZ out of DTC's, and a standalone table is read only for material shipping no pack. DTC packs exist for pawnful material; with no push to budget every zeroing move is already a conversion, so the answer there is DTZ's own number at nothing owed. Tables are memory-mapped and decoded a block at a time, with a shared LRU holding decoded blocks across every open table against one budget. _TableFile._open_source is the single seam where the transport is decided, and the table classes are named as class attributes, so serving tables from something other than a mapping is a subclass per kind and a _find. A Tablebase is safe to share between threads: probes register as readers so close() can wait for them before unmapping, and table opens are double-checked under a per-kind lock. Positions with castling rights are rejected with MissingTableError, as chess.syzygy and chess.gaviota do for the same input; the tables are built without them. Positions are assumed legal, as they are on the C++ side. Includes docs, tests, and small table sets for seven materials (KBK..KRKR) as test data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
noobpwnftw
force-pushed
the
add-chesstb-tablebases
branch
from
August 21, 2026 05:02
8126129 to
56cb5da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add
chess.chesstb: pure-Python prober for chesstb endgame tablebasesWhat this adds
A new module,
chess.chesstb, that probes the chesstb endgametablebase format directly from
chess.Boardpositions — in the same spirit asthe existing
chess.syzygyandchess.gaviotamodules.chesstb ships five table types per material:
.lzw.lzdtz.lzdtc.lzdtm.lzdtm50Each pack embeds an unbounded table as its first row, which makes that table
redundant wherever the pack ships: DTM comes out of the DTM50 pack, DTZ out of
the DTC pack. Both are read, with the pack preferred, so material shipping only
.lzdtmstill answersprobe_dtmand material shipping only.lzdtzstillanswers
probe_dtz.Files are looked up in the
wdl/,dtz/,dtc/,dtm/anddtm50/subdirectories of each search directory, and in the directory itself, so a flat
dump of table files is also probeable.
API
get_wdl/get_dtz/get_dtmare non-raising variants returning a default(
None) when no table is available.probe(board, rule50=0)returns the fullstructured result — every metric at once, off a single walk.
probe_dtzmeasures what syzygy's does, but the two are not interchangeable.Syzygy bases its cursed band off 100 —
n > 100is a cursed win whose zeroingmove is
norn - 100plies away — so a DTZ magnitude carries the WDL classalong with the count. A chesstb DTZ is a plain distance in every class, the
class being WDL's alone, so substituting one for the other silently changes
what the number means.
DTC, for readers who have not met it
DTZ prices every pawn push at 1, so it reads 1 almost everywhere a pawn can move
and cannot tell a useful push from a wasted one. DTC prices the pushes
separately from the waiting:
orderis how many of its own pawn pushes thewinning side still owes before a conversion (a capture or a promotion), and
valueis the plies to the next zeroing move on the line that owes them. Thekey is pushes first, then plies — a line that converts without touching a pawn
beats one that spends a push, even when the push is quicker.
Fewer pushes cost a longer wait, so the halfmove clock decides how many are
affordable, and where no budget fits the clock the answer is a 50-move draw:
Packs exist for pawnful material only: with no push to budget, every zeroing
move is already a conversion, so the answer is DTZ's own number at nothing owed.
Why pure Python
chess.syzygyis pure Python; this follows suit. The module depends only onpython-chessand the standard library:lzmawithFORMAT_RAW(the C++ side uses the LZMA SDK with props appended at each block tail).
(~40 lines) supporting the optional LZ4 dictionary. No new dependency.
The position index (symmetry canonicalization, king/pawn slice managers, the
binomial piece-group ranking, the radix-composed board index and the
index-permutation layout) and the probe orchestration (dropped-frame one-ply
minimax reconstruction for shrunk files, the en-passant overlay, and the layer
selection both packs do at a halfmove clock) are faithful re-implementations of
the C++
src/probelibrary. Square numbering already matches python-chessexactly (a1=0 … h8=63), so boards are consumed directly.
Table kinds that share a layout share a reader here, as they do on the C++ side.
DTZ and DTM are byte-for-byte twins on disk — as
src/probe/dtm_file.cppsaysof its own traits — so one reader carries both, with the magic and the value
decode as the whole of what separates them. DTC and DTM50 likewise share the
changepoint container (header, block decode, the prefix indexes) and differ only
in what a record means: DTM50 takes one layer per read and halves its value by
the parity its class implies, while DTC decodes a whole record, since its answer
is a pair and its ranks hold plies outright.
Design notes
with one shared LRU holding decoded blocks across every open table against a
single budget (
open_tablebase(..., block_cache_bytes=...))._TableFile._open_sourceis the only place thetransport is decided, and the file classes are named as class attributes on
Tablebase, so serving tables from something other than a mapping is asubclass per kind and a
_find. Nothing above that seam asks for a span widerthan one block.
close()can wait for thembefore unmapping; table opens are double-checked under a per-kind lock, and
block decoding is guarded per
(color, block)rather than per table.Validation
Every value is validated bit-for-bit against the reference C++ prober
(
tools/probe_fen) by enumerating positions and comparing WDL, DTZ, DTC, DTMand DTM50 in lockstep:
0 mismatches.
materials (exercising the CONST/SINGLE/DOUBLE/MULTI changepoint state machine,
the draw-end hint, and
recover_mate_at_hmc) — 0 mismatches.and the asymmetric one-ply-minimax derive (109 of 145 materials ship a dropped
frame) — 0 mismatches.
Black) — verified against the oracle.
reference
lz4C library on every shipped WDL table.The standalone DTM reader was validated the same way, over random positions and
short playouts across the seven fixture materials (which reach en-passant
squares, mates, stalemates and every capture/promotion sub-material), against
probe_fenpointed at the same directories — 0 mismatches in each shape thetable can take on disk:
chesstb --builddtmshrinktranscribe --loss-onlywdl/+dtz/DTC was validated the same way, comparing WDL, DTZ and the DTC pair field by
field — the pack answers DTZ too, so a wrong embedded row shows up as a DTZ
mismatch:
shrink-dropped (derive path)transcribe --loss-onlypacks (win derives)KNNKP is in that list for the cursed band: it is the smallest pawnful material
whose zeroing distances run past 100 (longest win 164), so its pack is the one
that exercises the 2-byte rank tier and the row values a budget cannot settle.
MULTI records are ~0.1% of even a KPPK block, so they were reached by
classifying sampled positions by record state and comparing that bucket rather
than by sampling alone.
The last row is the case that separates a derive from a read: a stored cell
carries no en-passant rights, so a reduced frame rebuilding a position whose
double-push child hands the opponent an ep capture has to price that reply the
way generation does. It needs pawns of both colours to arise at all, which the
single-pawn materials above cannot produce.
Tests
ChesstbTestCaseintest.py, against table sets for seven materials(KBK … KRKR) committed under
data/chesstb/,shrink-processed as they wouldship — the DTC pack for KPK included, so the white-to-move answers there run
through the one-ply derive rather than a read.
The logic is proven on the C++ side and by the sweeps above, so these tests
target what is specific to this port rather than re-proving the format: the
per-kind wiring and directory search, the shared DTZ/DTM reader and its value
decodes, rejection of malformed files (and release of the mapping a failed open
had taken), the changepoint decoder at a live clock, the DTC pair against the
clock that picks it (with the DTZ table it embeds, read with no
dtz/in thepath at all), the block-cache budget, the mmap lifecycle, the transport seam,
and probing concurrently from several threads.