-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmatch.py
More file actions
3044 lines (2827 loc) · 153 KB
/
Copy pathmatch.py
File metadata and controls
3044 lines (2827 loc) · 153 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
"""
match.py
========
Headless engine-vs-engine match runner -- everything ``engine_battle.py`` does
(subprocess engines with watchdogs, each position played both colours, the same
log-file format and Elo summary) but with NO pygame, so it runs anywhere and,
crucially, under PyPy:
Usage::
python3 match.py [engine1.py] [engine2.py] [num_positions] [--workers N] [--engine-smp N]
[--force-nodes-1 N --force-nodes-2 M] (explicit per-side node budgets, NO
calibration. --nodes derives each side's budget
from THIS box's NPS, which is what makes such a
run un-poolable with the same run elsewhere;
naming both budgets removes the machine from the
experiment so two boxes play the identical
contest and their pentanomials add. Both are
required together. Fairness becomes yours to
own: take the pair from ONE calibrated run and
reuse it. The mode string records them, so a
calibrated run resuming a forced state file
warns.)
[--total-time 1d,10h,4m,10s] (WALL-CLOCK budget: play until it expires,
then stop. Overrides the position count --
the schedule becomes the rest of the pool and
the clock decides. --offset, --seed, --sprt and
the state file all still apply. Any subset of
d/h/m/s, run together or separated:
4h, 30m, 2h50m, "2h 15m", 1d,10h.)
[--offset N] (opening-pool offset; the 4th POSITIONAL still
works, but this wins and is unambiguous. The
range is printed at the start, in the log
header and in the closing summary.)
[--push-state] (git add+commit+push the state file when the
run ends -- the campaign lives in git, the
rented box is disposable. Silent no-op without
a repo/remote/credentials.)
[--seed S] (SUBSET_SEED for the pool shuffle; "none" =
unshuffled. The pool is shuffled BEFORE it is
sliced, so the same offset under a different
seed is a DIFFERENT set of openings.)
[--tc 10+0.1] (time control: base+increment in seconds,
e.g. 50+0.2 or 10+0.1 or plain 10. Implies
--mode clock. --tc-seconds/--tc-increment set
the two halves separately.)
[--time-per-move MS] [--fixed-depth D] (fixed ms or fixed plies per move; each implies
its own --mode, like --nodes does)
[--book1 book.bin] [--book2 book.bin] (per-engine opening books; book testing)
[--start-pos True] (all games from startpos, ignore the FEN file)
[--sprt] [--sprt-min-pairs N] (SPRT early-stop: quit as soon as the result is
provably good/bad instead of playing the whole
budget -- default [0, 4] normalized, a=b=0.05;
override --sprt-elo0/elo1/alpha/beta/model)
[--sprt-resume state.json] (pool this tranche with the earlier ones in that
file: the LLR continues instead of restarting.
Refuses to pool a different experiment or an
overlapping --offset; written on every exit
path, including Ctrl-C. OPTIONAL: --sprt alone
auto-names one after the run and prints the
path at startup and in the summary, so a
no-decision tranche is never unpoolable)
Arguments (all optional, fall back to CONFIG section below):
engine1.py path to engine 1 (default: ENGINE_1)
engine2.py path to engine 2 (default: ENGINE_2)
num_positions positions to test; each is played TWICE (both colours)
so total games = num_positions * 2 (default: NUM_GAMES)
offset skip this many positions into the pool (for non-overlapping
parallel runs on different machines) (default: 0)
--workers N parallel game pairs; keep N * 2 <= CPU cores (default: N_WORKERS)
0 or 'auto' => all cores but one
--engine-smp N SMP workers inside each engine; use 1 for match runs,
higher only when playing a single game (default: 1)
Examples::
python3 match.py engine.py engine_phalanx.py 2500 --workers 10 --engine-smp 1
python3 match.py engine.py "Old Engine/21/engine21.py" 1000 --workers 5
python3 match.py engine.py engine_phalanx.py 2500 1000 --workers 5 # offset=1000
# run-to-decision campaign: tranche 2 continues tranche 1's LLR
python3 match.py A.py B.py 5000 0 --workers 0 --sprt --sprt-resume ab.json
python3 match.py A.py B.py 5000 5000 --workers 0 --sprt --sprt-resume ab.json
# same thing without naming the file: tranche 1 auto-writes
# sprt_A_vs_B_<stamp>.json and prints it; feed that path to tranche 2
python3 match.py A.py B.py 5000 0 --workers 0 --sprt
Progress is streamed to the terminal; a full per-move/PGN log is written to a
file named like ``<e1>_vs_<e2>_<timestamp>_<pid>.txt``.
Run several copies in parallel for more games (with a fixed SUBSET_SEED they all
draw the SAME positions, so results stay directly comparable / poolable).
Press Ctrl-C to stop early -- the summary (with Elo so far) is still written.
"""
# lib/ holds the shared support modules (time_manager, 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"))
# ====================================================================== #
# CONFIG -- edit these
# ====================================================================== #
ENGINE_1 = "engine.py" # path to engine 1
ENGINE_2 = "Old Engine/58/engine58.py" # path to engine 2
# FB-50 fold-in: was v51 while
# SUBSET_SEED said 55 -- the
# default opponent tracks the
# snapshot the seed belongs to
FEN_FILE = "UHO_4060_v4.epd" # positions (plain FEN or EPD, one per line). UHO_4060_v4.epd (16 MB, balanced Stockfish openings) is the default. fen.txt (447 KB) is also bundled as a small fallback; a bigger book (UHO_Lichess_4852_v1.epd, 174 MB) is at https://github.com/official-stockfish/books
other_elo = 2900
# PGN-header tag only (cosmetic). Reads the SAME env/default as
# stockfish_engine.py's SF_ELO so the tag can't drift from what the
# limiter is actually set to -- the 2026-07-16 run played at 2700 while
# a stale hardcoded 2600 here went into every PGN header.
import os # config-time env read; harmlessly re-imported below
stockfish_elo = 3000 # --sf-elo N; <= 0 = full strength.
# MUST match stockfish_engine.py's SF_ELO -- see
# the reason for 3000 there.
# FI-88: in CLOCK mode, an engine that manages its own clock (only Stockfish)
# is handed `go wtime/btime/winc/binc` and budgets each move itself. Set True
# (--sf-our-clock) for the pre-2026-07-24 behaviour, where Pygin's own
# time_manager computed the ms for BOTH sides and SF's manager never ran.
# Our engines are unaffected either way -- they have no internal clock.
sf_our_clock = False
NUM_GAMES = 5000 # number of starting POSITIONS to play (default when
# no arg passed). Each position is played twice --
# once with each engine as White -- so the actual
# TOTAL games played is NUM_GAMES * 2.
# (Controls for colour bias: every engine plays the
# same starting positions once with each colour.)
MODE = "clock" # "time" -> fixed milliseconds per move (TIME_PER_MOVE_MS)
# "depth" -> fixed search depth in plies (FIXED_DEPTH)
# "clock" -> real clock per side (TC_SECONDS + TC_INCREMENT),
# per-move budget via time_manager.calculate_move_time
TIME_PER_MOVE_MS = 1000 # used when MODE == "time"
FIXED_DEPTH = 10 # used when MODE == "depth"
TC_SECONDS = 50 # used when MODE == "clock": starting clock per side, in seconds
TC_INCREMENT = 0.50 # used when MODE == "clock": seconds added per move
# ERA NOTE: 45+0.10 through v36 (the whole ledger
# v21..v36); 50+0.20 from v37-era A/Bs through v58;
# 50+0.50 from 2026-08-04 on. Cross-era Elo numbers
# are NOT the same currency, and this is an era
# BOUNDARY: nothing measured at 50+0.20 pools with
# anything measured here.
# WHY 0.50 and not 0.25: increment = base/100 is
# Fishtest's whole family (10+0.1, 60+0.6,
# 150+1.5), so a screen and a confirmation stay in
# one ratio and outside rules of thumb transfer.
# 50+0.25 scales arithmetically but matches nobody.
# odds.py reads THESE values -- one source of truth
# (it carried its own 45+0.15 until this change,
# which is why the odds yardstick was quietly on a
# different clock from every A/B).
# --- WDL-based adjudication (OFF until data/wdl_model.json is calibrated) ------- #
# Shortens decided games: a win is adjudicated when BOTH engines' own
# reported scores agree the game is over (leader >= +threshold, opponent
# <= -threshold, each for ADJ_WIN_COUNT consecutive own moves), where the
# threshold is the cp at which the fitted WDL model says P(win) >= ADJ_WIN_P
# at the current phase. A draw is adjudicated late in level games. Needs
# data/wdl_model.json (written by tuning/fit_wdl_model.py); silently stays off without it.
# `--adj off` disables it per run without editing this file. Use that for
# CROSS-FAMILY matches (e.g. vs stockfish_engine.py): the WDL model is fitted
# on THIS engine's score scale, so the two-sided agreement rule loses its
# calibration against a foreign engine's cp reports. Same-family A/Bs only.
ADJUDICATE = True
ENGINE_SMP = 1 # Lazy-SMP workers inside EACH engine subprocess.
# --smp N overrides it for one run (--engine-smp
# is kept as an alias). Passed to the engine child
# as an argument -- it used to be exported as
# CLAUDECHESS_SMP, which is gone.
# Keep ENGINE_SMP * N_WORKERS * 2 (two engines per
# game) <= CPU cores or you oversubscribe and lose
# throughput.
ADJ_WIN_P = 0.99 # per-phase cp threshold = model's P(win) 99% point
ADJ_WIN_COUNT = 4 # consecutive own moves (each side) for a win call.
# 8 -> 4 (2026-07-18): the two-sided 99%-agreement
# rule below (leader >= +thr AND loser <= -thr) is
# what makes a false win near-impossible; the move
# debounce is only anti-blip. 4 ends decided games
# ~8 plies sooner, outcome-identical on real wins
# (cutechess resign defaults are 3-4). Draw params
# left conservative on purpose (FI-28 endgame-mask).
ADJ_DRAW_CP = 10 # |cp| <= this from both sides...
ADJ_DRAW_COUNT = 16 # ...for this many consecutive plies...
ADJ_DRAW_MIN_PLY = 100 # ...never before this game ply
ENGINE_USE_BOOK = False # opening books off -> a fair, search-only test
# Per-engine book override (BOOK TESTING): a Polyglot .bin path per side,
# e.g. "Perfect2023.bin". Setting one turns the book ON for that engine
# only, regardless of ENGINE_USE_BOOK -- so two books can be A/B'd against
# each other, or one side plays booked vs the other bookless. None = that
# engine follows ENGINE_USE_BOOK (and the default candidate scan). CLI:
# --book1 PATH / --book2 PATH.
BOOK_ENGINE1 = None
BOOK_ENGINE2 = None
# Start every game from the standard STARTING POSITION instead of FEN_FILE
# (CLI: --start-pos True). Meant for book testing (--book1/--book2): the
# UHO/EPD openings are deliberately ~8-12 plies deep, PAST book, so books
# never fire from them. Game variety then comes from the books' weighted-
# random move choice -- two bookless deterministic engines from startpos
# would repeat the same game, so leave this False for normal A/Bs.
START_POS = False
SUBSET_SEED = 64 # FIXED so parallel windows shuffle identically.
# ROTATION POLICY (2026-07-18): bump to the new
# version number at every vN snapshot -- within an
# era every campaign (and its extension tranches,
# which NEED the same seed for disjoint offset
# shards) shares one 5000-of-241k opening sample;
# across eras the book is resampled so the ledger
# can't slowly overfit one fixed 2% subset. 42 was
# the v31-v49 era seed (campaigns 1-24), 59
# the v59 era; rotated to 60 at the v60
# snapshot (2026-08-23).
MAX_PLIES = 200 # games longer than this are adjudicated a draw
VERBOSE_MOVES = False # also print every move to the terminal
# (per-move info is ALWAYS written to the log file)
N_WORKERS = 10 # parallel game workers (override via --workers N|auto)
# 1 -> sequential (one engine pair, plays all games)
# >1 -> N worker processes, each with its own engine pair
# ====================================================================== #
# Internals (rarely need changing)
# ====================================================================== #
PV_UCI = True # PV format in the log: True = UCI (g1f3), False = SAN (Nf3)
MAX_DEPTH_CAP = 30 # max_depth handed to the timed search
DEPTH_SAFETY_CAP = 30.0 # seconds: watchdog for a runaway fixed-depth search
TIME_OVERSHOOT_FACTOR = 2.0 # time-mode watchdog = budget * factor + grace
TIME_GRACE = 4.0
LOAD_TIMEOUT = 30.0 # seconds to wait for an engine process to load
import datetime
import hashlib
import re # FI-82: --sprt-resume experiment fingerprint
import json
import math
import multiprocessing as mp
import os
import signal
import sys
import threading
import time
from queue import Empty
import chess
import chess.pgn
from battle_worker import (engine_worker, nnue_label,
describe_nnue_source)
from time_manager import calculate_move_time
# Optional SPRT early-stop (--sprt). Imported defensively so a broken/missing
# sprt.py can never take a match down -- the feature just goes unavailable.
try:
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"testing")) # moved 2026-07-24
import sprt as _sprt
except Exception:
_sprt = None
# Don't evaluate the SPRT on a tiny sample (the LLR is noisy early and could
# early-stop on a fluke); wait for this many PAIRS first. 250 pairs = 500
# games -- a decision can't fire before then. (Was 500; halved 2026-07-29 on
# the user's call. The Wald bounds already control the error rates -- this
# floor only guards the pathological first-few-pairs regime, and 500 pairs
# was costing an hour before a landslide could ever be called.)
# --sprt-min-pairs N raises it per run. Worth raising on a SCREEN, where the
# error bars are wide and an early crossing is more likely to be a fluctuation
# than a verdict -- and where the point is to kill the clearly-bad cheaply
# rather than to measure anything precisely.
SPRT_MIN_PAIRS = 250
# ---------------------------------------------------------------------- #
# FI-82: --sprt-resume, tranche pooling for run-to-decision campaigns.
#
# A sequential test that has not decided must KEEP SAMPLING (the FI-30
# lesson), and the next tranche usually runs on another box or another day.
# Pooling that by hand is where campaigns go wrong -- FI-30's tranche 4 was
# played on the WRONG CHECKOUT and only the server reflog caught it. So the
# state file therefore RECORDS every tranche's config in a `runs` list, so a
# pooled figure can always be audited after the fact.
#
# It used to REFUSE to pool anything whose fingerprint (incl. the sha256 of
# both engine .py files) did not match. Removed 2026-07-29 on the user's call:
# adding a COMMENT to cengine.py changed its hash and orphaned a campaign's
# prior games over a difference that cannot affect a single move. Record and
# warn; the operator judges. Corrupt data is still fatal -- that is not a
# config choice.
# ---------------------------------------------------------------------- #
def _sha16(path):
"""First 16 hex of a file's sha256, streamed (FEN pools reach 174 MB)."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()[:16]
SPRT_FP_VERSION = 2 # FB-49: bump when the tuple below changes, so an
# old state.json fails LOUDLY instead of pooling.
def _net_sha_for_engine(engine_py):
"""sha16 of the .nnue an engine source names, or None.
Deliberately a text scan, not an import. It only has to be right when it
matters -- a net that is named and present -- and being wrong here is
safe in the conservative direction: a missed net means the fingerprint
falls back to the engine .py hash, which already changes whenever the
named path changes."""
try:
src = open(engine_py, encoding="utf-8", errors="replace").read()
except OSError:
return None
m = re.findall(r'NNUE_FILE\s*=\s*["\']([^"\']+\.nnue)["\']', src)
if not m:
return None
p = m[-1]
if not os.path.isabs(p):
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), p)
return _sha16(p) if os.path.isfile(p) else None
def _net_lines(procs):
""""Net (name): ..." lines for whichever engines use one.
Prefers what the loaded engine REPORTED, and falls back to scanning its
source. Both are needed: the banner is printed before the engine
processes start, so nothing has reported yet, while the summary runs
after and should show what actually loaded rather than what the file
says it would."""
out = []
for proc in procs:
info = getattr(proc, "nnue", None)
if info is None: # not started yet -> read source
info = describe_nnue_source(proc.path)
lb = nnue_label(info)
if lb:
out.append(f"Net ({proc.name}): {lb}")
return out
_SHORT_REASON = {
"CHECKMATE": "mate", "STALEMATE": "stalemate",
"THREEFOLD_REPETITION": "3-fold", "FIVEFOLD_REPETITION": "5-fold",
"FIFTY_MOVES": "50-move", "SEVENTYFIVE_MOVES": "75-move",
"INSUFFICIENT_MATERIAL": "material",
"MAX_PLIES (adjudicated draw)": "max-plies",
"ADJUDICATION_WIN": "adj", "ADJUDICATION_DRAW": "adj-draw",
"TIME_FORFEIT": "time", "VARIANT_WIN": "variant", "VARIANT_DRAW": "variant",
"VARIANT_LOSS": "variant",
"WORKER_STARTUP_FAILED": "worker-start", "WORKER_EXCEPTION": "worker-exc",
}
def _games(n):
"""Apostrophe thousands separator, per the user's preferred format."""
return f"{int(round(n)):,}".replace(",", "'")
def short_reason(reason):
"""Compact form for the scrolling per-game line ONLY.
The canonical string still goes to the .txt log and every stored record,
so nothing downstream has to learn these abbreviations -- this is purely
to stop MAX_PLIES (adjudicated draw) eating half the terminal width."""
return _SHORT_REASON.get(reason, (reason or "").lower())
def sprt_resume_load(path, offset):
"""Read a tranche state file -> (base_penta, next_offset, prior_runs, note).
WARNS, never refuses. This used to hard-fail on a fingerprint mismatch so
that two tranches could not be pooled unless every config field and the
sha16 of BOTH engine files matched. That is the right rule for a
certification suite and the wrong one for a research tool: a comment added
to cengine.py changed its hash and orphaned an entire campaign's prior
games, for a difference that could not affect a single move. The user's
call, 2026-07-29 -- record the provenance, print what changed, and let the
operator judge. Every tranche's config is kept in `runs` so a pooled number
can always be audited after the fact.
A corrupt file is still fatal: a bad penta silently poisons the statistic,
which is different from a config the operator may legitimately have
changed."""
with open(path, "r", encoding="utf-8") as fh:
st = json.load(fh)
penta = [int(v) for v in st.get("penta", [0] * 5)]
if len(penta) != 5 or any(v < 0 for v in penta):
raise ValueError(f"state file: corrupt penta in {path!r}")
nxt = int(st.get("next_offset", 0))
prior_runs = list(st.get("runs", []))
note = (f"Resuming {path!r}: {sum(penta):,} prior pairs "
f"({'/'.join(str(v) for v in penta)}) over {len(prior_runs)} run(s)"
f"; the LLR continues from there.")
if offset < nxt:
note += (f"\n ** OVERLAP WARNING: offset {offset} is below the "
f"{nxt} this file has already consumed. Pooling a position "
f"twice is the SAME evidence counted twice, not more of it. "
f"Use --offset {nxt} unless you meant this.")
if st.get("decision") in ("H0", "H1"):
note += (f"\n NOTE: this file already records a {st['decision']} "
f"decision -- this run only adds to it.")
return penta, nxt, prior_runs, note
def sprt_resume_save(path, penta, next_offset, sprt_state, runs):
"""Dump the pooled state atomically.
Written at the START of a run, every SAVE_EVERY games, and from the finally
block -- so it survives Ctrl-C, SIGTERM, a kill and a crash alike, and a
run that dies at game 9,000 does not lose 9,000 games. `runs` is the
provenance list: one record per tranche, so a pooled figure can be audited
even though pooling is no longer gated on a fingerprint."""
st = sprt_state or {}
tmp = f"{path}.tmp" # never truncate a good state file on
with open(tmp, "w", encoding="utf-8") as fh: # a crash mid-write
json.dump({"penta": list(penta),
"next_offset": int(next_offset),
"pairs": int(sum(penta)),
"decision": st.get("decided"),
"llr": st.get("llr"),
"runs": list(runs)}, fh, indent=1, sort_keys=True)
os.replace(tmp, path)
# How often the state file is refreshed mid-run, in completed GAMES.
SAVE_EVERY = 50
def push_state_file(path):
"""Commit+push the state file from whatever box the campaign ran on.
A campaign spans machines: the state file IS the campaign and the rented
box is disposable. A tranche played on box A and left only on box A means
box B resumes from a stale pool and silently discards real games -- which
nearly cost 2,321 pairs of a 21,806-game verdict.
Best-effort and silent about the boring failures: no git, no remote, no
credentials on a fresh rental, nothing to commit. Never raises -- this runs
next to the summary, and the summary is the point of the run. Adds ONLY the
state file, never -A, so it cannot sweep up unrelated edits."""
import subprocess
name = os.path.basename(path)
try:
run = lambda *a: subprocess.run(a, capture_output=True, text=True,
timeout=120,
cwd=os.path.dirname(os.path.abspath(path)) or ".")
if run("git", "rev-parse", "--git-dir").returncode != 0:
return # not a repo; nothing to do
run("git", "add", "--", path)
run("git", "commit", "-m", f"{name}: tranche state")
r = run("git", "push")
print(f"State pushed to git: {name}" if r.returncode == 0 else
f"State NOT pushed ({name}): {r.stderr.strip().splitlines()[-1] if r.stderr.strip() else 'push failed'}")
except Exception as ex:
print(f"State NOT pushed ({name}): {ex!r}")
# ====================================================================== #
# Engine subprocess handle (parent side) -- ported from engine_battle.py
# ====================================================================== #
class EngineError(Exception):
"""The engine failed to load or raised while searching."""
class EngineTimeout(Exception):
"""The engine did not return a move within the watchdog window."""
class EngineProcess:
"""Owns one engine subprocess and talks to it over a pipe."""
def __init__(self, ctx, path, book_path=None):
self.ctx = ctx
self.path = path
self.book_path = book_path # per-engine book (--book1/--book2)
self.name = os.path.splitext(os.path.basename(path))[0]
self.proc = None
self.conn = None
def start(self):
self._spawn()
def _spawn(self):
parent_conn, child_conn = self.ctx.Pipe()
self.conn = parent_conn
self.proc = self.ctx.Process(
target=engine_worker,
args=(child_conn, self.path, ENGINE_USE_BOOK, PV_UCI,
self.book_path, ENGINE_SMP, stockfish_elo),
# NOT daemon: an engine using Lazy SMP (CLAUDECHESS_SMP/SMP_WORKERS)
# spawns its own worker pool, and daemonic processes are forbidden
# from having children. The engine process is shut down explicitly
# via the shutdown protocol / terminate(), so non-daemon is safe.
daemon=False,
)
self.proc.start()
child_conn.close()
if not self.conn.poll(LOAD_TIMEOUT):
self.kill()
raise EngineError(f"{self.name}: timed out while loading")
msg = self.conn.recv()
if msg[0] == "ready":
# payload added later than the protocol; tolerate its absence so
# an older battle_worker still loads
self.nnue = msg[1] if len(msg) > 1 else None
# T-21: the child's EFFECTIVE options. Absent from an older worker,
# which the two-element contract above already tolerates.
self.opts = msg[2] if len(msg) > 2 else None
return
self.kill()
if msg[0] == "fatal":
raise EngineError(f"{self.name} failed to load:\n{msg[1]}")
raise EngineError(f"{self.name}: unexpected reply {msg[0]!r} on load")
def request_calibrate(self, timeout=120.0):
"""--nodes mode: ask the engine process to bench itself (6-FEN suite
@ d11, book/tb/1-thread forced) and return its measured NPS."""
self.conn.send(("calibrate",))
if not self.conn.poll(timeout):
self.kill()
raise EngineTimeout(f"{self.name}: calibration hung (killed)")
msg = self.conn.recv()
if msg[0] == "ok":
return float(msg[1]["nps"])
raise EngineError(f"{self.name} failed calibration:\n{msg[1]}")
def request_move(self, fen, mode, value, timeout):
"""Ask for a move; kill+respawn and raise on a timeout (hung search)."""
self.conn.send(("move", fen, mode, value, MAX_DEPTH_CAP))
if not self.conn.poll(timeout):
self.kill()
self._spawn() # fresh process for the remaining games
raise EngineTimeout(
f"{self.name}: no move within {timeout:.2f}s (killed)")
msg = self.conn.recv()
if msg[0] == "ok":
return msg[1]
if msg[0] == "error":
raise EngineError(f"{self.name} crashed during search:\n{msg[1]}")
raise EngineError(f"{self.name}: unexpected reply {msg[0]!r}")
def kill(self):
try:
if self.proc is not None and self.proc.is_alive():
self.proc.terminate()
self.proc.join(timeout=2)
except Exception:
pass
try:
if self.conn is not None:
self.conn.close()
except Exception:
pass
# ====================================================================== #
# Helpers
# ====================================================================== #
def _data_path(name):
"""Resolve a bundled data file. Books/EPDs moved to data/ on 2026-07-24;
a bare name is still honoured so an explicit --fen-file or a local copy
beside the runner keeps working."""
if os.path.isabs(name) or os.path.isfile(name):
return name
here = os.path.dirname(os.path.abspath(__file__))
cand = os.path.join(here, "data", name)
return cand if os.path.isfile(cand) else name
def load_fens(path):
"""Load and validate every position in ``path`` (plain FEN or EPD)."""
path = _data_path(path)
fens = []
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if not s or s.startswith("#"):
continue
try:
chess.Board(s) # plain FEN
fens.append(s)
continue
except Exception:
pass
parts = s.split() # EPD: keep board + supply clocks
if len(parts) >= 4:
cand = " ".join(parts[:4]) + " 0 1"
try:
chess.Board(cand)
fens.append(cand)
except Exception:
pass
if not fens:
fens = [chess.STARTING_FEN]
return fens
def elo(score, n, penta=None, wdl=None):
"""Elo difference for a match score in [0,1] over n games, with a 95%
margin. Returns (elo, margin).
THE VARIANCE MUST COME FROM THE RESULTS, NOT A COIN FLIP. This used
`se = 0.5/sqrt(n)`, i.e. Bernoulli variance 0.25 -- true only if every
game were a 50/50 win-or-lose with no draws. Real games are nothing like
that: at our measured 41% draw rate the per-game variance is 0.148, and
pairing each opening (the pentanomial) cuts it further to 0.062 per pair.
The old margin was therefore ~1.41x too WIDE on every campaign this
project has ever reported (measured across g1/g2/g3/s1/s2: 1.40-1.43x).
Too wide is the safe direction -- no verdict was ever wrongly ACCEPTED --
but it means real gains could be dismissed as noise, and it made the
printed margin disagree with the SPRT beside it, which always used the
real pentanomial variance via normalized_elo().
Variance, best source first:
penta -- dict/seq of the 5 pentanomial buckets: pairs are independent,
the shared opening cancels, smallest honest error bar.
wdl -- (wins, draws, losses): trinomial, still far better than 0.25.
neither -- falls back to the old coin-flip bound, marked by returning
the same conservative number rather than silently inventing one.
"""
score = min(max(score, 1e-9), 1 - 1e-9)
e = -400.0 * math.log10(1.0 / score - 1.0)
if n <= 0:
return e, 999.0
se = None
if penta is not None:
c = [float(penta[i]) for i in range(5)]
n_pairs = sum(c)
if n_pairs > 0:
p = [x / n_pairs for x in c]
vals = (0.0, 0.5, 1.0, 1.5, 2.0) # pair score out of 2
mu = sum(pi * v for pi, v in zip(p, vals))
var_pair = sum(pi * (v - mu) ** 2 for pi, v in zip(p, vals))
se = math.sqrt(var_pair / 4.0 / n_pairs) # SE of the mean GAME score
if se is None and wdl is not None:
w, d, l = (float(x) for x in wdl)
tot = w + d + l
if tot > 0:
pw, pd, pl = w / tot, d / tot, l / tot
mu = pw + 0.5 * pd
var_game = (pw * (1 - mu) ** 2 + pd * (0.5 - mu) ** 2
+ pl * (0.0 - mu) ** 2)
se = math.sqrt(var_game / tot)
if se is None or se == 0.0:
se = 0.5 / math.sqrt(n) # last-resort coin flip
lo = min(max(score - 1.96 * se, 1e-9), 1 - 1e-9)
hi = min(max(score + 1.96 * se, 1e-9), 1 - 1e-9)
margin = (-400.0 * math.log10(1.0 / hi - 1.0)
- (-400.0 * math.log10(1.0 / lo - 1.0))) / 2.0
return e, margin
# ====================================================================== #
# Pentanomial (paired-game) statistics
# ====================================================================== #
# The schedule already plays every FEN as a PAIR -- round 2k+1 with Engine 1
# White, round 2k+2 with Engine 2 White, same position (see schedule build in
# main()). Scoring the PAIR's combined result instead of each game in
# isolation is the standard paired-openings methodology (Fishtest/OpenBench):
# it cancels most of the opening-imbalance noise, which is what makes the
# Normalized Elo below a tighter, draw-rate-corrected effect size than the
# naive win/draw/loss Elo.
PENTA_LABELS = {0: "LL", 1: "LD", 2: "DD_WL", 3: "WD", 4: "WW"}
def game_score_e1(g, e1):
"""Engine 1's score for one finished game: 1.0 win / 0.5 draw / 0.0 loss.
None for an errored/excluded game -- its pair can't be scored either."""
if g["error"] is not None:
return None
if g["winner"] is None:
return 0.5
return 1.0 if g["winner"] is e1 else 0.0
def pentanomial_bucket(score_a, score_b):
"""Map two per-game E1 scores (each 0/0.5/1) to a pentanomial index 0..4:
0=LL 1=LD 2=DD_WL (two draws OR a win+a loss -- both sum to 1) 3=WD 4=WW."""
return round((score_a + score_b) * 2)
def pair_ratio(penta):
"""(WW + WD) / (LL + LD) -- a quick, distribution-free signal of which
engine is ahead. Returns None (not a divide-by-zero crash) when the
denominator is 0; the caller decides how to display that (e.g. "no
losing pairs yet" vs "no pairs at all")."""
denom = penta[0] + penta[1]
if denom == 0:
return None
return (penta[4] + penta[3]) / denom
def elo_from_score(score):
"""Point-estimate Elo from a win/draw/loss score in (0, 1). Rounded to
2 dp -- note round() on a float drops trailing zeros (5.1, not 5.10);
use f'{elo_from_score(s):.2f}' wherever the fixed 2-decimal STRING
("5.10") matters for display."""
score = min(max(score, 1e-9), 1 - 1e-9)
return round(-400.0 * math.log10(1.0 / score - 1.0), 2)
def normalized_elo(penta):
"""
Fishtest-style Normalized Elo (nElo): an effect size in "Elo per standard
deviation of game score" units, computed from the pentanomial pair
distribution instead of the raw win rate.
Why this corrects for draw-rate inflation: elo_from_score() only looks at
the MEAN score. Two matches with the same mean score but different draw
rates have very different score VARIANCE -- a higher draw rate compresses
the score distribution toward 0.5, so the same mean edge is a stronger
(less noisy) signal. nElo divides the score's distance from the 50%
(draws-only) baseline by its standard deviation before converting to Elo
units, so it rises with the draw rate for a fixed raw score -- correcting
exactly the bias that makes raw Elo look smaller in high-draw-rate
matches (e.g. near-equal engines at long time controls).
`penta` is a dict/sequence of pair counts indexed 0..4 (LL, LD, DD_WL,
WD, WW). Returns None when there's no variance to normalize by (zero
pairs, or every pair landed in the same bucket).
SCALE (FB-54, corrected 2026-07-26): this is now the SAME scale as the
GSPRT bounds and as Fishtest sprt_calc's elo0/elo1. Every "norm" figure
quoted in improvements.md / final_improvements.md from BEFORE this date
was a factor sqrt(2) = 1.414 larger; divide an old one by 1.414 to
compare it with a new one (v55's +21.18 becomes +14.98).
"""
n = sum(penta[i] for i in range(5))
if n == 0:
return None
pair_scores = (0.0, 0.5, 1.0, 1.5, 2.0) # score out of 2 per pair
p = [penta[i] / n for i in range(5)]
pair_mean = sum(p[i] * pair_scores[i] for i in range(5))
pair_var = sum(p[i] * (pair_scores[i] - pair_mean) ** 2 for i in range(5))
game_mean = pair_mean / 2.0 # score out of 1 per game
game_var = pair_var / 4.0 # Var(pair) / 2**2
sigma = math.sqrt(game_var)
if sigma == 0.0:
return None
# FB-54: divide by sqrt(2)*sigma, NOT sigma. This is the inverse of
# sprt.py's `_score_from_elo` for the "normalized" model
# (score = 0.5 + elo*ln10/800 * sqrt(2*var)), which is the scale the
# GSPRT bounds printed on the very next line are expressed in, and the
# scale of Fishtest sprt_calc's elo0/elo1 fields. Omitting the sqrt(2)
# made the printed figure read a factor 1.414 LARGER than the bounds
# beside it -- two numbers in one block on two different scales, the
# bigger one mislabelled as the standard.
nelo = (game_mean - 0.5) / (math.sqrt(2.0) * sigma) * (800.0 / math.log(10))
return round(nelo, 2)
def fmt_duration(seconds):
ms = max(0, int(round(seconds * 1000)))
d, ms = divmod(ms, 86_400_000)
h, ms = divmod(ms, 3_600_000)
m, ms = divmod(ms, 60_000)
s, ms = divmod(ms, 1000)
return f"{d}d {h}h {m}m {s}s {ms}ms"
def fmt_clock(ms):
if ms is None:
return "-"
s = max(0, int(ms)) / 1000.0
return f"{int(s) // 60}:{int(s) % 60:02d}" if s >= 60 else f"{s:.2f}s"
def build_pgn(round_no, fen, white, black, board, result, now, tc_label, tpm):
game = chess.pgn.Game()
game.setup(chess.Board(fen))
game.headers["Result"] = result # ensures movetext ends with the correct terminator
node = game
for mv in board.move_stack:
node = node.add_variation(mv)
if white.name == 'stockfish_engine' or black.name == 'stockfish_engine':
if white.name == 'stockfish_engine':
white_elo = stockfish_elo
black_elo = other_elo
else:
black_elo = stockfish_elo
white_elo = other_elo
else:
white_elo = other_elo
black_elo = other_elo
exporter = chess.pgn.StringExporter(headers=False, variations=False, comments=False)
movetext = game.accept(exporter).strip() or result
header = [
'[Event "Engine Match"]', '[Site "Local"]',
f'[Date "{now.strftime("%Y.%m.%d")}"]', f'[Round "{round_no}"]',
f'[White "{white.name}"]', f'[Black "{black.name}"]',
# f'[TimeControl "{tc_label}"]' if tc_label else '',
# f'[Time Per Move "{tpm}ms"]' if tpm is not None else '',
f'[BlackElo "{black_elo}"]',
f'[WhiteElo "{white_elo}"]',
f'[FEN "{fen}"]', f'[Result "{result}"]',
]
return "\n".join(h for h in header if h) + "\n" + movetext
# ====================================================================== #
# One game
# ====================================================================== #
# --- WDL adjudication runtime (config block up top) ------------------------ #
_WDL_THR = {} # eval family -> thr(phase) | None (model missing)
_WDL_FAMILY = {} # engine path -> "nnue" | "hce"
def _eval_family(engine):
"""Which eval SCALE this engine reports centipawns on.
The WDL model converts cp to P(win), and an NNUE arm and a hand-crafted
arm do not share a scale -- so in an NNUE-vs-HCE match a single model is
wrong for one of the two sides. Adjudication needs BOTH sides to agree a
game is decided, and it reads each side's OWN score, so the threshold has
to be chosen per side too.
Read from the engine SOURCE (the same scan that fills the net line in the
banner), so it costs nothing per move and is cached per path. Caveat worth
knowing: FI-104 can disarm the net at RUNTIME on a build with no SIMD dot
kernel, and a source scan cannot see that -- such a box would be judged on
the nnue model while playing HCE. It also refuses to arm at all there, so
the case is a non-SIMD host, which is not one anybody runs A/Bs on."""
path = getattr(engine, "path", None)
if path not in _WDL_FAMILY:
base = os.path.splitext(os.path.basename(path or ""))[0]
if base.startswith("stockfish_engine"):
# Stockfish reports on its own cp scale -- a third family. Its
# model (data/wdl_model_sf.json) is fitted from the SF sides of
# strength-matched yardstick runs; see fit_wdl_model.py
# --eval-family sf and NEAR_EQUAL_STOCKFISH_LOGS.
_WDL_FAMILY[path] = "sf"
else:
try:
_WDL_FAMILY[path] = ("nnue" if describe_nnue_source(path)["on"]
else "hce")
except Exception:
_WDL_FAMILY[path] = "hce"
return _WDL_FAMILY[path]
def _wdl_win_threshold(phase, family="hce"):
"""cp at which the fitted WDL model puts P(win) at ADJ_WIN_P for this
phase, or None while the model doesn't exist (adjudication then silently
stays off). Loaded once per process PER FAMILY -- each game worker reads
the file on its first adjudication check.
'hce' reads data/wdl_model.json; 'nnue' prefers data/wdl_model_nnue.json
(tuning/fit_wdl_model.py --nnue) and FALLS BACK to the hce model when that
file does not exist. The fallback is what the code did unconditionally
before this existed, so a tree with no NNUE model behaves exactly as it
always has -- but it says so once, because an NNUE arm judged on
hand-crafted thresholds is a thing an operator should know about rather
than discover in a draw rate."""
if family not in _WDL_THR:
path = "wdl_model.json" # bound before the try: the except prints it
try:
import json
path = _data_path("wdl_model.json")
if family in ("nnue", "sf"):
fam_path = _data_path(f"wdl_model_{family}.json")
if os.path.exists(fam_path):
path = fam_path
else:
print(f"[match] NOTE: no data/wdl_model_{family}.json -- "
f"the {family} side is being adjudicated on the "
"hand-crafted-eval model. Same behaviour as before "
"per-family models existed; refit with "
f"tuning/fit_wdl_model.py --eval-family {family} "
"to fix the scale.",
file=sys.stderr, flush=True)
with open(path, encoding="utf-8") as f:
mod = json.load(f)
AS, BS = mod["as"], mod["bs"]
pmax, pmin = mod["phase_max"], mod["phase_clamp_min"]
# invert w = 1/(1+exp((a-cp)/b)) = p -> cp = a - b*ln(1/p - 1)
gap = math.log(1.0 / ADJ_WIN_P - 1.0)
def thr(ph):
x = min(max(ph, pmin), pmax) / pmax
a = ((AS[0] * x + AS[1]) * x + AS[2]) * x + AS[3]
b = ((BS[0] * x + BS[1]) * x + BS[2]) * x + BS[3]
return a - b * gap
_WDL_THR[family] = thr
except (OSError, ValueError, KeyError) as _e:
# HOST-07: adjudication used to disable itself in SILENCE here.
# That is an A/B-integrity hazard, not a cosmetic one: a campaign
# split across two machines where one loads the model and the
# other does not produces halves with different game lengths and
# different draw rates, which must never be pooled.
_WDL_THR[family] = None
print(f"[match] WARNING: WDL adjudication is OFF for the "
f"'{family}' side -- could not load {os.path.basename(path)} "
f"({type(_e).__name__}: {_e}). Games will run to natural "
f"end / MAX_PLIES. If the OTHER half of a split campaign "
f"loaded it, DO NOT pool the two halves.",
file=sys.stderr, flush=True)
t = _WDL_THR[family]
return None if t is None else t(phase)
def _phase24(board):
"""Tapered phase 0..24 (mirrors engine.py's PHASE_WEIGHTS/PHASE_MAX)."""
npm = (chess.popcount(board.knights | board.bishops)
+ 2 * chess.popcount(board.rooks)
+ 4 * chess.popcount(board.queens))
return min(24, npm)
def play_game(round_no, fen, white, black, e1, mode_cfg):
"""Play a single game. Returns a dict of results + the per-move log lines."""
board = chess.Board(fen)
engine_log = []
error = None
result = "*"
reason = ""
is_clock = (mode_cfg["mode"] == "clock")
if is_clock:
init_ms = int(mode_cfg["tc_seconds"] * 1000)
clocks = {chess.WHITE: init_ms, chess.BLACK: init_ms}
inc_ms = int(mode_cfg["tc_increment"] * 1000)
else:
clocks, inc_ms = None, 0
clock_started = False
# WDL adjudication state (see ADJUDICATE; all no-ops while it's off).
# Keys are booleans (True = White), matching `mover_is_white`.
adj_win = {True: 0, False: 0} # consecutive own moves >= +threshold
adj_lose = {True: 0, False: 0} # consecutive own moves <= -threshold
adj_draw = 0 # consecutive near-zero plies (both sides)
while True:
# claim_draw=False on purpose. python-chess's claim_draw=True asks
# can_claim_threefold_repetition(), which is True as soon as the side
# to move HAS a legal move that would produce a third repetition --
# NOT when one has occurred. The harness was claiming that draw on
# behalf of a player who was frequently winning and would never claim
# it: 419 of 2,000 games in one A/B and 29 of 100 in a Stockfish run,
# with ZERO actual repetitions among them. can_claim_fifty_moves() has
# the identical phantom clause, so the halfmove clock is read directly.
# Between two Pygins this was near-symmetric and only cost sensitivity
# (draws inflated); against Stockfish it was one-directional, because
# SF is the side with won endgames to convert.
outcome = board.outcome()
if outcome is not None:
result = outcome.result()
reason = outcome.termination.name
break
if board.is_repetition(3):
result, reason = "1/2-1/2", "THREEFOLD_REPETITION"
break
if board.halfmove_clock >= 100:
result, reason = "1/2-1/2", "FIFTY_MOVES"
break
if board.ply() >= MAX_PLIES:
result, reason = "1/2-1/2", "MAX_PLIES (adjudicated draw)"
break
mover = white if board.turn == chess.WHITE else black
mover_is_white = board.turn == chess.WHITE
# Decide the per-move request (mode / value / watchdog).
if is_clock:
color = board.turn
budget = calculate_move_time(board, clocks[color], clocks[not color], inc_ms)
req_mode = "time"
if mode_cfg.get("sf_our_clock"):
req_value = budget # pre-FI-88: our budget drives BOTH
req_timeout = budget / 1000.0 * TIME_OVERSHOOT_FACTOR + TIME_GRACE
else:
# FI-88 (default): carry the raw clocks too. A clock-managing
# engine budgets its own move, so the watchdog can no longer be
# a multiple of OUR budget -- SF outspending it on a critical
# move is the whole point. The honest bound is its REMAINING
# clock: past that it has flagged anyway.
req_value = (budget, clocks[chess.WHITE], clocks[chess.BLACK],
inc_ms, inc_ms)
req_timeout = clocks[color] / 1000.0 + TIME_GRACE
elif mode_cfg["mode"] == "depth":
req_mode, req_value = "depth", mode_cfg["depth"]
req_timeout = DEPTH_SAFETY_CAP
elif mode_cfg["mode"] == "nodes":
# --nodes: per-side NPS-calibrated budget (set in _worker_loop);
# the node count is load-immune but WALL time stretches under
# load, hence the generous depth-style watchdog.
req_mode = "nodes"