diff options
| author | Scott Gasch <[email protected]> | 2026-10-04 08:17:52 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-04 08:17:52 -0700 |
| commit | bbdf1bda8f410df6aa9be0a38e71b7db04982198 (patch) | |
| tree | e8315247f700ee94875cd6d2eb5dd81aef71395d /src/eval_tune/match_play.py | |
| parent | 9b28080f8b3dca4588cb2aaaf748451f45e3c626 (diff) | |
Dynamic move-ordering/LMR session: honest FH tracking, decoupled admission/magnitude, bugfixes, tooling
Lands the 2026-09-28..10-03 move-ordering investigation (full history in
lmr_testing/*.md). Summary of what ships:
LMR/move-ordering redesign (searchsup.c, dynamic.c, data.c, chess.h):
- GetLMRReduction's admission decision (do we reduce at all) is now
decoupled from magnitude (how much): LMR_STATIC_ADMIT_THOUSANDTHS=500
gates admission via the 4D joint fail-high table (data.c) plus a
countermove-match veto; magnitude is a flat Crafty/Ethereal-shaped
log(depth)*log(moves) table (g_iLMRQuietReduction), which measured as
good or better than the old per-tier joint-table payout while being
structurally immune to the per-bucket miscalibration that sank the
tiered approach. g_FailHighs honesty fix (real 0/0 init, not a fake
prior) underlies the dynamic-danger signal.
- Retired NumLeftoverMovesToSelect's depth-indexed selection budget --
evidence showed the leftover pool has real findable signal a bailout
was discarding.
- ShouldLatePrune (overt late-move pruning) implemented but disabled at
its call site -- measured net-negative at the tested threshold; kept
for reference and as a base for a future hang-bucket-9-targeted retry.
- PRUNE_VERIFY, PRUNE_LIVE, DISABLE_LMR: opt-in diagnostic build flags
(GNUmakefile), off in the standard profile.
Bug fixes found and fixed while exercising this work:
- JOINT_EXTRA_TIER_COUNT 4->6 (chess.h): fixed an out-of-bounds counter
write reachable at remaining depth >= 14 (unreachable at sd10, routine
in real search/self-play).
- Node-type instrumentation (search.c, root.c, searchsup.c): fixed four
compounding defects that produced impossible percentages (e.g. 106.2%
of PV nodes) -- wrong denominator, qsearch misclassified as main
search, untyped early-exit nodes, and fail-low tests using the
in-place-raised alpha instead of the initial one.
- FH-rate unit unification (search.c, searchsup.c, dynamic.c, chess.h):
GetMoveFailHigh* returned per-mille while g_uJointFHThousandths is
thousandths-of-a-percent, two different units sharing a "_THOUSANDTHS"
suffix. Unified to one scale, renamed to "_PCT_THOUSANDTHS".
RateBucket's band edges fixed to match.
- QSearch option B (search.c): waives the opponent-stand-pat requirement
at NODE_PV only (sound there since the exact score propagates; not
sound at null-window nodes). iExtensionAmount unit fix (was 0/2 into a
ONE_PLY-scaled field). pi->iEval now published when Eval() runs.
- eval_tune/match_play.py local_ebf: was reporting 0.000 in every match
since 2026-09-18 (regex only matched the trailing-stats parser, not
the live move-scan loop; engines were also spawned with "nopost" so
root.c never emitted the lines at all). True per-ply EBF is ~2.0, not
the ~3.9 every prior match report implied.
- eval_tune/test_vs_head.sh: positional-arg shift ordering bug caused
every no-extra-args run to silently rebuild ./typhoon with a
non-standard profile and, separately, could feed a flag's value in as
the candidate binary path.
- PERF_COUNTERS cleanup (eval.c, search.c): moved 3 leaked rdtsc sites to
the existing EVAL_TIME guard, deleted two concluded one-off diagnostics
(u64HeadStyle*, u64FHJointHist/RateBucket/AttemptsBucket) and their
report plumbing, and re-gated a per-move hang-material estimate behind
FH_TABLE_COLLECTION instead of running it unconditionally.
Infrastructure:
- match_play.py time-odds support (--odds/--st-head/--st-candidate),
validated against the engine seat rather than color.
- Wilson-upper-bound re-bake of the joint table (data.c): cells store a
confidence-aware bound instead of a raw point estimate, so a consumer
can threshold on the value alone; monotone, so every consumer became
strictly more conservative. Verified behavior-neutral for LMR.
Known-open items (not resolved here, see lmr_testing/GET_LMR_REDUCTION.md
section 10 and 2026-10-03_UNCOMMITTED_TRIAGE.md): the self-play gate on
this candidate is underpowered (+5 Elo, 95% CI roughly [-6, +17]); the
curated-suite solve-count deltas used earlier in the investigation turned
out to be noise-dominated cancellation when diffed per position, not the
clean signal they first appeared to be.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune/match_play.py')
| -rwxr-xr-x | src/eval_tune/match_play.py | 224 |
1 files changed, 192 insertions, 32 deletions
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py index d8a8e7b..0985801 100755 --- a/src/eval_tune/match_play.py +++ b/src/eval_tune/match_play.py @@ -48,6 +48,7 @@ import select import sys import time from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED +from datetime import date from pathlib import Path import chess @@ -220,11 +221,26 @@ def sample_openings(pgn_path, n, min_ply=8, max_ply=20, seed=0, def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, - depth=None, time_control_sec=None, max_plies=200): + depth=None, white_time_sec=None, black_time_sec=None, + max_plies=200, post=False): """Returns (result, moves, white_stats, black_stats) where result is 1.0/0.5/0.0 from White's perspective, moves is the list of UCI moves actually played (for PGN reconstruction), and white_stats/black_stats - are EngineStats instances covering that side's moves in this game.""" + are EngineStats instances covering that side's moves in this game. + + 2026-10-03: the time control is now PER SIDE (white_time_sec / + black_time_sec) rather than one value shared by both engines, so a + time-odds match is expressible -- notably HEAD vs HEAD at 2:1, which + measures what one extra ply of depth is actually worth in Elo *in + this engine*, the calibration every depth-buying search change + (LMR/EFP/LMP) is implicitly betting on. Pass the same value twice for + an ordinary even match. `depth` (--sd) stays shared: fixed-depth odds + would be a different experiment and there's no caller for it. + + NOTE for callers: these two parameters are keyed by SEAT (white/ + black), but in a time-odds match the odds must follow the ENGINE, + which swaps seats every other game by design. main()'s run() does + that mapping via candidate_is_white -- see the warning there.""" moves = [] white_stats = EngineStats() black_stats = EngineStats() @@ -250,15 +266,41 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, with Engine(white_engine_path, f"{scratch_dir}/w", engine_args) as we, \ Engine(black_engine_path, f"{scratch_dir}/b", engine_args) as be: # Disable book and pondering. Set time control. Resign lost positions. - tc_cmd = f"sd {depth}" if depth is not None else f"st {time_control_sec}" - for command in ( "book name /none", - "set ThinkOnOpponentsTime false", - "set ResignThreshold -1000", - tc_cmd): + # 2026-10-03: Engine.__init__ (tune_eval_dna.py:120) sends + # "nopost" unconditionally, and root.c:248 gates the + # per-iteration PV lines on g_Options.fShouldPost -- so those + # lines are never emitted and local_ebf (the TRUE per-ply + # branching ratio, as opposed to the cumulative + # nodes**(1/depth) `ebf` that conflates aspiration/root-PV + # re-search cost with real tree growth) could never be computed. + # It reported 0.000 for every engine in every match since it was + # added. "post" re-enables those lines. Opt-in via --post rather + # than default-on: it adds roughly one stdout line per iteration + # per move, which every engine process must then be read through, + # and the default behavior of a harness in active use should not + # change silently underneath a running experiment. + startup = ["book name /none", + "set ThinkOnOpponentsTime false", + "set ResignThreshold -1000"] + if post: + startup.append("post") + for command in startup: _dbg("W", f"send: {command}") we._send(command) _dbg("B", f"send: {command}") be._send(command) + # Time control is the one setting that can legitimately differ + # between the two engines (time odds), so it is sent separately + # rather than as part of the shared command list above. + if depth is not None: + white_tc = black_tc = f"sd {depth}" + else: + white_tc = f"st {white_time_sec}" + black_tc = f"st {black_time_sec}" + _dbg("W", f"send: {white_tc}") + we._send(white_tc) + _dbg("B", f"send: {black_tc}") + be._send(black_tc) board = chess.Board(fen) for ply_idx in range(max_plies): @@ -273,12 +315,49 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, mover._send(f"setboard {board.fen()}") mover._send("go") move_uci = None + # 2026-10-03 BUGFIX: the per-iteration PV lines _ITER_RE + # matches are printed DURING the search, i.e. BEFORE the + # "move" line -- so they arrive in THIS loop, not in the + # trailing stats block below. They used to be parsed only + # down there, which meant this loop read and discarded every + # one of them and iter_ratio_count was always 0, so + # avg_local_ebf silently reported 0.000 for every engine in + # every match since it was added (2026-09-18). The whole + # point of local_ebf is that it is a TRUE per-ply branching + # ratio, unlike the cumulative nodes**(1/depth) `ebf` which + # conflates aspiration-window/root-PV-change re-search cost + # with real tree growth -- so the one metric that could + # settle "is our branching factor actually ~3.9 or ~2.0" + # was dead on arrival. Accumulate here as well. + last_iter_depth = last_iter_nodes = None + iter_log_ratio_sum = 0.0 + iter_ratio_count = 0 + + def _note_iter(line): + nonlocal last_iter_depth, last_iter_nodes + nonlocal iter_log_ratio_sum, iter_ratio_count + im = _ITER_RE.match(line) + if not im: + return + iter_depth = int(im.group(1)) + iter_nodes = int(im.group(2)) + if (last_iter_depth is not None and + last_iter_nodes is not None and + iter_nodes > last_iter_nodes and + iter_depth > last_iter_depth): + iter_log_ratio_sum += (math.log(iter_nodes / last_iter_nodes) + / (iter_depth - last_iter_depth)) + iter_ratio_count += 1 + last_iter_depth = iter_depth + last_iter_nodes = iter_nodes + for _ in range(400): line = mover.stdout.readline() if not line: _dbg(tag, "EOF while scanning for move line") break _dbg(tag, f"read(move-scan): {line!r}") + _note_iter(line) if line.startswith("tellics resign"): # Engine resigned instead of moving -- root.c prints # this and returns without ever printing "move ...", @@ -310,9 +389,8 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, # together at the end (arithmetic mean of ratios would let # one huge outlier iteration dominate; geometric mean is # the correct way to average ratios/growth-factors). - last_iter_depth = last_iter_nodes = None - iter_log_ratio_sum = 0.0 - iter_ratio_count = 0 + # (state hoisted above the move-scan loop -- see the + # 2026-10-03 bugfix comment there) for _ in range(_STATS_TRAILING_LINES): line = _readline_or_none(mover.stdout, _STATS_IDLE_TIMEOUT_SEC) if line is None: @@ -339,22 +417,7 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, if fm: ply_fmb = float(fm.group(1)) continue - im = _ITER_RE.match(line) - if im: - iter_depth = int(im.group(1)) - iter_nodes = int(im.group(2)) - if (last_iter_depth is not None and - last_iter_nodes is not None and - iter_depth != last_iter_depth and - iter_nodes > last_iter_nodes and - iter_depth > last_iter_depth): - ratio = iter_nodes / last_iter_nodes - depth_delta = iter_depth - last_iter_depth - iter_log_ratio_sum += math.log(ratio) / depth_delta - iter_ratio_count += 1 - last_iter_depth = iter_depth - last_iter_nodes = iter_nodes - continue + _note_iter(line) if ply_nodes is not None and ply_time is not None: ply_local_ebf = (math.exp(iter_log_ratio_sum / iter_ratio_count) if iter_ratio_count else None) @@ -391,10 +454,11 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, def build_pgn_game(start_fen, moves, result_white, white_name, black_name): board = chess.Board(start_fen) game = chess.pgn.Game() - game.headers["Event"] = "typhoon DNA gate match" + game.headers["Event"] = "typhoon self-play match" game.headers["White"] = white_name game.headers["Black"] = black_name game.headers["FEN"] = start_fen + game.headers["Date"] = f"{date.today()}" game.setup(board) result_str = {1.0: "1-0", 0.0: "0-1", 0.5: "1/2-1/2"}[result_white] game.headers["Result"] = result_str @@ -672,14 +736,85 @@ def main(): "the two are actually mutually exclusive now.") tc.add_argument("--st", type=float, default=None, help="seconds per move per side instead of fixed depth") + # Time odds. See play_one_game's docstring for why this exists: the + # headline use is HEAD vs HEAD at 2:1 to price one extra ply of + # depth in Elo for THIS engine, which bounds what any depth-buying + # search change can ever be worth. + ap.add_argument("--odds", type=float, default=None, metavar="MULT", + help="give the CANDIDATE engine MULT times the " + "head engine's seconds per move (e.g. --st 4 " + "--odds 2 => head 4s, candidate 8s). Requires " + "--st; incompatible with --sd. Use --st-head/" + "--st-candidate instead for full control.") + ap.add_argument("--st-head", type=float, default=None, metavar="SEC", + help="explicit seconds per move for the head engine " + "(overrides --st for that side)") + ap.add_argument("--st-candidate", type=float, default=None, metavar="SEC", + help="explicit seconds per move for the candidate " + "engine (overrides --st for that side)") + ap.add_argument("--post", action="store_true", + help="send 'post' so engines emit per-iteration PV " + "lines, which is what local_ebf (the true " + "per-ply branching ratio) is computed from. " + "Without this, local_ebf reads 0.000 -- the " + "engines never print those lines because " + "Engine.__init__ sends 'nopost'.") ap.add_argument("--max-plies", type=int, default=200) ap.add_argument("--scratch", default="/usr/local/tmp/typhoon_match") ap.add_argument("--log", default="match_history.log") - ap.add_argument("--pgn-out", default="match_games.pgn", + ap.add_argument("--pgn-out", default="../self_play_games.pgn", help="every played game is appended here as PGN") args = ap.parse_args() - if args.sd is None and args.st is None: + + # --- resolve the per-side time controls once, loudly --------------- + # Deliberately strict: a time-odds match that silently degrades into + # an even one is the dangerous failure here, because it still scores + # ~0.500 and looks perfectly healthy (same family of silent confound + # as the still-live opening book and the shared logfile this script's + # header documents). So every inconsistent combination is a hard + # error rather than a best-effort interpretation. + odds_requested = (args.odds is not None or + args.st_head is not None or + args.st_candidate is not None) + if odds_requested and args.sd is not None: + ap.error("time odds (--odds/--st-head/--st-candidate) needs a " + "time control, not --sd; fixed-depth odds would be a " + "different experiment entirely.") + if args.sd is None and args.st is None and not odds_requested: args.sd = 8 + if args.odds is not None: + if args.st is None: + ap.error("--odds multiplies --st, so --st is required with it") + if args.st_candidate is not None: + ap.error("--odds and --st-candidate both set the candidate's " + "time; pass one or the other") + if args.odds <= 0: + ap.error("--odds must be positive") + head_time = args.st_head if args.st_head is not None else args.st + cand_time = args.st_candidate if args.st_candidate is not None else args.st + if args.odds is not None: + # round: 4 * 1.5 is 6.000000000000001 in binary float, which ends + # up verbatim in the "st" command and in the result line's TC= + # stamp. Harmless to the engine, ugly in a log that gets diffed. + cand_time = round(args.st * args.odds, 4) + if args.sd is None: + if head_time is None or cand_time is None: + ap.error("need --st (or both --st-head and --st-candidate) " + "when not using --sd") + if odds_requested and head_time == cand_time: + ap.error(f"time odds requested but both sides resolved to " + f"{head_time}s -- that is an even match. Check " + f"--odds/--st-head/--st-candidate.") + print(f"TIME_CONTROL: head={head_time}s/move " + f"candidate={cand_time}s/move" + + (" *** TIME ODDS ***" if head_time != cand_time else "")) + else: + print(f"TIME_CONTROL: both sides sd {args.sd}") + # Stash on args so print_final_summary (a separate function, already + # taking args) can stamp the time control into the result line + # without widening its signature at both call sites. + args.head_time = head_time + args.cand_time = cand_time head_engine = str(Path(args.head_engine).resolve()) candidate_engine = str(Path(args.candidate_engine).resolve()) @@ -706,10 +841,23 @@ def main(): def run(job): i, fen, candidate_is_white, white_engine, black_engine = job game_start = time.monotonic() + # The odds follow the ENGINE, not the seat. Every opening is + # played twice with colours swapped (see the jobs[] loop above), + # so keying the time control on White/Black instead would hand + # the advantage to each engine in exactly half the games -- a + # balanced match that measures nothing while scoring a healthy- + # looking ~0.500. Hence this mapping off candidate_is_white, and + # the per-game assertion below. + white_time = cand_time if candidate_is_white else head_time + black_time = head_time if candidate_is_white else cand_time + if args.sd is None and head_time != cand_time: + assert ((white_time == cand_time) == candidate_is_white), \ + "time-odds seat mapping inverted" result_white, moves, white_stats, black_stats = play_one_game( white_engine, black_engine, f"{args.scratch}/g{i}", fen, - depth=args.sd, time_control_sec=args.st, - max_plies=args.max_plies, + depth=args.sd, + white_time_sec=white_time, black_time_sec=black_time, + max_plies=args.max_plies, post=args.post, ) game_elapsed = time.monotonic() - game_start candidate_result = result_white if candidate_is_white else (1.0 - result_white) @@ -873,9 +1021,21 @@ def print_final_summary(done, candidate_points, wins, draws, losses, sprt_str = (f" SPRT={outcome} LLR={sprt.llr():+.3f} " f"BOUNDS=[{sprt.la:.3f},{sprt.lb:.3f}] " f"ELO0={args.elo0:.1f} ELO1={args.elo1:.1f}") + # Stamp the time control into the result line itself: a time-odds + # score is NOT comparable with an even-match score, and a bare + # CANDIDATE_SCORE in a log file gives no way to tell them apart + # later. + if args.sd is not None: + tc_str = f" TC=sd{args.sd}" + elif args.head_time != args.cand_time: + tc_str = (f" TC=ODDS(head={args.head_time}s," + f"cand={args.cand_time}s)") + else: + tc_str = f" TC=st{args.head_time}" print(f"CANDIDATE_SCORE={score:.4f} GAMES={n_games} " f"WINS={wins} DRAWS={draws} LOSSES={losses} " - f"ELO={elo:+.1f}+/-{err:.1f} LOWER95={lower95:.4f}{sprt_str}") + f"ELO={elo:+.1f}+/-{err:.1f} LOWER95={lower95:.4f}" + f"{tc_str}{sprt_str}") print(f"CANDIDATE_STATS: {candidate_stats.summary()}") print(f"HEAD_STATS: {head_stats.summary()}") |
