-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.py
More file actions
5183 lines (4800 loc) · 278 KB
/
Copy pathengine.py
File metadata and controls
5183 lines (4800 loc) · 278 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
engine.py
=========
A self-contained chess engine. It uses the ``chess`` library only for the
board, move generation and legality checks -- never for evaluation or search,
which are entirely our own.
Search features
---------------
* **Negamax + alpha-beta** core with **Principal Variation Search (PVS)**.
* **Iterative deepening** reusing the previous iteration's PV move, killers,
history scores and the transposition table. Partial-iteration results are
preserved: if time runs out mid-depth, the best root move evaluated so far
is used rather than always falling back to the last completed depth.
* **Aspiration windows** around the previous score for deeper iterations.
* **Transposition table** keyed by the board's internal position key
(cheap and collision-safe -- see "Early correctness fixes" below) storing
depth + bound (exact / lower / upper) + best move, with mate-score
distance correction.
* **Quiescence search** with stand-pat, delta pruning and check evasions,
plus a *lazy* stand-pat: the expensive positional eval terms are skipped
when the cheap material+PST base already proves a >= beta cutoff (exact, so
the search is unchanged).
* **Pruning / selectivity**: null-move pruning, reverse-futility (static
null-move) pruning, futility pruning at frontier nodes, late-move
reductions (LMR -- a log(depth)*log(move) reduction table) and late-move
pruning (LMP -- dropping the quiet tail at shallow non-PV nodes once
enough quiet moves have been searched without a cutoff).
* **Extensions**: check extension (post-push, draws on its own ``chk_budget``
so a line full of captures cannot starve it), single-reply / forced-move
extension, passed-pawn push extension (5th rank or beyond), and **singular
extension** (``use_singular_ext``, dormant at fast TCs): at depth >= 8,
when a deep TT_LOWER/TT_EXACT entry backs the TT move, an exclusion search
(same node, TT move excluded, half depth, null window at ``tt_value -
2*depth``) tests whether any other move comes close -- if none does, the
TT move is extended. A/B'd twice vs v29 at 45+0.1: null at min-depth 8
(-0.69 +/-6.8, 10k -- barely engages when average search depth is ~8) and
NEGATIVE at min-depth 6 (-13.1 +/-10.8 at 4k, stopped early -- probe
overhead beats the selectivity gain at these depths). Kept at 8: measured
zero cost, and it engages by itself once searches run deeper.
All non-check extensions share a single ``ext_budget`` cap. A recapture
extension also exists (``recapture_ext`` toggle) but is **off by default** --
the quiescence search already resolves exchanges at the leaves and extending
again costs ≈35% more nodes for no measured gain.
* **Move ordering**: TT move, MVV-LVA + **capture history** (learned per
``(mover_pt, to_sq, victim_pt)`` triple, same gravity rule as quiet history,
blended directly into the capture score so equal-MVV-LVA captures are ranked
by past cutoff experience) captures, promotions, killer moves, the
counter-move heuristic and the history heuristic (with a history malus that
penalises quiet moves searched before the cutoff, keeping stale scores from
dominating ordering), with **Static Exchange Evaluation (SEE)** demoting
losing captures and pruning them in quiescence.
* **SEE-prune losing captures at frontier nodes**: at depth ≤ 2, non-PV,
not-in-check, if the fast piece-type pre-filter flags a potentially losing
capture (mover_pt > victim_pt) and the full SEE confirms SEE < -depth×100,
the move is skipped entirely (the qsearch will resolve the same exchange at
no extra tree cost).
* **LMR for losing captures**: captures where the mover's piece type exceeds
the victim's (a fast proxy for "probably loses material") are reduced by 1
at depth ≥ 3 once LMR_MIN_MOVE moves have been tried -- smaller reduction
than quiets to stay conservative on tactics.
* **Static eval proxy in check**: in-check nodes no longer leave the improving
heuristic blind. A TT-cached eval is used when available; otherwise alpha
serves as a safe lower-bound proxy. This lets LMR's improving bias track
through check-evasion sequences instead of treating every check node as
"not improving".
* **Opening book**: optional Polyglot ``.bin`` book consulted before search,
with uniformly-random move selection among all book entries for opening variety.
* **Endgame tablebase**: optional online Syzygy probe (the free Lichess
tablebase API) at the root for positions with few enough pieces. A hit
returns the provably-optimal move and skips the search entirely; it is never
queried inside the search. To avoid paying network latency where it buys
nothing, the probe is skipped for positions the search already nails faster
than the API responds -- dead-drawn insufficient material and overwhelming
pawnless mop-up wins (a lone king vs a major piece, e.g. KQK / KRK) -- so the
tablebase is spent only on genuinely tricky endings (pawns, or a defending
piece, where the win/draw verdict or technique can actually be wrong). Any
network error / timeout / illegal response falls back to a normal search, and
the network wait is bounded by the move's time budget so a miss cannot lose on
time. Disabled with ``use_tb = False`` for fully offline / benchmark play.
* **Endgame / draws**: an endgame "mop-up" term that drives the weak king to
the edge to convert won endings (KQK / KRK / KQ-vs-P), and contempt-scored
repetition detection so a clearly winning side avoids draws while a losing
side is happy to hold them.
Evaluation
----------
A tapered hand-crafted evaluation (HCE): middlegame and endgame scores blended
by game phase, returned in centipawns from White's view (``_evaluate_stm``
flips it to the side to move for negamax).
Terms:
* Material + piece-square tables + tempo bonus.
* Pawn structure: doubled / isolated / passed / backward pawns.
* King safety: pawn shield, open files, attacker count.
* Mobility, rook on open / semi-open file, bishop pair.
Speed tricks:
* The cheap base half (material + PST + phase + tempo) is kept *incrementally*:
a per-move delta updates an accumulator on every make/unmake
(``use_incremental_eval``), so it's never rescanned per node. The result is
byte-for-byte identical to a from-scratch scan.
* The pawn-structure term depends only on the pawn bitboards and phase, so it
is memoized in a pawn hash keyed on ``(white pawns, black pawns, phase)``.
Several further eval/search refinements (pin penalty, trade-down
simplification, recapture extension, alternative TT-replacement schemes,
quiescence SEE ordering) exist as off-by-default A/B toggles in ``__init__``;
see the per-flag verdicts there.
Early correctness fixes (pre-v15)
----------------------------------
The earliest version had several issues that ballooned the node count and the
per-node cost. Fixed and flagged inline with ``# FIX``:
* ``chess.polyglot.zobrist_hash(board)`` was called for the TT key at *every*
node (rebuilds from scratch, ≈50k/s). Switched to
``board._transposition_key()`` (≈1.2M/s, ≈22x faster); Zobrist hashing is
now only used for the book probe. (Unrelated to ``use_zobrist``, added
later for Lazy SMP -- that's an *incremental* 64-bit hash for the shared
TT, not a from-scratch rebuild, so it doesn't reintroduce this cost.)
* The root searched every move with a full, un-narrowed window (alpha never
raised), disabling root pruning. Now uses PVS and raises alpha, while
still supporting the random tiebreak.
* Move ordering called ``board.gives_check(move)`` for *every* legal move
(one of python-chess's most expensive calls). Removed from ordering; check
detection now happens once, cheaply, after the move is pushed.
* PVS, LMR, reverse-futility and futility pruning (claimed but not actually
present in the original) are implemented, cutting the tree hard.
Version history
---------------
Each version is a saved snapshot in ``Old Engine/<N>/``; only the *changes* are
logged here. For what the current build does, see "Search features" and
"Evaluation" above. Aggregate NPS/Elo numbers live in "Cross-version
benchmark" below.
* **v1**: initial working engine -- negamax + alpha-beta, iterative deepening,
an inline dict transposition table, quiescence search, null-move pruning,
killer moves and a material + piece-square-table eval. This is the naive
baseline the "Early correctness fixes" section above refers to.
* **v2**: the main search + eval build-out. Selectivity added -- PVS,
reverse-futility (static null-move), futility pruning and LMR -- plus
aspiration-window root search. New bitboard eval terms: pawn structure,
mobility, king safety, bishop pair, rook files. History-heuristic updates
and an optional Polyglot opening book (``use_book``).
* **v3**: endgame + draw handling -- the mop-up term (``_mopup_bb``) that drives
the weak king to the edge, contempt-aware draw scoring (``_draw_score``) and
the counter-move heuristic.
* **v4**: Static Exchange Evaluation (``_see``, ``use_see``) for move ordering
and pruning losing captures.
* **v5**: recapture extension (``_recapture_at``).
* **v6**: endgame eval fix -- lone-loser detection so the king-safety terms are
no longer dropped in lone-king endings.
* **v7**: pin evaluation (``_pin_penalty_bb``, ``use_pin_eval``).
* **v8**: eval refactor + quiescence stand-pat -- eval split into base /
positional halves, mobility and king safety merged into one pass
(``_mobility_king_safety_bb``), quiescence stand-pat (``_qs_stand_pat``),
trade-down simplification (``use_simplify``) and PV extraction.
* **v9**: late-move pruning (``use_lmp``), the history malus
(``use_history_malus``) and the "improving" heuristic. NPS drops ~14% by
design (LMP skips near-leaf quiets) but the search reaches deeper per second.
* **v10**: transposition-table refactor -- probe/store split into ``_tt_get`` /
``_tt_store`` with two-tier and depth-preferred replacement variants
(toggles), plus a quiescence-SEE ordering toggle.
* **v11**: incremental base eval (``use_incremental_eval``) -- material + PST +
phase + tempo maintained by a per-move delta in ``_make`` / ``_unmake``
instead of a per-node rescan (byte-identical to the from-scratch scan).
* **v12**: extension budgeting -- a separate check-extension budget and a
``MAX_EXTENSIONS`` cap so a capture-heavy line cannot starve the other
extensions.
* **v13**: eval-weight retune -- bishop pair, rook files, tempo and the
pawn-structure penalties reset from a tuning run.
* **v14**: online Lichess Syzygy tablebase (``use_tb`` / ``_tb_probe``,
root-only, triviality-guarded so trivial mop-ups skip the ≈150-400ms round
trip, network-safe), Internal Iterative Reduction (depth >= 4 with no TT
move) and a pawn-structure hash keyed on ``(wp, bp, phase)`` (phase-tapered,
so the naive ``(pawns, occ_white)`` key would be wrong).
* **v15**: pre-C-extension baseline. LMR divisor tuned 2.25 -> 2.0 (~12% more
reductions; an overnight 5-variant sweep found every value a statistical
tie -- 2.0 is just the noise-peak). Probcut was tried and removed here (+4
+/-12 Elo at 1s/move, ≈0 at 500ms -- null at both TCs) -- see "Rejected /
shelved experiments" below.
* **v16**: ``_mobility_king_safety_bb`` ported to C (``eval_c.c``, loaded via
``ctypes``; build ``python3 scripts/eval_build.py``). 0/10,000 positions differ
from the Python path. **NPS 21,369 -> 27,507 (+28.7%)** at fixed depth.
* **v17**: legal + capture move generation ported to C (``movegen.c``; build
``python3 scripts/movegen_build.py``; toggle ``use_c_movegen``), reproducing
python-chess's exact pseudo-legal move order so the search stays
byte-identical (not just set-equal) after ``order_moves``'s stable sort --
a prior *staged* movegen that reordered quiet ties lost ≈20 Elo, which is
why order-matching was mandatory here. Perft-verified to depth 6 on the
full standard suite. **NPS +24.8%** at fixed depth. **v16+v17 combined vs
v15: +69 +/-16 Elo** (2000 games).
* **v18**: Lazy SMP groundwork -- incremental 64-bit Zobrist hashing
(``use_zobrist``, maintained in ``_make``/``_unmake``, never rebuilt from
scratch) so the dict TT's key can eventually live in shared memory (a
plain tuple key can't, and a tuple's ``hash()`` is per-process-randomised
anyway). Off by default -- zero overhead in normal play. Verified over 7M
make/unmake checks (incremental == from-scratch).
* **v19**: Lazy SMP finished -- a lock-free shared-memory transposition table
(``shared_tt.SharedTT``/``use_shared_tt``, Stockfish-style XOR'd 64-bit
slots so a torn read is always detected as a miss, never a corrupt hit)
and multi-process orchestration (``smp.py``, ``self.smp_workers``: N worker
processes search the root to the same wall budget, diversified by RNG
seed, deepest-completed result wins). N=4 reaches +1 ply deeper than N=1
in the same wall time on most positions (≈70-85% efficiency) -- real but
modest, and only pays off in time-limited (not fixed-depth) play. Also
folds in the remaining eval/movegen infra: the INBETWEEN_BITBOARDS table,
magic bitboards for slider attacks in ``eval_c.c``/``movegen.c``, and a
packed move word (mover/victim piece type + en-passant flag) so the
search loop skips several python-chess calls per move.
* **v20**: three new eval terms -- ``use_rook_on_7th`` (rook on the 7th vs an
exposed enemy king/pawn), ``use_mobility_area`` (mobility excludes squares
attacked by an enemy pawn), ``use_threats`` (bonus per enemy piece attacked
by a cheaper one of ours) -- plus folding rook-files/bishop-pair into the
existing mobility/king-safety C call to remove a second ctypes round trip.
**A/B vs v19: +45 +/-11 Elo** (4000 games, 0.75+0.25 TC).
* **v21**: capture-history move ordering (``use_capt_history``), SEE-pruning
of losing captures at depth <= 2 (``use_see_prune_captures``), LMR for
losing captures, and an in-check static-eval proxy (``use_check_eval_proxy``)
so the improving heuristic isn't blind through check evasions -- plus five
small NPS wins (a bitboard ``has_non_pawn_material`` check, a longer
time-check interval, a pre-allocated SEE gain array, a bitboard passed-
pawn-push check, and direct killer slots). **A/B vs v20: +16 +/-10 Elo**
(5000 games, 0.65+0.1 TC).
* **v22**: nine correctness bug fixes -- low-phase eval was dropping king
safety and the post-v21 toggles entirely; the lone-loser mop-up shortcut
returned 0 instead of falling through below its gate; a false
``TT_EXACT`` flag could be stored after an alpha-raise; the shared-TT
mate-score clamp could overclaim a bound it hadn't proven; a stale
``alpha`` could pollute the TT's cached static eval; the null-move child
mis-keyed 2-ply continuation history; the quiescence lazy-margin (400)
was measured stale (raised to 700); mate delivered exactly at the
75-move clock scored as a draw; and the four post-v21 toggles without a
C implementation lacked a documented Python-fallback caveat -- plus six
NPS wins (reusing raw capture tags in quiescence, interning ``Move``
objects, int-packing history-table keys, reusing ordering-time SEE in
the prune gate, porting SEE itself to C, and reordering the quiet-history
lookup past the prune checks that might skip it). Not yet A/B'd for Elo.
* **v23**: fixed a Zobrist method-swap bug -- a permanently-off ``if`` guard
in the hottest functions (``_make``/``_make_null``/``_unmake``) isn't free
in CPython even when it never fires. Split into branch-free variants
bound once per search instead of checked every node. No measurable NPS
effect (within noise).
* **v24**: the same fix applied to the TT dispatch
(``_tt_get``/``_tt_store``). +0.5-1.36% NPS depending on position (real,
if small). Both v23 and v24 are kept for code quality regardless.
* **v25 (2026-07-04): the 18-item [BUG] block from improvements_v24.md.**
Single-thread search is byte-identical to v24 (h1h8/3495 reference) apart
from the intended fixes (zeitnot budgets, in-search TT cap).
- setoption changes now reach the C eval (``_sync_c_params``, P-06).
- UCI ``stop`` race closed via the host-owned ``_abort`` flag (P-05).
- zeitnot time management: emergency budgets respect overhead, sub-250 ms
budgets bind a 1024-node poll (P-08).
- all search timing on monotonic ``perf_counter`` (P-09).
- TT entry cap enforced inside long searches (P-15).
- eval_c/movegen ``.so`` ABI handshake + loud fallback (P-12).
- C-side division / ``ctzll(0)`` guards (C-01/C-02).
- SMP cluster: pool created on the main thread so production runs
multi-core with a live ``Threads`` option (P-01); stm-relative tie-break
(P-02, the old one picked Black's *worst* tie); dead-worker timeouts +
error rows (P-03); search-id-tagged results (P-04); pool carried across
ucinewgame (P-07); host config replicated into workers (P-11); workers
close their shm view (P-13); shared-TT PV walk fixed (X-01).
- **A/B vs v24** (stopped early at 3,462/10k games, 45+0.15s clock):
+2.91 +/-11.6, normalized +5.51 -- no regression; validated with a
1,068-game interim (+3.8) and the SF-2450 absolute benchmark (≈2442).
* **v26 (2026-07-05): the byte-identical NPS batch
from improvements_v24.md.** Search is node-identical to v25 -- verified per
item and end-to-end (8-position suite in all four zob x incremental
configs, 40k+ accumulator round-trips, 56.9k SEE differential, 304-endgame
oracle, perft). **Measured +18.5% time-to-depth vs v25** (interleaved
best-of-3 on identical trees; the P-47 eval memo contributes +1.1%).
**A/B vs v25 (2026-07-06): 10,000 games @ 45+0.15s clock (950 recovered
from an interrupted first run + 9,050 resumed, disjoint openings):
4329W/2541D/3130L = 55.99% -> +41.9 +/-5.7 Elo** (ptnml
424/777/1860/1054/885, pair ratio 1.61, normalized ~+71.5) -- far above
the +10-18 expected from speed alone; the zeitnot budgets, in-search TT
cap and eval memo carry real Elo beyond time-to-depth.
- 19 Python items: ctypes slice decode, qsearch castling-arg drop,
``_see_raw``, pre-bound C refs, insufficient-material pre-filter,
``PIECE_VALUES`` tuple, raw-tag promo/from-to reads,
killer/countermove/history flat lists, ``_move_delta`` raw threading +
``_contrib`` table, gives_check pass-down, ``order_moves`` row return,
inlined poll-mask gate, static-eval memo, make/unmake acc de-branch.
- C group: ``-O3 -mcpu=native``, constructor table init, directional ring
popcounts, ``attacked()`` slider guards, mopup folded into the C eval
(=> eval ABI 2).
* **v27 (2026-07-06): the risk-free Python NPS batch
from improvements_v24.md merge #6.** Search is NODE-IDENTICAL to v26
(every item gated on the 10-position suite = 148,775 nodes + the
h1h8/3495 reference), so this is a pure speed refactor -- byte-identical
play, faster. Items: U-01 (hoist remaining per-move ``self.`` loads + LMR
clamp-once), W-08 (pm1/pm2 predecessor keys built once per node), U-02
(passed-pawn-push gated on the raw tag), W-07 (thread the node's TT key
into ``_evaluate_stm``), Y-03 (qsearch stand-pat shares the P-47 eval
memo), P-24 (TT/killer/counter identity via 15-bit ints, not
``Move.__eq__``), Y-06 (continuation history as ``{pred: flat 8192-list}``
instead of a 25-bit-keyed dict), W-09 (pawn cache keyed ``(wp, bp)`` with
a per-call passer taper). W-12 (reuse the ordering history blend) was
TRIED and REVERTED -- it changed node counts (history tables mutate during
a node's own move loop), so it's a search-behaviour change for the A/B
tier, not a free speedup. **Measured +12.0% NPS vs v26** (idle, interleaved
3x3s over the 10-position suite: v26 78,242 -> v27 87,656 NPS; +0.2 ply avg
depth, every position faster). **A/B vs v26 (2026-07-06): 8,000 games @
45+0.15s clock, 3342W/2123D/2535L = 55.04% -> +35.17 +/-7.7 Elo** (ptnml
341/676/1457/887/639, pair ratio 1.50, normalized +60.78) -- far above the
~+12 expected from +12% NPS alone, the same over-delivery seen in v26-vs-v25
(+41.9 from +18.5% speed): at a clock TC a node-identical speedup converts to
more Elo than the naive time-to-depth model predicts.
Also folded in (node-identical, verified 2026-07-06 -- perft --deep ALL PASS
1.49B nodes + the 148,775-node suite exact + h1h8/3495): Z-04 (`_capture_moves`
returns rows), W-10 (rook open-file scored inside the rook mobility loops --
one ctz pass), W-14 (one `_npm`/`npm_side` helper replaces the 6 duplicated
material-value formulas across Python + C), W-15 (`eval_c.c`/`movegen.c` alias
Constants.c's KNIGHT_ATTACKS/KING_ATTACKS instead of rebuilding them at load).
Second node-identical batch (verified 2026-07-06, 148,775-node suite + h1h8/
3495): V-05 (predecessor key computed once in `_negamax`), V-07 (`_capture_moves`
carries `victim_value` on the row so quiescence doesn't re-decode it), V-08
(`see_attackers` skips the slider magic lookup when no such slider exists).
* **v28 (2026-07-06): another node-identical NPS batch.**
Search is NODE-IDENTICAL to v27 (every item gated on the 148,775-node
depth-6 suite + the h1h8/3495 reference). Items: U-04 (king squares derived
inside the C eval from the ``kings`` bitboard, eval ABI 2 -> 3), V-04
(killers/counter stored pre-packed as their 15-bit key, not ``Move``
objects), V-06 (passed-pawn taper as a precomputed ``[phase][rel]`` table),
item 64 (``_hist_key`` inlined at its two hottest read sites), V-09 (skip the
C ``generate_legal`` round-trip + its ``clean_castling_rights`` on in-check
nodes), and the V-14a-d cleanup cluster (dead poll backstop, one-shot book
legal set, module-scope file masks, arch-aware build flags). Also removed the
dead ``positional_extras`` C export. **Measured +4.38% NPS vs v27**
(interleaved subprocess best-of-5, own-.so each: 90,393 -> 94,351). **A/B vs
v27 (2026-07-06): 13,000 games, 5028W/3435D/4537L = 51.89% -> +13.13 +/-6.0
Elo** (ptnml 709/1188/2375/1359/869, pair ratio 1.17, normalized +22.53) --
spot on the ~+13 predicted from +4.38% NPS, and the node-identical-speed ->
Elo ratio (~3 Elo per 1% NPS) now holds steady across v26/v27/v28.
* **v29 (2026-07-07): P-35 soft-stop + two bug fixes.**
``soft_stop_frac = 0.55``: after completing a depth, don't start the next
ID iteration once 55% of the move budget is spent -- it typically costs at
least as much as all previous iterations combined and would be aborted
mid-depth; the banked clock compounds via the time manager on later moves
(None disables; replicated to SMP workers). Fixed-depth searches are
untouched (148,775-node suite exact). Plus V-02 (SMP pool path syncs
``nodes_searched``, reporting-only) and V-03 (in-check quiescence gets the
same ``_path`` repetition draw guard as ``_negamax``; node-identical on
non-repeating lines). P-41 (capture-cutoff history malus) was tried between
v28 and v29 and REJECTED at -7.99 -- see "Rejected / shelved experiments".
**A/B vs v28 (2026-07-07): 10,000 games @ 45+0.1s clock, 4363W/2373D/3264L
= 55.50% -> +38.34 +/-6.9 Elo** (ptnml 477/731/1871/1058/863, pair ratio
1.59, normalized +64.97) -- ~5x the +3-8 predicted: the v28 baseline burned
its full budget on every move (always starting a doomed final iteration),
so it played in permanent self-inflicted time pressure. Caveat: time policy
is worth the most when effective clocks are thin (heavily parallel server);
expect a smaller edge at leisurely TCs.
* **v30 (2026-07-07, current ``engine.py``): U-06 stability time scaling +
dormant singular extensions.** U-06 (``use_stability_time``) scales the
P-35 soft-stop fraction by best-move stability across completed ID
iterations: unchanged >= 2 iterations -> stop at 0.40 of the budget (the
move needs no more confirmation; bank the clock), flipped on the last
iteration -> allow 0.80 (contested position, spend where it matters),
else the flat 0.55. Hard budget cap still rules; fixed-depth searches
untouched. Also carries P-33 singular extensions as DORMANT infrastructure
(min-depth 8; A/B'd null there and negative at 6 -- see the Extensions
bullet) and the V-02/V-03 bug fixes. **A/B vs v29 (2026-07-07): 10,000
games @ 45+0.1s, 3930W/2454D/3616L = 51.57% -> +10.91 +/-6.8 Elo** (ptnml
615/855/1831/999/700, pair ratio 1.16, normalized +18.32) -- within the
+0-8 estimate band. The time-policy vein is 2-for-2 (P-35 +38.3, U-06
+10.9) while depth-8-regime tree surgery went 0-for-2 (P-41, P-33):
at thin effective clocks, clock intelligence is where the Elo is.
* **v31 (2026-07-08, lives in ``cengine.py`` + ``csearch.c`` -- NOT this
file): the C search core.** The entire per-node loop moved to C (board,
ordering, array TT, pruning, quiescence, and a bit-exact port of THIS
file's static eval, verified over 3M positions); Python keeps only the
root layer -- this file's ID loop, aspiration windows, P-35/U-06
soft-stop, book probe -- and syncs every eval table/param from this
file's Engine class at construction, so engine.py remains the single
source of truth for evaluation. ~2.5M NPS vs ~90k (bench sweep, trimmed
means: NPS 84.5k -> 3.84M, depth-reached 10.5 -> 17.75 at 2s/move).
**Gate A/B vs v30 (30 games @ 45+0.1, match.py, adjudicated): 29W/1D/0L
= 98.33%** -- outside Elo's measuring range. External re-date: **rook
odds vs full Stockfish 50.50% -> 93.25%** (400 games, +456 +/-169; see
Strength below); knight odds baseline 76.75% (400 games, +207 +/-48).
Design + phase log: DESIGN_c_search_core.md. This file stays the
shipped Python engine (v30) and the eval oracle. Snapshotted as
Old Engine/31 (engine31.py + frozen engine_eval.py oracle).
* **v32 (2026-07-08, lives in ``cengine.py`` + ``csearch.c``): P-03
Internal Iterative Reduction.** No TT move at a depth>=4 non-check node
-> search one ply shallower (the TT-fed revisit gets full depth).
``set_iir(0)`` restores v31's search node-exactly. **A/B vs v31
(2026-07-08): 10,000 games @ 45+0.1, 3174W/3862D/2964L = 51.05% ->
+7.30 +/-6.8 Elo** (ptnml 347/1155/1864/1209/425, pair ratio 1.09,
normalized +13.99) -- first confirmed feature of the C era, right in
IIR's classic +5-10 band. C-era backlog: improvements_v30+.md (local).
* **v33 (2026-07-09, lives in ``cengine.py`` + ``csearch.c``): P-14 keep
the TT warm across irreversible moves.** v30's wipe-on-capture/pawn-move
rule existed for its unbounded dict TT; the C table is fixed-size with
generation-aware replacement and full-key-checked probes, so the wipe
only discarded the still-reachable subtree's entries on a very frequent
event. ``TT_KEEP_WARM=False`` reproduces v32 move-for-move. **A/B vs v32
(2026-07-09): 10,000 games @ 45+0.1, 3572W/3532D/2896L = 53.38% ->
+23.52 +/-6.8 Elo** (ptnml 319/1002/1898/1246/535, pair ratio 1.35,
normalized +44.49) -- ~5x the +2-5 estimate; the warm table is worth
far more at depth 14+ than the old dict-TT hygiene assumed. Also in
this rev: Lazy-SMP TT-poison fix (stopped helpers no longer store
garbage; single-thread unaffected) and a process-wide search lock
(multi-instance hosts can no longer corrupt the shared C state).
* **v34 (2026-07-09, lives in ``cengine.py`` + ``csearch.c``): P-01 check
extensions.** A move that gives check gets +1 ply, drawn from a per-line
budget of 5 (v30's MAX_CHECK_EXT recipe; the budget flows down the line,
spent only when an extension fires, and LMR requires !gives_check so
extension and reduction are mutually exclusive). ``set_check_ext(0)``
restores v33's search node-exactly. **A/B vs v33 (2026-07-09): 10,000
games @ 45+0.1, 3321W/3554D/3125L = 50.98% -> +6.81 +/-6.8 Elo** (ptnml
404/1087/1880/1167/462, pair ratio 1.09, normalized +12.74) -- the
weakest confirmed gain yet (95% CI lower bound ~+0.01, same tier as
P-03's +7.30), but every secondary signal agrees, so kept. Snapshotted
as Old Engine/34.
* **v35 (2026-07-10, lives in ``cengine.py`` + ``csearch.c``): P-22
noisy-only qsearch generation + P-44 qsearch TT probe/store -- the
biggest version step of the C era, ~+72 Elo.** P-22: quiescence
generates only the noisy moves it searches (captures/promos/ep, same
order as the full generator's subset; stalemate semantics preserved) --
NODE-IDENTICAL at fixed depth, +32% NPS. P-44: the node-majority
qsearch probes the warm TT before movegen+eval and stores depth-0
entries that can never displace negamax entries. **Measured: the bundle
DIRECTLY vs v34 ~+71.8 +/-8.5 @ 7,061 games (stopped as decisive);
P-44 isolated vs the P-22 base (engine_qtt_off) +8.06 +/-6.8 @ 10k
(3202W/3828D/2970L = 51.16%, ptnml 359/1123/1891/1181/446, norm
+15.35) -- so ~+64 speed + ~+8 qsearch-TT, and the parts compose.**
Process lesson recorded: "node-identical" exempts only the fixed-depth
gate -- P-22's timed-play Elo went unmeasured until it confounded
P-44's first A/B; speed changes get their own timed A/B from now on.
Snapshotted as Old Engine/35.
* **v36 (2026-07-10, lives in ``cengine.py`` + ``csearch.c``): P-23 staged
move ordering (+ the P-46 lazy-qsearch speed rider).** Negamax stops
generating/scoring every move at every node: the TT move plays via
reconstruct-and-validate with zero generation, then captures, killers,
counter, quiets and bad captures are generated lazily per class. VERIFY
mode proved the staged stream equals order_moves' sorted output under
identical state (~1M nodes); live trees deliberately diverge -- quiets
are scored AFTER earlier subtrees updated history, i.e. with fresher
information (often fewer nodes), plus ~+10-20%% NPS. **A/B vs v35:
+24.67 +/-6.8 over 10,000 games @ 45+0.1 (53.55%%, ptnml
295/998/1911/1295/501, pair ratio 1.39, norm +47.51)** -- the second-
biggest single feature of the C era after P-14. Snapshotted as Old
Engine/36. **This closes the 45+0.10 ledger era: the standard A/B TC is
50+0.20 from the next campaign on (cross-era Elo is not the same
currency).**
* **v37 (2026-07-10, lives in ``cengine.py`` + ``csearch.c``): PV-01
triangular PV + PV-02 exact PV -- a correctness release, ~0 Elo by
design.** The PV is collected during the search (triangular table,
node-exact) instead of TT-walked afterwards, and PV nodes skip TT
cutoffs/bound-narrowing so the line survives end-to-end (the standard
strong-engine rule; the TT move still orders). **A/B vs v36: +0.17
+/-6.8 over 10,000 games @ 50+0.20 (50.02%%, ptnml 347/1177/1922/1232/
322, pair ratio 1.02) -- a clean null, i.e. the exact PV is free**; it
fixed matetrack's ~60%% Bad-PV rate (truncated/spliced mate PVs).
Same-era rejects on the way: Q-01 continuation history (-0.87 +/-6.8),
P-47 check-extension budget 8 (-4.59 +/-6.8; the extensions vein is
closed at this TC: P-01 +6.8, P-43 +3.5 marginal, P-47 -4.6).
Snapshotted as Old Engine/37.
* **v38 (2026-07-10, lives in ``cengine.py`` + ``csearch.c``): CB-01, the
correctness batch -- a null by design, kept for correctness.** One master
toggle (``set_score_hygiene``) over seven sub-resolution fixes: delta
pruning budgets the Texel piece values the eval actually awards (queen
~1150, not the classic 900); qsearch detects in-check repetition and
insufficient-material draws (perpetual-check lines used to score as eval,
and P-44 persisted the misscore in the warm TT); null-move returns/stores
its fail-soft bound (unproven mates clamped); the qsearch TT probe narrows
alpha from a LOWER bound; mate-distance pruning at non-PV nodes; deep
qsearch orders with the last killer slot, not the root's. **A/B vs v37:
+1.36 +/-6.8 over 10,000 games @ 50+0.20 (50.20%%, ptnml
257/1208/2043/1223/269, pair ratio 1.02)** -- a clean null, KEPT as a
correctness release (PV-02 precedent). Mate-suite payoff: matetrack @0.5s
692/600 -> 868/751, still ZERO Bad PVs (mate-distance pruning ~+25%%
found). One trap the mate suite caught: mate-distance pruning at PV nodes
clamps beta to exactly the fastest-mate score, starving PV-01's in-window
store (Bad PVs 0 -> 470) -- restricted to non-PV nodes. Snapshotted as
Old Engine/38.
* **v39 (2026-07-11, lives in ``cengine.py`` + ``csearch.c``): the Phase-2
NPS train -- the first Elo gain after two correctness releases.** Three
speed changes, each ladder-verified: FI-01 incremental Zobrist (the
position key lives on the Board and is XOR-maintained through apply_move/
make_null instead of a 9-multiply full-state hash recomputed at every
node; the ZKEY differential vs a from-scratch oracle is clean over 52.4M
nodes; EP-01's FIDE ep filter folds in as an O(1) fixup); FI-03 the static
eval cached in the TT entry's spare 16 bits (exact by determinism, reused
at negamax pruning-eval and qsearch stand-pat); and the FI-02 micro-batch
(mover PT read from the move word, ordering's SEE verdict tagged and reused
by quiescence, lazy pick_next move ordering). **+8.9%% NPS on a paired
alternating bench (9/9 pairs positive). A/B vs v38: +8.86 +/-6.8 over
10,000 games @ 50+0.20 (51.28%%, ptnml 218/1158/2042/1315/267, pair ratio
1.15, normalized +18.89)** -- the NPS converted at ~1 Elo/1%%. Snapshotted
as Old Engine/39.
* **v40 (2026-07-11, lives in ``cengine.py`` + ``csearch.c``): EP-01
FIDE-exact en-passant hashing -- the third correctness release.** The
position key counts an ep square only when a legal ep capture actually
exists (= python-chess's ``_transposition_key``), so the engine's
repetition detection finally agrees with the FIDE arbiter: a phantom ep
after a double push no longer splits one FIDE-identical position across
two hash keys (which could miss a saving repetition claim or blunder into
one). The oldest open correctness item in the repo -- every audit flagged
it -- made nearly free by v39's incremental Zobrist: an O(1) fixup in
board_key that only runs when an ep square is set. Merging the phantom-ep
TT entries even saves nodes (d12 ladder 713,014 -> 562,363). **A/B vs
v39: +4.31 +/-6.8 over 10,000 games @ 50+0.20 (50.62%%, ptnml
227/1203/2064/1231/275, pair ratio 1.05, normalized +9.14)** -- a null
KEPT as correctness (PV-02/CB-01 precedent). Snapshotted as Old
Engine/40.
* **v41 (2026-07-11, lives in ``cengine.py`` + ``csearch.c``): CB-02,
correctness batch #4 -- the fourth correctness release.** Four fixes
under one toggle (``set_cb2`` + the driver's CB2 logic): (a) the
null-move TT store obeys the replacement policy -- a deep entry and its
best move are no longer clobbered by a shallower moveless bound (FB-22,
self-inflicted ordering damage against the warm table); (b) quiescence
applies the 50-move rule (completing CB-01's draw set); (c) deep
null-move cutoffs (depth >= 10) are verified with a reduced no-null
re-search -- zugzwang/fortress insurance has_non_pawn alone cannot give;
(d) root fail-high moves are adopted as the depth's provisional best,
ordered first in the widened re-search, and played if it aborts --
v30's ``_partial_root_move`` rule, which the C port had dropped (the
engine could play a move it had just PROVEN inferior). **A/B vs v40:
-2.88 +/-6.8 over 10,000 games @ 50+0.20 (49.59%%, ptnml
287/1198/2086/1169/260, pair ratio 0.96, normalized -6.04)** -- a null
KEPT as correctness (PV-02/CB-01/EP-01 precedent; the verification
re-searches cost ~+47%% d12 nodes and bought their safety at noise-level
price). Snapshotted as Old Engine/41. (CW-01, the cannot-win eval clamp,
was implemented alongside but is the TENTH campaign's candidate --
dormant in v41.)
* **v42 (2026-07-11, lives in ``cengine.py`` + ``csearch.c``): CW-01, the
cannot-win eval clamp -- the fifth correctness release, and the first
user-reported one.** At the end of the static eval (both engines,
bit-exact twins: ``use_cantwin`` here, ``set_cantwin``/``CANTWIN`` in the
C core), if the side the score favors has no pawns, no rooks/queens, and
at most a lone minor (or two knights), the score clamps to 0: that side
cannot force mate, so the position's true upper bound is a draw. Fixes
horizon draw-dodging -- a lone-bishop side no longer shuffles at "+2.6"
against tripled pawns, avoiding the capture that would reveal the
insufficient-material draw (the reported position goes +2.92/shuffles ->
0.00/plays Kxc4). Oracle differential clean over 389 positions incl.
no-pawn endings; the fixed-depth ladder is untouched (the clamp cannot
fire while both sides keep pawns). **A/B vs v41: +3.27 +/-6.8 over
10,000 games @ 50+0.20 (50.47%%, ptnml 257/1115/2159/1215/254, pair
ratio 1.07, normalized +6.98)** -- a null KEPT as correctness
(PV-02/CB-01/EP-01/CB-02 precedent). Snapshotted as Old Engine/42.
* **v42 -> v43 (2026-07-11, lives in ``cengine.py`` + ``csearch.c``): NV-01
-- the first REMOVAL release.** v43 = v42 minus CB-02's deep-null
verification search (component (c) of the v41 batch). The isolation A/B
measured the removal at **+5.18 +/-6.8 over 10,000 games @ 50+0.20 vs
v42 (50.74%%, ptnml 258/1151/2068/1230/293, pair ratio 1.08, normalized
+10.82)**; combined with CB-02's own -2.88 lean, two independent reads
priced the zugzwang insurance at ~3-5 Elo of nodes-to-depth (it cost
~one ply in a fixed budget; d18 recovered at 5s startpos) -- so it goes,
matching modern practice (Stockfish-family runs unverified null-move;
has_non_pawn + the TT cover zugzwang). ``NULL_VERIFY=True`` restores
v42's verifying search. The rest of CB-02 (null-store policy, qsearch
50-move, fail-high adoption) stays. Snapshotted as Old Engine/43.
* **v43 -> v44 (2026-07-12, lives in ``cengine.py`` + ``csearch.c``):
FI-26a, the TT prefetch.** ``TT_PREFETCH(c.key)`` after apply_move at the
three child-recursion sites (negamax/qsearch/root) -- FI-01's incremental
child key made the prefetch address free, inverting P-45's original null.
NODE-IDENTICAL (+4.9%% NPS, median of 3/3 warmup-discarded pairs); the
timed A/B priced it at **+13.31 +/-6.8 over 10,000 games @ 50+0.20 vs
v43 (51.91%%, ptnml 250/1050/2073/1321/306, pair ratio 1.25, normalized
+27.85)** -- the biggest single NPS win of the C era in Elo terms.
Snapshotted as Old Engine/44.
* **v44 -> v45 (2026-07-12, lives in ``cengine.py`` + ``csearch.c``): FI-25,
the TT-value pruning-eval sharpener.** The TT hit's SEARCH value replaces
the raw static eval in RFP / null-move / frontier futility whenever its
bound provably improves the estimate (LOWER above / UPPER below / EXACT
always; non-mate values, any entry depth) -- strictly better information
the engine already held, Stockfish-family practice. static_eval stays RAW
for the FI-03 TT cache and the P-04 eval stack (exactness invariants).
A/B: **+13.52 +/-6.8 over 10,000 games @ 50+0.20 vs v44 (51.94%%, ptnml
225/1100/2056/1299/320, pair ratio 1.22, normalized +28.34)** -- back to
back with v44's +13.31; matetrack rose to 913/783 (baseline 896/767).
``TT_EVAL_SHARPEN=False`` restores v44 exactly. Snapshotted as Old
Engine/45.
* **v45 -> v46 (2026-07-13, lives in ``cengine.py``): TT doubled to 22 bits
(96 MB, from 48 MB).** Motivated by a live hashfull capture -- a single
deep search fills ~half the 48 MB table, and the warm persistent TT then
climbs past 950%% within a game. A/B vs v45 at the full 223-worker load:
**+5.94 +/-6.8 over 10,000 games @ 50+0.20 (50.85%%, ptnml
264/1157/2014/1274/291, pair ratio 1.10, normalized +12.33)** -- a
BORDERLINE-positive (the 95%% CI just touches zero) shipped on the
monotonic-low-risk rationale: a bigger table cannot worsen decision
quality at fixed nodes, and its only downside vector (DRAM bandwidth) was
exercised at the load where it bites hardest = net positive. Between
FI-25 and this, FI-18 (SEE pruning, -1.25) and FI-06 (root ordering,
+2.26) both read null and are dormant. ``TT_BITS=21`` restores v45's
48 MB table. Snapshotted as Old Engine/46.
* **v46 -> v47 (2026-07-13, lives in ``cengine.py``): TT to 23 bits (192 MB)
+ MultiPV.** The 96->192 MB increment: A/B vs v46 at the full 223-worker
load **+3.16 +/-6.8 over 10,000 games @ 50+0.20 (50.46%%, ptnml
258/1211/2018/1208/305, normalized +6.54)** -- net-positive so DRAM
bandwidth had not bitten, shipped on the same monotonic-low-risk rule.
The diminishing +5.94 -> +3.16 (halving per doubling) closes memory
scaling -- 24 bits would be sub-noise. v47 also adds **MultiPV** (UCI
spin 1..5, a C root-move exclusion list, abi 10; node-exact when off, so
=1 and all match play are byte-identical). ``TT_BITS=22`` restores v46.
Snapshotted as Old Engine/47.
* **v57 -> v58** (2026-08-03, lives in ``cengine.py``/``NNUE/``): the **first
HCE/NNUE hybrid** and the first net that pays. ``USE_NNUE`` armed on
``nnue_v4_6f910e35bb1e.nnue``: **+19.11 +/-7.8 over 3,404 games**, TIMED
50+0.20 on x86, GSPRT[0,4] LLR +2.950 ACCEPT H1 at 1,702 pairs, ptnml
71/358/691/477/105. Ledger +305 -> +324. Bench 1,145,629 -> 1,074,820 with
NPS down ~30% to the SIMD tail -- the +19 is measured net of that cost.
Against v3's +0.52 +/-6.8 on the same instrument, the difference is purely
the TRAINING: same data, same dimensions, cosine LR instead of flat, val
0.074417 -> 0.066663. arm64 confirmation is owed (v3 read +5.70 there).
* **v56 -> v57** (2026-07-31, lives in ``cuci.py``): host layer only, and the
**last pure-HCE release** -- from here the engine is an HCE/NNUE hybrid.
Node-identical to v56 (bench 1,145,629), so no A/B slot and no ledger
movement. Ponderhit now honours the P-35/U-06 soft-stop instead of spending
the whole fresh budget re-confirming a settled move (1.666s -> 0.686s); the
soft-stop neighbourhood is exposed over UCI; and cuci no longer overwrites
the engine's own ``soft_stop_frac`` with a hardcoded copy of it.
* **v54 -> v55** (2026-07-25, lives in ``cengine.py``/``csearch.c``): a
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 -- 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. Timed on purpose: a fixed-node instrument reads exactly zero
for a change that buys no new nodes. Gave the conversion **~1.16 Elo per 1%
NPS**, which prices every bench item since.
* **v55 -> v56** (2026-07-30, lives in ``csearch.c``): **FI-107 ProbCut**, the
fail-high half of forward pruning. A qsearch filters each capture at
beta + 200 and a reduced real search confirms before anything is cut, so no
prune ever rests on a static score. Bench 1,461,732 -> 1,145,629 (-21.6%) at
a 2.4% NPS cost. **+4.11 +/-4.2 over 21,806 games** on --nodes, and
**+11.44 +/-6.9 over 5,940 games** TIMED vs Old Engine/55 -- the timed
figure is banked (ledger +305). The gap is the lesson: --nodes under-credits
node-saving changes because its calibration charges them for their own NPS
cost. Nothing in this file changed.
* **v53 -> v54 (2026-07-23, lives HERE in ``engine.py``): the PST retune --
the second-largest release, and the first time the piece-square tables
themselves were fitted.** v53 tuned the 44 scalars *conditioned on* the
stock PeSTO tables; v54 adds all 736 table entries (12 tables x 64 minus
the 16 impossible pawn squares) to the fit, ``tuning/texel.py --pst``, ±25cp
bounds, on 5,000,000 own-self-play positions. 735 values moved vs v53.
**A/B vs Old Engine/53: +31.20 ±5.6 over 11,668 games @ nodes 1,750,000
(54.48%, ptnml 312/1142/2185/1579/616, ratio 1.51), GSPRT[0,2] LLR
+7.806 -> ACCEPT** -- both split halves positive (+35.09 / +27.13); a 2k
screen had read +32.41. The endgame values continued v53's direction and
the king tables shifted hard, which is why matetrack was the gating
pre-ship check. Bench signature 1,122,753 -> 1,461,732; both selftest
pins re-measured (CE_LADDER d14 1,921,549, REF_NODES 2874). NOTE the
held-out loss that screened it (+2.26%) was inflated by the FB-43
train/val split leak, fixed the same day -- the A/B, not the loss, is the
truth here. Snapshotted Old Engine/54. Re-tune with ``tuning/texel.py --pst``.
* **v52 -> v53 (2026-07-22, lives HERE in ``engine.py``): the Texel retune
-- the largest single gain the project has recorded, and the eval lane's
first win.** v48-v52 were all search/TT work in ``cengine.py``; this one
is 44 eval scalars and no C change at all, since ``cengine.py`` pushes
this file's constants into ``csearch.so`` at construction (the eval-param
oracle, ``cengine.py:940``). Fitted by ``tuning/texel.py`` on 4,000,000 quiet
positions from this project's own near-equal self-play logs, labelled
with the GAME RESULT rather than a Stockfish score -- 1.43%% better on a
held-out 20%% split, converged across 10 restarts.
**A/B vs Old Engine/52: +37.52 +/-6.3 over 12,000 games @ nodes
1,750,000 (55.38%%, ptnml 245/1133/2264/1802/556, pair ratio 1.71),
GSPRT[0,2] LLR +9.918 -> ACCEPT** -- 3.4x the accept bound, against
previous bests of +4.549 (FI-56) and +4.533 (FI-24). Three disjoint
slices of the seeded pool all agree within noise: the 1,000-position
screen at offset 0 (+35.39), then two 2,500-position halves on separate
servers at offsets 1000 and 3500 (+39.08 and +36.83).
41 of 44 parameters moved. The middlegame came down and the endgame went
up, widening the taper (MG N 353->307, B 356->323, R 489->443; EG B
328->348, R 570->609, Q 1020->1062), mobility rose across the board
(4/3/2/1 -> 6/5/3/3), and the terms the SEARCH already resolves
concretely were driven toward their floors (PASSED_PAWN_MG rank 6
105->42, ROOK_ON_7TH 18/32 -> 8/15, TEMPO 20->8). That last group is the
known static-fit-vs-search mismatch and was the reason to screen before
spending a slot -- the screen said the material and mobility gains win
anyway. Being an eval change it re-pins BOTH ``selftest.py`` pins
(``CE_LADDER`` d14 1,716,693 -> 2,053,985 and ``REF_NODES`` 3495 ->
2950; ``--recompute-ladder`` regenerates only the former) and moves the
bench signature 1,052,763 -> 1,122,753. Snapshotted as Old Engine/53.
Re-tune with ``python3 tuning/texel.py extract && python3 tuning/texel.py tune``.
Cross-version benchmark
-----------------------
Sweep (2026-07-02): 24 versions x 8 positions x 6 timed 5s runs (1152 searches).
* **NPS +79.2%** v1->v24.
* **Search depth +7.96 ply** (9.98 -> 17.94) -- the cleaner signal, since one
position hits the depth cap on every version and dilutes the NPS aggregate.
* v9 (LMP) and v18->v19 (Zobrist/shared-TT) dip in NPS but gain depth --
heavier, smarter search, not a slowdown.
* v16/v17 (C-eval / C-movegen) are the two biggest wins in both metrics.
A/B result (2026-07-04): v24 vs v21, 10,000 games @ ≈8.3 s/game -> **+11.75
+/-6.8 Elo** (51.69%; normalized +22.08; pentanomial 362/1120/1802/1250/466,
pair ratio 1.16). The whole v21->v24 batch is net-positive; NPS moved only
≈+2%, so the gain is mostly the bug fixes (low-phase king safety,
mate-at-clock-100, LAZY_MARGIN).
**Chain-composition audit (2026-07-07): v28 vs v25 DIRECT, 5,000 games @
45+0.1s -> +80.56 +/-10.2 Elo** (61.39%, ptnml 161/309/851/588/591, pair
ratio 2.51, normalized +136.01). The chained adjacent A/Bs predicted +90.2
+/-11.3 (41.9 + 35.2 + 13.1); the 9.6-Elo gap is ~0.6 sigma of the combined
error, so adjacent gains COMPOSE within noise (at most a mild ~10% haircut).
The version-history Elo ledger can be read cumulatively.
Strength (absolute, vs Stockfish)
----------------------------------
Latest (measured on v25; v26 AND v27 are search-identical so the figure
carries -- v26/v27 only made the same search faster,
2026-07-05): vs ``stockfish_engine.py`` at Stockfish Elo 2450,
2,500 games @ ≈7.2 s/game (match.py harness, engines single-threaded per its
default SMP override): 886W / 670D / 944L = 48.84% -> **-8.1 +/-13.6 Elo**
(normalized -13.7; pentanomial 172/242/451/242/143, pair ratio 0.93).
Point estimate **≈2442**, CI ≈[2428, 2456] -- statistically level with
Stockfish 2450; call the current single-thread strength **≈2440-2450**.
**Caveat discovered 2026-07-07 -- Elo-LIMITED Stockfish compresses
differences and is retired as a progress instrument.** A controlled pair on
one server (identical conditions): v29 vs SF-2550 = -97.8 +/-14.7; v25 vs
the same SF-2550 = -120.1 +/-15.3 -- only ~22 Elo of gap, where direct
Pygin-vs-Pygin measurement at the same conditions puts v25 -> v29 at +119
(chain-audit validated). The UCI_Elo limiter injects errors at a calibrated
*rate*; exploiting them is nearly fixed-yield, so real strength differences
read ~5x smaller against it. The ≈2442 bracket above remains a fair
one-time class estimate, but version-over-version progress must be read
from the internal ledger (chain-audit validated) and from the odds series
below.
**Odds series vs FULL-strength Stockfish 18 (no limiter, 45+0.15):**
**ENVIRONMENT CAVEAT (2026-07-10): knight-odds percentages are NOT
comparable across machines.** Local-Mac re-runs measured v35 at 68.1% and
v36-dev at 71.0% (400 games each, 10 workers) -- v36 > v35 as the internal
ledger predicts, but BOTH far below the 76.75% v31 baseline, which was run
in a different environment (Stockfish's per-move strength scales with the
host CPU; worker contention differs too). Re-base the yardstick per
environment; the internal ledger remains the source of relative truth.
queen odds (Qd1) 100/100 (2026-07-06, saturated); rook odds (Ra1): 48.00%
(v29 era) -> 50.50% (v30, 2026-07-07) over 100 games each -- dead even --
**-> 93.25% (v31 C core, 2026-07-08: 400 games, 364W/18D/18L, +456 +/-169
Elo, 345 wins by checkmate).** The line the +139 Elo of v25->v30 could not
budge moved ~450 Elo in one step, confirming the C core externally (its
internal 30-game gate vs v30 was 29W/1D/0L). Rook odds is now SATURATED
as a yardstick, like queen odds before it. **Knight odds (Nb1) is the
current external progress benchmark -- v31 baseline (2026-07-08): 76.75%
(400 games, 287W/40D/73L, +207 +/-48 Elo)** -- decisive but far from
ceiling, with a CI tight enough to resolve future ~+50 Elo steps.
Full-strength SF plays real chess (no error scheduler), so this series
actually moves with engine improvements (~400 games, ~7 s/game).
Earlier SMP benchmark: Stockfish skill ≈2400, engine running
``SMP_WORKERS = 4``: 400 games, 188W / 131D / 81L (63.4%) -> +95 +/-37 Elo
(4h25m, ≈40s/game), i.e. ≈2495 -- consistent with the single-thread figure
above plus the SMP-4 depth gain. This sits on top of v16/v17's
C-eval/C-movegen work (+69 Elo) and v19's optimization-reference work above.
An older 6000-game baseline (≈2341 vs Stockfish 2350) predates the C work
and ran single-threaded, so it's no longer comparable.
NB on PyPy: the old "≈1.5x faster" guidance is STALE. Once v16/v17 moved eval
and move-gen behind ctypes, PyPy's FFI cost erodes its edge -- a properly
warmed PyPy is only ≈+25% over CPython here (and *cold* PyPy is slower, so
short searches favour CPython). PyPy's JIT can't see through the ctypes/
python-chess boundary, which is also why a Cython build and a hand-written
bitboard board layer (both built and measured, never folded in -- see below)
couldn't beat it by much.
Rejected / shelved experiments
-------------------------------
* **King shelter at C-core depth** (P-20a, 2026-07-08): the strongest
v21-era eval toggle (+10 +/-10 solo at depth 8) re-tested on v32 at
depth ~14: **-4.27 +/-6.8 over 10,000 games** (49.38%, norm -7.98).
The depth-8 signal did not survive: deep search sees king attacks
concretely and subsumes the static term. ``use_king_shelter`` stays
False everywhere; expect the same fate for the weaker toggles
(phalanx +3, outpost +0) -- re-test only with a strong new reason.
* **Probcut** (v15): +4 +/-12 Elo at 1s/move, ≈0 at 500ms -- null at both
tested TCs. Removed; design and bug history are in git.
* **Cython search core** (``engine_cy_build.py`` compiles engine.py
unchanged): warmed PyPy (77.8k NPS) still beats it (71.3k), because the
hot path is python-chess board ops that PyPy JITs and Cython (external,
C-API speed) cannot. Kept only as an optional no-warmup CPython build;
not folded into engine.py.
* **Own bitboard board layer** (``fastboard.py``, a drop-in ``FastBoard``
for python-chess's Board): python-chess turned out to already be a tight
pure-Python bitboard engine with O(1) magic attack tables, so the
"python-chess is naive/object-heavy" premise was largely false. Only +9%
CPython / ≈parity PyPy. Not integrated into the engine.
* **Outpost** (``use_outpost``): +0 +/-10 Elo (5000 games).
* **Space** (``use_space``): -9 +/-14 Elo (5000 games).
* **Phalanx / connected pawns** (``use_phalanx``): +3 +/-10 Elo (5000 games,
local only).
* **Pawn storm** (``use_storm``): -5 +/-10 Elo (5000 games).
* **King shelter depth** (``use_king_shelter``): +10 +/-10 Elo solo (5000
games) -- borderline, so tested combined instead of folded in alone.
* **Combined outpost + phalanx + shelter** (30,000 games vs the v21 base):
**+5 +/-4 Elo FOR the base** -- i.e. ≈5 Elo weaker combined; individually-
marginal features didn't compose additively. All five features above stay
OFF; eval-tuning phase closed 2026-07-02.
* **History malus on capture cutoffs (P-41)** (penalize the quiets searched
before a CAPTURE causes the cutoff, as Stockfish does -- the existing malus
only fires on quiet cutoffs): **-7.99 +/-9.6 Elo** (5,000 games vs v28 @
45+0.1s, 48.85%, pair ratio 0.88, normalized -13.69). Expected +0-4;
measured clearly negative -- with this engine's ordering, penalizing the
whole quiet list on every capture cutoff over-damps quiet history (capture
cutoffs vastly outnumber quiet ones, so the malus flux swamps the bonus
flux). Reverted 2026-07-07; the quiet-cutoff-only malus stays.
Future Improvements (vs. Stockfish)
-----------------------------------
A review of the Stockfish architecture (as documented on the Chessprogramming Wiki)
highlights several advanced features missing from this engine that could yield
Elo or NPS gains. (Note: Outposts, Space, Phalanx, Pawn Storm, and King Shelter
were all tested and kept OFF -- see "Rejected / shelved experiments" above.)
1. Search Enhancements
- Singular Extensions: IMPLEMENTED 2026-07-07 (P-33, ``use_singular_ext``
-- see "Search features" above). A/B verdict: no Elo at this engine's
working depths (null at min-depth 8, negative at 6); kept as dormant
infrastructure for deeper searches.
- Null Move Verification: A shallow verification search before returning a
null-move cutoff at high depths (depth >= 10–12) to avoid incorrect prunes
in zugzwang positions. Low code cost; prevents rare but decisive errors
in K+P endgames.
- TT Prefetching: Using CPU instructions (e.g. __builtin_prefetch) to load
the Transposition Table entry into the CPU cache before it is needed.
Only relevant once the TT is a C array (the dict TT has no fixed layout
to prefetch).
Note: Move Count Based Pruning IS already implemented here as LMP
(``use_lmp = True``, ``LMP_COUNT = {1:6, 2:10, 3:14}`` -- see above).
2. Evaluation Enhancements (Classical)
- Material Imbalance Tables: Scoring how specific pieces coordinate (e.g.,
Bishop pair + Knights vs. Rooks) rather than just summing piece values.
- Material Hash Table: Caching material evaluations for a large speedup since
material changes rarely.
NNUE (why it isn't here, and the ultimate goal)
-----------------------------------------------
A real NNUE is **not** integrated, on purpose:
* Pure-Python inference needs an incremental accumulator plus ≈hundreds of
multiply-accumulates per node. At the few-tens-of-thousands of nodes/sec this
interpreter manages, that makes the engine *slower*, not stronger.
* Doing it properly would need a C extension (e.g. a Stockfish binding) --
which defeats the "from scratch in Python" goal.
* Instead, the hand-crafted eval was expanded (backward pawns, per-piece
mobility, king-zone attacker counts, rook files, bishop pair, tempo) as the
practical substitute.
That said, a proper NNUE remains the biggest single upgrade available:
replacing all hand-crafted terms with learned weights could be worth
**+200-300 Elo**.
"""
# lib/ holds the shared support modules (time_manager, wdl, interruptible,
# smp, shared_tt) since the 2026-07-24 reshuffle. They stay importable by
# their plain names, so nothing else in the tree had to change.
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "lib"))
import ctypes
import json
import math
import operator
import os
import random
import sys
import time
import urllib.parse
import urllib.request
import chess
import chess.polyglot
# --- #8: C evaluation module for mobility + king-safety ------------------- #
_EVAL_C_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'eval_c.so')
try:
_eval_lib = ctypes.CDLL(_EVAL_C_PATH)
_eval_lib.set_mobility_params.argtypes = [ctypes.c_int] * 11
_eval_lib.set_mobility_params.restype = None
_eval_lib.set_mobility_eg.argtypes = [ctypes.c_int] * 4 # FI-86
_eval_lib.set_mobility_eg.restype = None
_eval_lib.mobility_king_safety.argtypes = [
ctypes.c_uint64, ctypes.c_uint64, # occ_w, occ_b
ctypes.c_uint64, ctypes.c_uint64, # knights, bishops
ctypes.c_uint64, ctypes.c_uint64, # rooks, queens
ctypes.c_uint64, ctypes.c_uint64, # wp, bp
ctypes.c_uint64, # kings (C derives wksq/bksq, U-04)
ctypes.c_int, # phase
]
_eval_lib.mobility_king_safety.restype = ctypes.c_int
# #2.5: rook_files + bishop_pair + mopup constants. set_positional_params
# keeps the C-side values in sync with the Python tuner (called once from
# Engine.__init__, same pattern as the mobility params). These constants
# are read by mobility_king_safety's inlined #2.5b pass + folded mopup.
_eval_lib.set_positional_params.argtypes = [ctypes.c_int] * 9
_eval_lib.set_positional_params.restype = None
# #3.x: rook on 7th rank. Phased (mg, eg) weights; 0/0 disables on the
# C side (the toggle that gates the Python fallback also chooses what
# to pass here).
_eval_lib.set_rook_on_7th_params.argtypes = [ctypes.c_int, ctypes.c_int]
_eval_lib.set_rook_on_7th_params.restype = None
# #3.x: mobility-area toggle (1 = subtract enemy-pawn-attacked squares
# from each piece's mobility count, 0 = legacy behaviour).
_eval_lib.set_mobility_area.argtypes = [ctypes.c_int]
_eval_lib.set_mobility_area.restype = None
# #3.x: threats (pawn -> enemy non-pawn, minor -> enemy major). 0/0 off.
_eval_lib.set_threats_params.argtypes = [ctypes.c_int, ctypes.c_int]
_eval_lib.set_threats_params.restype = None
# Outpost bonus (knight/bishop on pawn-supported, enemy-pawn-safe sq).
_eval_lib.set_outpost_params.argtypes = [ctypes.c_int] * 5
_eval_lib.set_outpost_params.restype = None
# Space bonus (safe central squares c-f, ranks 2-4/5-7).
_eval_lib.set_space_params.argtypes = [ctypes.c_int, ctypes.c_int]
_eval_lib.set_space_params.restype = None
# Phalanx / connected pawns.
_eval_lib.set_phalanx_params.argtypes = [ctypes.c_int] * 3
_eval_lib.set_phalanx_params.restype = None
# Pawn storm toward enemy king.
_eval_lib.set_storm_params.argtypes = [ctypes.c_int] * 3
_eval_lib.set_storm_params.restype = None
_eval_lib.set_shelter_params.argtypes = [ctypes.c_int] * 3
_eval_lib.set_shelter_params.restype = None
# Roadmap item #15: Static Exchange Evaluation, ported from _see/
# _see_attackers/_least_valuable_attacker (engine.py) to eval_c.c.
_eval_lib.see.argtypes = [
ctypes.c_uint64, ctypes.c_uint64, # pawns, knights
ctypes.c_uint64, ctypes.c_uint64, # bishops, rooks
ctypes.c_uint64, ctypes.c_uint64, # queens, kings
ctypes.c_uint64, ctypes.c_uint64, # occ_w, occ_b
ctypes.c_int, # turn (1=white is the mover)
ctypes.c_int, ctypes.c_int, # from_sq, to_sq
ctypes.c_int, # is_ep
]
_eval_lib.see.restype = ctypes.c_int
# P-12: ABI handshake. Bump together with abi_version() in eval_c.c on
# any export-signature or semantics change -- a stale-but-loadable .so
# must be rejected here, not trusted silently.
_EVAL_C_ABI = 6 # 6: FI-85 removed (set_xray_mob is a no-op);
# 5: FI-86 set_mobility_eg; 4: FI-85; 3: U-04 kings bb
_eval_lib.abi_version.restype = ctypes.c_int
if _eval_lib.abi_version() != _EVAL_C_ABI:
raise OSError(f"eval_c.so ABI {_eval_lib.abi_version()} != expected "
f"{_EVAL_C_ABI} (rebuild: python3 scripts/eval_build.py)")
_USE_C_EVAL = True
except (OSError, AttributeError) as _e: