Skip to content

Latest commit

 

History

1,017 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pygin

A from-scratch chess engine in Python + C. The search is hand-written. Since v58 the evaluation is an HCE/NNUE hybrid: the neural net scores positions inside the main search, and the hand-crafted eval keeps quiescence. Nothing calls out to another engine at runtime: the search, the eval and the net all run in this repo's own code.
python-chess is used only for board representation, move generation and legality.

Strength Speed Versions C--era_gains Source  ·  Built with Claude Code

At a glance

retractedstrength: see Measured strength5.65M npsthe net costs ~28% of it
~+360 EloA/B-confirmed, v31→v64~18 plyfrom startpos in 5 s
+93.87 Elov58→v64, measured directly1.09×single-thread vs v31
v53+v54 eval lane+37.52 & +31.20, the two biggest1 dependencypython-chess only
Cumulative A/B Elo across the C era, v31=0 climbing to +360 at v64 Single-thread speed as a multiple of v31, peaking at 1.55x and ending at 1.09x
Mate-finding on mates2000.epd across the C era, rising from 24.0% at v31 to a 52.1% plateau, then settling at 43.0% at v64

All three charts are self-play. Every C-era version (v31 and up) is A/B-tested against the one before it, and the gains stack to about +360 Elo. Single-thread speed peaked at 1.55× and sits at 1.09× today: v58 hands about 28% of it to the net and still comes out +19.11 ahead. The v30→v31 C rewrite (~34× faster) is off the left edge, so v31 is the honest zero.

Mate-finding is the one curve that does not track Elo, and it is left in because of that. It climbs to a ~51% plateau by v39, then falls away as the search gets more selective and the net arms — v62 finds 39.9% of mates2000.epd at 0.25s where v51 found 52.1%. Forward pruning and a net that values position over forced sequences both cost mate speed, and every one of those releases still measured POSITIVE in games. A tactical-suite score is a different instrument from an A/B, and where they disagree the A/B is the one that decides.

The odds ladder against full-strength Stockfish is unmeasured, and that is now true for two separate reasons. The pre-2026-08-13 rungs were measured on a harness that erased Stockfish's won endgames, so those figures are withdrawn rather than footnoted; see Measured strength.

Two engines, one eval

cengine.py and csearch.c are the C search core, and the strongest engine here. The whole per-node loop runs in C: board, ordering, TT, pruning, quiescence, and since v58 the NNUE forward pass. Python keeps only the root, which means iterative deepening, time management and the book. That is about 50× the Python core.

The eval is a hybrid. The net scores positions inside negamax, while qsearch stand-pat stays on the hand-crafted eval. The split is deliberate: an earlier all-NN attempt measured -203 to -273 Elo. NNUE_REQUIRE_SIMD keeps the net off CPUs with neither NEON nor AVX2, where the scalar tail costs more than the eval is worth.

engine.py is the reference Python engine, and the single source of hand-crafted eval truth: the C core reads every HCE parameter from it at startup. The net's weights live in NNUE/nets/ instead.

Measured strength

No external strength figure is published. The old ~2868 Elo was retracted 2026-09-05: the run's Elo cap never reached the engine subprocess (T-14), and everything before 2026-08-13 came from a harness that mis-scored repetitions in Stockfish's favour (fixed in fc82cb7). Retracted numbers were deleted rather than footnoted. A replacement needs matches against engines with published ratings; opponents/ and scripts/rating_ladder.py are built for it and no ladder has been run yet.

Odds ladder -- every rung was played against a CAPPED Stockfish (odds.py never applied STOCKFISH_ELO; UCI_Elo 2900 before 2026-08-07, 3000 after, fixed under B-15). A cap compresses real gaps, so none of these is a full-strength reading and the f2 series spans two opponents.

Rung Result Vintage
Pawn odds f2 81.00% / 1,000 games (704W/212D/84L, +251.89 ±35.2) v59, 2026-08-13
Pawn odds h2 (active) 56.75% / 200 games (75W/77D/48L, +47.19 ±38.0) v62, 2026-09-02
Knight, rook, queen odds 100%, saturated v53/v54
vs the Python engine v30 195W / 5D / 0L over 200 games v59, 2026-08-12

h2 is active for resolution: it answers the same question as f2 with 41% fewer games. Why h2 costs Stockfish so much less than the other seven pawns is not explained -- the open-file hypothesis was tested and rejected.

The internal A/B ledger is unaffected: between two Pygins the repetition bug was near-symmetric, so it compressed effect sizes toward zero rather than inflating them.


Version progression

64 versions, each A/B-tested against the one before it. Speed is nodes/s, depth is from startpos in 5 s (book off, best-of-N), and Elo Δ is the A/B result against the previous version. Cumulatively that is ≈ +360 over v31.

The list below has the full per-version speed, depth and Elo, and the charts above summarise it. Regenerate both with bench/bench_progress.py and scripts/make_readme_charts.py.

Every version in full -- complete milestone + Elo list
  • v64 -- a new net, and four settings that only pay together. The evaluation network is replaced, and four search parameters that each measured as nothing on their own cross the accept bound when armed as one change: less late-move reduction (LMR_DIV 170), a sharper capture-pruning threshold (SEE_SCALED_K 75), and two clock settings that let a stable move think slightly longer. (Bundle: +6.19 ±4.1 over 11,896 games at 50+0.5, SPRT[0,4] LLR +2.969 ACCEPT H1 -- its first tranche ran a full 10,000 games at +5.94 ±4.4 with no early stop. Net, measured on top of that bundle so the two are known to work together: +78.82 ±8.3 over 3,004 games at 50+0.5, LLR +9.264 ACCEPT H1 at the minimum, nElo +121.86, pair ratio 3.37. Also carried: a profile-guided build, +4.7% speed on the reference machine, node-identical. Bench 1,123,567 -> 785,901.)
  • v63 -- losing captures near the leaves are no longer searched, and v62's rejected TT rule is gone. FI-38 skips a capture that loses material by SEE when it loses more than 50cp per remaining ply, at depth 6 or less, off the PV, and never in or into check. The core under it is v61's again: the b05 FI-115 completion v62 shipped after it had been rejected is reverted, and a retained-TT probe in selftest.py now pins that rule. (HEAD vs v61 at 50+0.5: +20.95 ±7.9 over 3,006 games, SPRT[0,4] LLR +3.049 ACCEPT H1. FI-38 K=50 on top: +9.50 ±6.0 at 50+0.5 over 6,802 games, LLR +2.961 ACCEPT H1, after +9.34 ±5.3 / LLR +2.946 on the 10+0.1 screen. K=75 and K=100 were NULL at the full budget, so 50 is the setting. Every run stopped early, so the ledger is not advanced; a fixed-budget 50+0.5 run of v63 vs v61 is owed. Bench 1,203,792 -> 1,123,567.)
  • v62 -- the aspiration window stopped fighting itself. The root window opened flat at 30cp whatever the position and widened 2× per failure, and a fail-low left beta untouched -- so a position could oscillate low/high and pay the whole ladder out to the 1920 fallback. Three riders replace that as one policy: the opening delta scales with the previous score, a fail-low pulls beta to the midpoint before alpha drops, and growth is 1.5×. Driver-only, but the C core is not byte-identical to v61 as released: v62 also carried b4339ae+a1a31cd, the b05 FI-115 completion, which had already measured ACCEPT H0 (LLR -5.98) and was reverted after the release. Both arms of the A/B below shared that core, so the +18.40 measures the window honestly; v62-as-released vs v61-as-released was never measured. (+18.40 ±7.6 at 50+0.5 over 3,439 games, 52.65%, ptnml 62/361/735/450/109, nElo +28.25, SPRT[0,4] LLR +2.959 ACCEPT H1 -- and +2.969 ACCEPT on the 10+0.1 screen before it. Both stopped early, so the magnitude is bound-biased and the ledger is not advanced on it; a fixed-budget 50+0.5 run is owed. Bench 1,140,099 -> 1,203,792 -- unlike v61 this one does move the signature, because the window shapes the root search from the first iteration.)
  • v61 -- dead entries in the transposition table are now evicted first. Material is irreversible, so an entry whose stored piece count exceeds the current root's can never occur again -- garbage with certainty, not a guess. v61 stamps that count into 6 spare bits and takes those first, depth-protecting a still-reachable old entry instead of clobbering it on age. Nothing is swept; it is a better choice of victim, made at store time. Also carried: FI-113's two-cache-line TT prefetch, node-identical, +1.09% NPS. (+15.89 ±4.2 over a fixed 10,000-game budget at 50+0.5, 192 MiB both sides, 52.29%, ptnml 146/1044/2226/1375/209, ratio 1.33, nElo +25.77. No early stop, so the ledger advances: +338 -> +354. Measured at the shipped table size deliberately -- the 10+0.1 screen ran 24 MiB to force replacement pressure -- and a cold-TT bench cannot see this rule at all, so the signature stays 1,140,099.)
  • v60 -- the first net trained on real games, not generated ones. The search is byte-identical to v59; only NNUE_FILE moves, to nnue_v12_bf86c4ced057.nnue. Every net before this learned from self-play positions manufactured for the purpose. v12 learned from 24,825,823 positions harvested out of Pygin's own A/B match logs, labelled at depth 12-16 by the search those games actually ran. It is also the first trained with the game-result term off (LAMBDA 1.0) -- a correctness requirement, not a tuning choice: those logs predate the phantom-repetition fix and replay showed 1,479/1,479 of their repetition draws were phantom. (GSPRT[0,4] LLR +2.957 ACCEPT at 793 pairs vs Old Engine/59, TIMED 50+0.5, 56.09% over 1,594 games -> +42.50 ±17.3 -- stopped early, so the magnitude is bound-biased and the ledger is not advanced on it. Against the v10 net it drew over a full 10,000 games.)
  • v59 -- lazy NNUE evaluation, and the first release measured as exactly the config that ships. LAZY_NNUE is armed: the engine skips the net's forward pass wherever a cheap bound already decides the node, spending the saved time on more nodes (bench 1,074,820 -> 1,214,534). The toggle was isolated for the first time -- same v4 net on both sides, nothing else different -- against Old Engine/58 on the corrected harness. (GSPRT[0,4] LLR +2.950 ACCEPT at 2,264 pooled pairs, TIMED 50+0.5 on x86, ptnml 69/509/975/595/116, pooled 51.99% -> +13.84 ±6.4, stopped early so the magnitude is bound-biased -- the verdict is the result. Historical note: v58's own +19.11 turned out to have been measured with this toggle ON while the release ran it OFF; v59 closes that gap by shipping what was measured.)
  • v58 -- the first HCE/NNUE hybrid, and the first net that pays. USE_NNUE is armed on nnue_v4_6f910e35bb1e.nnue: the net replaces the hand-crafted eval inside negamax, while qsearch stand-pat stays HCE. Bench signature 1,145,629 -> 1,074,820, and single-thread NPS drops roughly 30% to the SIMD tail, so the Elo below is measured net of that cost. The interesting part is what changed from v3, which read +0.52 ±6.8 on this same instrument, i.e. nothing at all: not the dataset, not one dimension of the architecture, only the learning-rate schedule. Cosine in place of flat took held-out val 0.074417 -> 0.066663, and that is the whole gain; identical dimensions mean identical speed, so none of it is bought with nodes. NNUE_REQUIRE_SIMD keeps the net off scalar builds, where the ~3x slower tail would make the engine worse. (+19.11 ±7.8 over 3,404 games TIMED 50+0.20 on x86, GSPRT[0,4] LLR +2.950 ACCEPT at 1,702 pairs, ptnml 71/358/691/477/105 -- sixth SPRT accept, and the second-largest release after the v53 Texel retune. An arm64 confirmation is owed: v3 read +5.70 ±4.6 there against +0.52 here, so the architecture spread is real and only x86 has been measured for v4)
  • v57 -- the last pure-HCE release; from here Pygin is an HCE/NNUE hybrid. Host layer only and node-identical to v56 (bench signature 1,145,629 unchanged, ladder node-exact), so no A/B slot was spent and the ledger is untouched. Ponderhit now honours the soft-stop -- a prediction hit used to spend the full fresh budget re-confirming an already-settled move; it now applies the same P-35/U-06 fractions the main search uses (1.666 s → 0.686 s, a ratio of 0.412 against the designed 0.40). The soft-stop neighbourhood is exposed over UCI (SoftStop, SoftStopStable, SoftStopUnstable, SoftStopStableIters) so it can be swept without a rebuild. And a latent bug is fixed: cuci.py restored a hardcoded 0.55 soft-stop fraction over whatever the engine set, which meant any future tuning would have worked in testing and been silently discarded in every real game.
  • v56 -- ProbCut: the fail-high half of forward pruning, which Pygin had no equivalent of. At a shallow non-PV node a qsearch filters each capture at beta + 200 and a real reduced-depth search confirms before anything is cut, so nothing is ever pruned on a static score. Cuts 21.6% of nodes at fixed depth (bench 1,461,732 → 1,145,629). (+11.44 ±6.9 over 5,924 games TIMED 50+0.20, GSPRT[0,4] LLR +2.953 ACCEPT -- the ledger's own instrument; the --nodes campaign that shipped it read +4.11 ±4.2 over 21,806 games (LLR +2.971), so the fixed-node instrument reads CONSERVATIVE. Fifth SPRT accept, and the first pruning MECHANISM to pay since the search lane was declared exhausted: what was exhausted was the parameter space, not the mechanism space)
  • v55 -- node-identical speed pair: FI-11 pin-aware legality + FI-42 the (mg,eg,phase) accumulator on Board. Bench signature UNCHANGED at 1,461,732, perft --deep clean, ladder node-exact -- the search plays the SAME moves, it just gets there faster: +8.3% NPS on x86, +13.5% on arm64. (+9.66 ±8.2 over 6,874 games, TIMED 50+0.20, GSPRT[0,4] LLR +2.946 ACCEPT -- measured on the clock because a fixed-node instrument reads zero for a node-identical change; ~1.16 Elo per 1% NPS)
  • v54 -- PST retune (736 piece-square entries fitted for the first time, texel.py --pst, 735 values moved; GSPRT[0,2] LLR +7.806, 11.7k games -- second-largest release) (+31.20 ±5.6)
  • v53 -- Texel eval retune (44 scalars refitted on 4M own-self-play positions, game-result labels; fourth SPRT accept, LLR +9.918, 12k pooled games -- largest single release) (+37.52 ±6.3)
  • v52 -- null-move refinements (no double null + eval-scaled R; third SPRT accept, 12k pooled games) (+6.63 ±4.5)
  • v51 -- root-move LMR (late quiet root scouts reduced; second SPRT accept, 9.3k pooled games) (+11.12 ±5.3)
  • v50 -- rule50 TT staleness guard + depth-independent TT mate handling (permanent terminal entries; null kept as correctness) (+1.60 ±6.8)
  • v49 -- cuckoo upcoming-repetition (forcible draw scored one ply early; null kept as correctness) (+0.97 ±6.8)
  • v48 -- qsearch TT-quality batch (TT value sharpens stand-pat; first SPRT accept, 21.6k games) (+4.73 ±3.2)
  • v47 -- TT to 192 MB (diminishing) + MultiPV (node-exact off) (+3.16 ±6.8)
  • v46 -- transposition table doubled to 96 MB (borderline; less TT thrash per game) (+5.94 ±6.8)
  • v45 -- TT search value sharpens the pruning eval (same NPS, smarter cuts) (+13.52 ±6.8)
  • v44 -- TT prefetch (node-identical, +5–6 % NPS) (+13.31 ±6.8)
  • v43 -- verified-null REMOVED (the insurance cost ~1 ply; isolation A/B) (+5.18 ±6.8)
  • v42 -- cannot-win eval clamp (correctness) (+3.27 ±6.8)
  • v41 -- verified null + 50-move + TT-store policy (correctness) (-2.88 ±6.8)
  • v40 -- FIDE-exact en-passant hashing (correctness) (+4.31 ±6.8)
  • v39 -- incremental Zobrist + eval-in-TT + NPS batch (+8.86 ±6.8)
  • v38 -- score-hygiene batch (correctness) (+1.36 ±6.8)
  • v37 -- exact PV (correctness) (+0.17 ±6.8)
  • v36 -- staged move ordering (+24.67 ±6.8)
  • v35 -- noisy-only qsearch gen + qsearch TT (≈ +72)
  • v34 -- check extensions (+6.81 ±6.8)
  • v33 -- transposition table kept warm across moves (+23.52 ±6.8)
  • v32 -- internal iterative reduction (+7.30 ±6.8)
  • v31 -- C search core (whole per-node loop in C) (29W/1D/0L gate ¹)
  • v30 -- stability-scaled time (U-06); last Python (+10.91 ±6.8)
  • v29 -- soft-stop time management (P-35) (+38.34 ±6.9)
  • v28 -- node-identical speed batch (+4 %) (+13.13 ±6.0)
  • v27 -- node-identical speed batch (+12 %) (+35.17 ±7.7)
  • v26 -- node-identical speed batch (+41.90 ±5.7)
  • v25 -- 18-item bug block; Lazy-SMP production fixes (+2.91 ±11.6)
  • v24 -- TT-dispatch de-branching (± is the v21→v24 span) (+11.75 ±6.8 ²)
  • v23 -- Zobrist dispatch de-branching (code quality) (≈ +0 est ²)
  • v22 -- nine correctness bug fixes + six NPS wins (≈ +8 est ²)
  • v21 -- capture history, SEE capture pruning, LMR losing captures (+16 ±10 ⁴)
  • v20 -- rook-on-7th, mobility area, threats; one-call C eval (+45 ±11 ⁴)
  • v19 -- lock-free shared TT, multi-process SMP, packed move word (≈ +5 est ⁵)
  • v18 -- incremental Zobrist hashing (off by default; SMP infra) (≈ +0 est ⁵)
  • v17 -- move generation ported to C (movegen.c) (+69 ±16 ³)
  • v16 -- evaluation ported to C (eval_c.c) ((in ³))
  • v15 -- LMR-divisor tune (tie); probcut tried & removed (≈ +0 est ⁵)
  • v14 -- Syzygy TB probe, internal iterative reduction, pawn hash (≈ +8 est ⁵)
  • v13 -- eval-weight retune (≈ +4 est ⁵)
  • v12 -- check-extension budgeting + max-extensions cap (≈ +4 est ⁵)
  • v11 -- incremental base eval (byte-identical) (≈ +3 est ⁵)
  • v10 -- TT refactor (two-tier + depth-preferred replacement) (≈ +8 est ⁵)
  • v9 -- late-move pruning, history malus, improving heuristic (≈ +12 est ⁵)
  • v8 -- quiescence stand-pat, trade-down simplify, PV extraction (≈ +12 est ⁵)
  • v7 -- pin evaluation (≈ +4 est ⁵)
  • v6 -- lone-king endgame eval fix (≈ +8 est ⁵)
  • v5 -- recapture extension (≈ +3 est ⁵)
  • v4 -- SEE move ordering + losing-capture pruning (≈ +20 est ⁵)
  • v3 -- endgame mop-up, contempt draws, counter-moves (≈ +15 est ⁵)
  • v2 -- search + eval build-out: PVS, futility, LMR, aspiration, pawn/mobility/king-safety eval, book (≈ +120 est ⁵)
  • v1 -- first working engine (naive negamax + material eval) (--)
Reading the table (footnotes & caveats)
  • Elo Δ is the A/B vs the previous version (C-era = 10,000 games). It is not summable across the whole column, because the TCs differ (Python era assorted ⁴, v32–36 at 45+0.10, v37–47 at 50+0.20, v48+ on --nodes).
  • est is a feature-based estimate, not an A/B. The real anchor is ≈2442 by v25.
  • Bundled A/Bs: v16+v17 vs v15 = +69 ±16 ³; v22–24 vs v21 = +11.75 ±6.8 ². v31's ≈+215 ¹ was odds-derived and is withdrawn -- the odds ladder it came from was measured on the pre-fc82cb7 harness. The v30→v31 gate (29W/1D/0L over 30 games) stands on its own: the jump was past what Elo could express.
  • NPS 4T is "--" for v1–24 (no reliable SMP). v25–30 were multi-process, v31+ pthread Lazy-SMP, so the v30→v31 jump is partly methodology.

The biggest jumps

Jump NPS What
v15→v17 28.7k → 52.7k eval, then movegen ported to C (byte-identical)
v25→v28 49.1k → 69.0k node-identical speed batches
v30→v31 69.0k → 2.34M (~34×) whole per-node loop moves to C
v34→v36 2.13M → 3.19M noisy-only qsearch gen + staged ordering
v43→v44 3.23M → 3.67M TT prefetch: +13.31 Elo, ~2.7 Elo per 1% NPS
v53 -- Texel eval retune: +37.52 Elo, biggest single release
v54→v55 3.69× → 4.19× pin-aware legality + eval accumulator: +9.66 Elo, ~1.16 Elo per 1% NPS
v55→v56 4.19× ProbCut: +11.44 timed (+4.11 on --nodes) at -21.6% nodes; S-06 pool +9.83% at 4 threads
v56→v57 4.19× host layer only, node-identical: ponderhit soft-stop + the time-policy knobs over UCI. Last pure-HCE release
v57→v58 ~2.9× NNUE armed: +19.11 timed while GIVING BACK ~30% NPS to the net. First hybrid; the gain is the training schedule, not the architecture

Not visible as NPS: v39→v40 (ep-key merge) and the v41→v43 verified-null removal are nodes-to-depth gains at flat speed.


Features

Search

  • Negamax / alpha-beta with PVS; iterative deepening reusing the previous iteration's PV move, killers, history and TT; aspiration windows.
  • Partial-iteration salvage: if time runs out mid-depth, the best root move evaluated so far is used rather than falling back to the last full depth.
  • Quiescence with stand-pat, delta pruning, check evasions, and a lazy stand-pat that skips the expensive eval terms when the cheap base already proves a cutoff (exact -- the tree is unchanged).
  • Internal Iterative Reduction at TT-less nodes; ProbCut on the fail-high side, where a qsearch filter is confirmed by a real reduced-depth search so nothing is ever cut on a static score.

Transposition table (24-byte entries, lockless XOR-folded, Hash MB)

  • Dead-entry replacement (FI-115, ours): material is irreversible, so an entry whose piece count exceeds the root's can never recur -- evicted first, while a still-reachable old entry is depth-protected instead of clobbered on age. The count rides in 6 spare bits.
  • Kept warm across irreversible moves rather than wiped, plus a rule-50 guard so a stored score cannot outlive the draw counter that justified it.
  • Depth-preferred replacement with an exact-bound bonus, terminal-node storing, depth-independent mate handling, and a two-cache-line prefetch of the child entry issued right after apply_move.
  • Cuckoo upcoming-repetition detection (Stockfish's scheme): a cycle reachable in one move scores as a draw before the repetition physically happens.

Selectivity

  • Null-move pruning with no-double-null and eval-scaled reduction; reverse futility (static null); futility pruning; LMR including root moves; LMP.
  • SEE gating of captures at frontier nodes; a cannot-win clamp so a side without mating material is never scored as winning.

Extensions -- checks (per-line budget 5), single reply, passed-pawn pushes.

Move ordering -- TT move, MVV-LVA plus capture history, killers, counter-moves, the history heuristic with quiet malus, and SEE for capture sorting.

Evaluation -- NNUE (armed)

  • (6144 → 256)×2 → 16 → 32 → 1, perspective accumulators updated incrementally through the game, int8/int16 quantized (QA 127, QB 64, output cp/400).
  • Lazy NNUE: the forward pass is skipped wherever a cheap bound already decides the node -- the gain is in the nodes that buys.
  • SIMD kernels (NEON+dotprod / AVX2) with a hard guard that refuses to arm the net on scalar builds, where the ~3× slower tail makes the engine worse.
  • Trained on positions labelled by search, with a cosine LR schedule -- the schedule alone was worth +19 Elo.

Evaluation -- hand-crafted (the qsearch stand-pat, and the whole eval on non-SIMD hosts)

  • Tapered mg/eg by game phase: material, piece-square tables.
  • Pawns: doubled, isolated, backward, passed.
  • King safety: pawn shield, king-ring attacks, open/semi-open file penalties.
  • Mobility, rook open and semi-open files, bishop pair, threats, pin penalty, tempo.
  • Endgame: mop-up (centre-manhattan drive), simplification bias, contempt, and insufficient-material / cannot-win handling.
  • 44 scalars Texel-fitted; the C port is verified bit-exact against the Python reference over 3M positions.

Engine internals

  • Magic-bitboard movegen reproducing python-chess's move order byte-for-byte, perft-verified over 1.49 billion nodes.
  • The entire per-node loop in C; Python keeps only clock, host and orchestration.
  • Lazy SMP over pthreads on a lock-free shared TT (Threads); the Python engine has a separate multi-process variant.

Endgame and play

  • Local Syzygy 3-4-5 probing (WDL + DTZ, so it converts rather than shuffling), with online Lichess probing for 6-7 men.
  • Bundled Polyglot book (Perfect2023.bin); real UCI pondering with a soft-stop-aware ponderhit; MultiPV by root exclusion; certified instant premoves; wdl info lines from a WDL model fitted per eval family.

Built, measured, and deliberately OFF -- kept because the mechanism is sound and the verdict is recorded: singular extensions, outpost and king-shelter eval terms, root-move ordering by subtree count, history-driven quiet pruning, several qsearch-TT variants, and a growing transposition table. Each was A/B'd, measured null or negative, and left in the tree at its default rather than deleted.

Setup

git clone https://github.com/IchNukeDichWeg/Pygin.git
cd Pygin
./setup.sh

setup.sh installs anything missing (Homebrew on macOS, apt/dnf/pacman/zypper on Linux), builds the C libraries, best-effort builds the Old Engine/ snapshots, and self-tests.

Needs Python 3.10+, a C compiler (clang/gcc), python-chess (the only dependency), and Stockfish if you want strength/odds testing.

python3 selftest.py        # health check; exit 0 = OK, chainable

Isolated install: python3 -m venv .venv && source .venv/bin/activate, then ./setup.sh. Windows: build a Unix .so, so use WSL (wsl --install) or Git Bash / MSYS2. Rebuild C by hand: python3 scripts/eval_build.py && python3 scripts/movegen_build.py (for csearch.so, re-run ./setup.sh).


Running a headless match

match.py plays engine-vs-engine, prints a live scoreboard + Elo, and writes a per-game log and PGN.

# the live engine (v63: v62's settings on v61's core, plus FI-38) vs the
# previous release: 100 positions (×2 colours)
python3 match.py cengine.py "Old Engine/60/engine60.py" 100 0 --workers 0
  • Positional args are engine1 engine2 NUM_POSITIONS OFFSET. Each position is played both colours, so games = NUM_POSITIONS × 2.
  • Flags: --workers 0 = cores-1, --adj on|off adjudication, --sf-elo N, --smp N, --book1/--book2 PATH, --start-pos True.
  • Openings default to the bundled UHO_4060_v4.epd. Larger sets are in the Stockfish books repo; point FEN_FILE at one.

vs Stockfish (binary on PATH):

python3 match.py engine.py stockfish_engine.py 100 0 --sf-elo 2000   # 0 = full strength

Material / time odds are configured in odds.py's CONFIG block (the default is pawn odds, h2). The opponent is full-strength SF-18 -- that is what the ladder means, and a capped opponent measures something else that no recorded rung can be compared to. This is true of the code only since 2026-09-05: before B-15 the setting never reached Stockfish and every recorded rung was played capped. Each worker runs two engines, so --workers 0 here means cores/2, not cores-1 -- a real clock TC gets starved by oversubscription, and a Stockfish opponent squeezed to 10k nps stops being the yardstick the run is quoting:

python3 odds.py --positions 500 --workers 0

UCI options

cuci.py is the UCI engine (setoption name <Option> value <x>). Standard GUI options:

Option Type Default Range Purpose
Threads spin 1 1–512 Lazy-SMP search threads. Above your physical core count they timeshare and cost strength
Hash spin 192 2–24576 Transposition-table size (MB); resizing wipes it. Sizes are powers of two, so anything between rounds down (6144 / 12288 / 24576 near the top). Raise it for long games or analysis -- at 50+0.20 the default is full by move 16
MultiPV spin 1 1–20 PV lines reported. >1 is an analysis mode: it bypasses the book and is never active in match play
Skill Level spin 40 0–40 Deliberately imperfect play for human opponents. 40 = full strength and is byte-identical to the option being absent; lower levels cap the search depth and add a random bias that grows with how far a candidate trails the best move, so weak levels routinely pick a worse one. Not an Elo scale -- see below
OwnBook check false -- Play from the bundled Polyglot book. Off by default: a book is an opening preference, not strength
BookFile string -- Path to Polyglot .bin book (empty ⇒ bundled Perfect2023.bin)
UseTB check false -- Probe Syzygy at the root. Local SyzygyPath is always tried first; the online Lichess probe is the opt-in fallback for sizes you do not have (needs network)
Move Overhead spin 40 0–5000 Clock margin (ms) for GUI/network lag
Premove check false -- Emit certified instant-reply premoves (opt-in)
UCI_ShowWDL check true -- Emit wdl on info lines (opt-out for strict arenas)
Clear Hash button -- -- Wipe the transposition table without ucinewgame
Contempt spin 50 -100–100 Draw score bias (cp) when ahead/behind
SyzygyPath string -- Folder of local Syzygy .rtbw/.rtbz tables. The man-count is detected from what is on disk, so 6- and 7-man sets are used locally if you have them; the online probe is only a fallback for what you do not
Ponder check false -- Think on the opponent's clock. A ponderhit honours the soft-stop rather than spending the full fresh budget
SoftStop spin 55 1–100 Base soft-stop fraction (% of the move budget) before the search may stop between iterations
SoftStopStable spin 40 1–100 Soft-stop fraction once the best move has held for SoftStopStableIters iterations
SoftStopUnstable spin 90 1–100 Soft-stop fraction while the best move is still changing
SoftStopStableIters spin 3 1–20 Iterations the best move must hold before "stable" applies

On Skill Level and the missing UCI_Elo. The weakening scheme is Stockfish's -- search at least four root candidates, then bias each score by how far it trails the best -- on a 0-40 scale, which halves the step size without moving the endpoints (weakness 120 - level, depth cap 1 + level // 2).

UCI_Elo and UCI_LimitStrength are deliberately not implemented and say so in an info string rather than silently doing nothing. UCI_Elo is a claim in Elo units and nobody has fitted that curve for this engine; Skill Level promises only a relative ordering, which the code can keep. It also needs no upkeep: levels are defined against the engine they sit in, so a stronger release lifts all of them and 40 stays full strength. scripts/calibrate_skill.py measures what each level is currently worth.

⚠️ Never measure engine progress against a skill-limited opponent. A limiter injects errors at a fixed rate, so real differences compress: a version pair worth +119 Elo read as ~22 against an Elo-limited one.


Tooling

Script Purpose
testing/perft.py Move-generator correctness gate vs the published Perft results (--deep for the full 1.5 B-node suite).
bench/profile_bench.py Real NPS + a per-function bottleneck breakdown in one pass (--graph for an HTML report).
bench/nps_history_bench.py NPS / depth benchmark across the Old Engine/ snapshots.
bench/benchmark.py NPS / depth / nodes benchmark for the C search core (--type, --threads, --hash, averaged over --runs).
cuci.py UCI host for the C search core (Threads / OwnBook / UseTB options).
tuning/fit_wdl_model.py Fit the win/draw/loss model from match logs (data/wdl_model.json + data/wdl_model_nnue.json; match.py and tuning/eval_bench.py read them, --sync-only copies the coefficients into cuci.py).

Project layout

engine.py              the reference Python engine: the readable statement of
                       the search, and the source of every eval scalar the C
                       port is verified bit-exact against
cengine.py             root driver for the C search core -- THE SHIPPED ENGINE.
                       Its class attributes are the live toggle set
csearch.c              the whole per-node search loop in C (built to .so)
eval_c.c / movegen.c   C evaluation and move generation (built to .so)
Constants.c/.h         magic-bitboard + attack tables (linked into the .so files)
cuci.py                UCI host for the C search core (17 options)
match.py               headless engine-vs-engine match runner (SPRT, pentanomial)
battle_worker.py       per-game worker process used by match.py
stockfish_engine.py    UCI adapter exposing Stockfish through the same API
odds.py                material / time-odds match runner
selftest.py            the pre-commit gate: every behavioural contract, colour-coded
setup.sh               builds the three .so files, then runs the selftest
Old Engine/<N>/        frozen version snapshots (engineN.py + its C sources)

NNUE/                  the whole net lane -- config.py (labelling + arch
                       constants), gen_data.py (self-play generation, --syzygy,
                       --label-nnue), train.py, model.py, data_format.py,
                       verify_labels.py (label reproduction gate),
                       label_depth_probe.py + label_teacher_probe.py (is the
                       corpus deep enough / is the net a better teacher),
                       nnue.c, nets/, datasets/, shims/ (one file per A/B arm),
                       campaigns/ (SPRT state, committed the turn a run ends)
uci/                   front ends for FROZEN snapshots: cuci_old.py drives any
                       C-era engineN.py, uci_old/uci_legacy for the Python era
lib/                   shared support: time_manager (clock budgets),
                       interruptible (Ctrl-C/SIGTERM salvage), smp + shared_tt
                       (the PYTHON engine's multi-process Lazy SMP and its
                       lock-free shared TT)
data/                  Perfect2023.bin book, UHO opening EPDs, fen.txt, and the
                       two fitted WDL models (hce + nnue -- different cp scales,
                       never pooled)
syzygy/                local Syzygy 3-4-5 WDL/DTZ tables (gitignored, ~939 MB;
                       scripts/fetch_syzygy.sh pulls and checksums them)
docs/                  design notes, OpenBench guide, progression SVGs, licences
tuning/                texel.py, fit_wdl_model.py and the eval-fitting tools
bench/                 NPS / depth / profiling harnesses, incl. nps_history_bench
testing/               perft, SPRT, and the correctness gates selftest shells out
                       to (test_tt_deadtag, test_wdl_family, test_sprt_resume ...)
scripts/               build, release, A/B campaign, export and fetch scripts

Old Engine/<N>/ holds every historical version, each self-contained. See its README.


Notes

  • C .so files are not committed. They are platform-specific and built by setup.sh.
  • If a .so won't load, the engine falls back to pure Python (correct, slower); the self-test reports which path is active.

License

  • Source: MIT -- see LICENSE.
  • Released binaries bundle python-chess (GPL-3.0+), so the binary distribution is GPL-3.0 as a whole. Full text, source pointers and credits (Perfect2023 book -- Sedat Canbaz; UHO suites -- Stefan Pohl) in THIRD_PARTY_LICENSES.md.

About

Pygin - a from-scratch UCI chess engine in Python + C, with a hand-written search and evaluation and the entire per-node search loop in C. Built with the help of Claude Code.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages