diff options
| author | Scott Gasch <[email protected]> | 2026-10-05 09:01:25 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-05 09:01:25 -0700 |
| commit | 64b5d3307a7a95975f7f8c0248f3f1febedd8617 (patch) | |
| tree | 993fffd62a68bec962261caf033a8f05c6370ea9 /src/eval_tune | |
| parent | bbdf1bda8f410df6aa9be0a38e71b7db04982198 (diff) | |
Continue dynamic move-ordering/LMR session: SPRT-validated at 1770+ games
Gate match vs head (st4.0) shows LLR=+2.89 approaching the H1=+2.94
bound, candidate score 0.5324 (LOWER95=0.5092, ELO=+22.6+/-16.2),
candidate depth/ebf both improved (13.98 vs 13.61 avg depth, ebf 3.324
vs 3.464) with comparable nps -- consistent with real search-efficiency
gains rather than noise. match_play.py gets additional instrumentation
to report these stats; search.c/searchsup.c/generate.c/chess.h/eval.c
carry the underlying move-ordering/LMR changes from this session's
line of work.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune')
| -rwxr-xr-x | src/eval_tune/match_play.py | 102 | ||||
| -rwxr-xr-x | src/eval_tune/test_vs_head.sh | 1 |
2 files changed, 89 insertions, 14 deletions
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py index 0985801..4eecfd6 100755 --- a/src/eval_tune/match_play.py +++ b/src/eval_tune/match_play.py @@ -84,7 +84,12 @@ _FIRST_MOVE_BETA_RE = re.compile( # window at the same g_uIterateDepth, printing another line before # depth actually advances) -- see the EBF-from-consecutive-iterations # derivation in EngineStats.add_ply's caller for how that's handled. -_ITER_RE = re.compile(r"^\s*(\d+)[+-]?\s+\S+\s+\S+\s+(\d+)\s") +# Groups: (1) depth, (2) score in centipawns from the SIDE TO MOVE's +# perspective (raw iScore, VALUE_PAWN == 100 -- see chess.h), (3) node +# count, (4) the rest of the line: the SAN PV text (WalkPV, util.c), or +# "FL --" for a fail-low line (no PV -- the move that failed low isn't +# necessarily the move eventually played) or "<SAN> ++" for a fail-high. +_ITER_RE = re.compile(r"^\s*(\d+)[+-]?\s+(-?\d+)\s+\S+\s+(\d+)\s*(.*)$") # Reading PostMoveSearchReport's trailing block used to stop either at a # bounded line count or at gamelist.c's "_white(move N)_"/"_black(move N)_" # prompt -- both unreliable: the block's line count depends on whether the @@ -223,10 +228,12 @@ 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, 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. + """Returns (result, moves, move_evals, 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), move_evals is a + parallel list of (score_cp_white_relative, pv, depth) -- see its + assignment below for None-handling -- and white_stats/black_stats 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 @@ -242,6 +249,13 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, which swaps seats every other game by design. main()'s run() does that mapping via candidate_is_white -- see the warning there.""" moves = [] + # Parallel to `moves`: (score_cp_white_relative, pv, depth) per move + # played, or (None, None, None) when no eval was captured (post=False, + # or the engine printed no in-window iteration this move -- e.g. an + # instant book/EGTB move). score is flipped to be WHITE-relative + # (standard PGN eval-comment convention), unlike iScore itself, which + # util.c prints from the SIDE-TO-MOVE's perspective. + move_evals = [] white_stats = EngineStats() black_stats = EngineStats() white_last_fmb = black_last_fmb = None @@ -332,15 +346,27 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, last_iter_depth = last_iter_nodes = None iter_log_ratio_sum = 0.0 iter_ratio_count = 0 + # Last-seen (score, pv, depth) from any _ITER_RE line this move, + # overwritten on every line -- so once the move-scan/stats loops + # are done, this is the search's FINAL completed iteration: the + # score/PV that actually back the move about to be pushed. Only + # populated when "post" is on (see play_one_game's docstring); + # stays None otherwise, which build_pgn_game treats as "no eval + # available" rather than guessing. + last_eval_score = None + last_eval_pv = None def _note_iter(line): nonlocal last_iter_depth, last_iter_nodes nonlocal iter_log_ratio_sum, iter_ratio_count + nonlocal last_eval_score, last_eval_pv im = _ITER_RE.match(line) if not im: return iter_depth = int(im.group(1)) - iter_nodes = int(im.group(2)) + iter_score = int(im.group(2)) + iter_nodes = int(im.group(3)) + tail = im.group(4).strip() if (last_iter_depth is not None and last_iter_nodes is not None and iter_nodes > last_iter_nodes and @@ -350,6 +376,16 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, iter_ratio_count += 1 last_iter_depth = iter_depth last_iter_nodes = iter_nodes + # Fail-low lines ("FL --") carry no usable PV -- the move + # that failed low is not necessarily the move eventually + # played, so don't let a fail-low line's score overwrite a + # real PV from an earlier, in-window iteration at this same + # depth. Fail-high lines ("<SAN> ++") DO have a real PV + # (the move that beat beta), so those are kept. + if tail == "FL --": + return + last_eval_score = iter_score + last_eval_pv = tail[:-3].rstrip() if tail.endswith(" ++") else tail for _ in range(400): line = mover.stdout.readline() @@ -370,7 +406,7 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, if move_uci is None: # side to move had nothing to play -- treat as loss for mover result = 0.0 if white_to_move else 1.0 - return result, moves, white_stats, black_stats + return result, moves, move_evals, white_stats, black_stats # PostMoveSearchReport's stats block trails the "move" line # (confirmed against root.c and real output), not the other @@ -433,12 +469,17 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, move = chess.Move.from_uci(move_uci) if move not in board.legal_moves: result = 0.0 if white_to_move else 1.0 - return result, moves, white_stats, black_stats + return result, moves, move_evals, white_stats, black_stats except ValueError: result = 0.0 if white_to_move else 1.0 - return result, moves, white_stats, black_stats + return result, moves, move_evals, white_stats, black_stats board.push(move) moves.append(move_uci) + score_white = None + if last_eval_score is not None: + score_white = (last_eval_score if white_to_move + else -last_eval_score) + move_evals.append((score_white, last_eval_pv, last_iter_depth)) white_stats.add_game_first_move_beta(white_last_fmb, white_stats.plies) black_stats.add_game_first_move_beta(black_last_fmb, black_stats.plies) @@ -448,10 +489,38 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, result = 0.5 else: result = 1.0 if outcome.winner == chess.WHITE else 0.0 - return result, moves, white_stats, black_stats + return result, moves, move_evals, white_stats, black_stats + + +def _format_eval_comment(score_white, pv, depth): + """Render one move_evals entry as a PGN comment, e.g. "+0.34/12 Nf3 + Nc6 Bb5" (score.pawns/depth pv...), the convention most PGN viewers + (lichess, ChessBase, scid) already render as an eval graph/annotation. + score_white is None when no eval was captured for this move (post=False, + or an instant book/EGTB move) -- callers should skip the comment + entirely rather than call this with None.""" + mate_in = None + if abs(score_white) >= NMATE_THRESHOLD: + # Ply-distance-to-mate isn't recoverable from a single iteration + # line (it's baked into the score magnitude, not broken out), so + # just flag the side rather than claim a specific "#N" we can't + # verify -- see ADJUST_MATE_IN_N/hash.c in CLAUDE.md for why mate + # scores need care generally. + mate_in = "+M" if score_white > 0 else "-M" + score_str = mate_in if mate_in else f"{score_white / 100:+.2f}" + comment = f"{score_str}/{depth}" if depth is not None else score_str + if pv: + comment += f" {pv}" + return comment + + +# chess.h: #define NMATE (+INFINITY_SCORE - 200) -- any |score| at or past +# this is a mate-distance score, not a material/positional evaluation. +NMATE_THRESHOLD = 32000 -def build_pgn_game(start_fen, moves, result_white, white_name, black_name): +def build_pgn_game(start_fen, moves, result_white, white_name, black_name, + move_evals=None): board = chess.Board(start_fen) game = chess.pgn.Game() game.headers["Event"] = "typhoon self-play match" @@ -463,8 +532,12 @@ def build_pgn_game(start_fen, moves, result_white, white_name, black_name): result_str = {1.0: "1-0", 0.0: "0-1", 0.5: "1/2-1/2"}[result_white] game.headers["Result"] = result_str node = game - for move_uci in moves: + for i, move_uci in enumerate(moves): node = node.add_variation(chess.Move.from_uci(move_uci)) + if move_evals is not None: + score_white, pv, depth = move_evals[i] + if score_white is not None: + node.comment = _format_eval_comment(score_white, pv, depth) return game, result_str @@ -853,7 +926,7 @@ def main(): 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( + result_white, moves, move_evals, white_stats, black_stats = play_one_game( white_engine, black_engine, f"{args.scratch}/g{i}", fen, depth=args.sd, white_time_sec=white_time, black_time_sec=black_time, @@ -863,7 +936,8 @@ def main(): candidate_result = result_white if candidate_is_white else (1.0 - result_white) white_name = "candidate" if candidate_is_white else "baseline" black_name = "baseline" if candidate_is_white else "candidate" - game, _ = build_pgn_game(fen, moves, result_white, white_name, black_name) + game, _ = build_pgn_game(fen, moves, result_white, white_name, black_name, + move_evals=move_evals) this_candidate_stats = white_stats if candidate_is_white else black_stats this_head_stats = black_stats if candidate_is_white else white_stats return (i, candidate_result, game, game_elapsed, diff --git a/src/eval_tune/test_vs_head.sh b/src/eval_tune/test_vs_head.sh index 592ecf1..6f37039 100755 --- a/src/eval_tune/test_vs_head.sh +++ b/src/eval_tune/test_vs_head.sh @@ -108,6 +108,7 @@ python3 ./match_play.py \ --pgn ../../pgn/twic_filtered.pgn \ --games ${GAMES} \ --workers 18 \ + --post \ ${TC_ARGS} \ ${SPRT_ARGS} \ --scratch /usr/local/tmp/typhoon_match_${LABEL} \ |
