summaryrefslogtreecommitdiff
path: root/src/eval_tune
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-05 09:01:25 -0700
committerScott Gasch <[email protected]>2026-10-05 09:01:25 -0700
commit64b5d3307a7a95975f7f8c0248f3f1febedd8617 (patch)
tree993fffd62a68bec962261caf033a8f05c6370ea9 /src/eval_tune
parentbbdf1bda8f410df6aa9be0a38e71b7db04982198 (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-xsrc/eval_tune/match_play.py102
-rwxr-xr-xsrc/eval_tune/test_vs_head.sh1
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} \