diff options
| author | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
| commit | 8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch) | |
| tree | 7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src/eval_tune/match_play.py | |
| parent | 9d1e32db698855b939c4bc5df6b6a82509dd381c (diff) | |
Fix ONE_LEGAL_MOVE hash-move-shrink bug, tighten extension taper, add
a scoped check-evasion qsearch futility prune, and fix a pre-existing
PV-corruption crash.
- chess.h/generate.c: ONE_LEGAL_MOVE now reads a uTrueEscapeCount
snapshot taken before _ScoreAllEscapes excludes an already-tried
hash move, instead of the live post-exclusion MOVE_COUNT -- the old
version misreported "one legal reply to check" whenever the hash
move happened to be one of exactly two legal replies.
- root.c: g_uSoftExtendLimit/g_uHardExtendLimit taper onset moved from
2x/4x nominal search depth to 1.5x/3x (uniform 0.75x scaling of the
same band shape) -- a forcing chain of check/singular-reply
extensions could previously run fully unthrottled out to double the
nominal depth before any taper at all kicked in.
- search.c: QSearchFromCheckNoStandPat gains a scoped futility prune
for check-evasion replies, active only when this side already had a
stand-pat opportunity earlier in the line (fCouldStandPat). Only
applies to non-checking moves, uses the same calibrated
FUTILITY_BASE_MARGIN_BY_SOURCE table normal QSearch already trusts
(not a separate, unvalidated margin), computes its Eval() call
lazily (only once a non-checking candidate is actually reached, not
unconditionally per node), and never fires until at least one legal
move has already been searched (uLegalMoves > 0), so it can never
discard every reply to a check.
- search.c/searchsup.c: split CommonSearchInit's "too deep" bailout
into two separately-counted triggers (the rare MAX_PLY_PER_SEARCH-1
array-safety ceiling vs. the much more frequently-hit
g_uHardExtendLimit, now reachable sooner given the tightened taper
above) and surfaced both counts in root.c's per-search report.
- searchsup.c: UpdatePV no longer copies a child ply's stale PV tail
forward after writing a sentinel move (HASHMOVE/RECOGNMOVE/
DRAWMOVE/MATEMOVE) into the current ply's slot -- sPlyInfo[] is
reused across unrelated nodes at the same ply, so an earlier,
unrelated node's leftover sentinel in that slot could reproduce the
same sentinel value twice in a row and trip WalkPV's terminator
assert (util.c:1093), reproducing under MP via debug_smoke_test.sh.
Pre-existing, confirmed on unmodified HEAD; not introduced by any of
the above.
- eval_tune/match_play.py: EngineStats now tracks running average
search depth (already-parsed per-move data, just never surfaced)
and prints it alongside nps/ebf in the periodic status line, not
just the final summary. Added a ^C handler that dumps the same
CANDIDATE_SCORE/CANDIDATE_STATS/HEAD_STATS summary from whatever
games completed so far, then exits immediately rather than waiting
for the ThreadPoolExecutor's default wait=True teardown to let
in-flight games finish naturally.
Validated: precommit_check.sh (self-test + DEBUG smoke test) passing
throughout; self-play gauntlet (match_play.py --st 1) trending net
positive against HEAD through 1000 games.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TBtwX9aPrvwiM3gh4VnnZi
Diffstat (limited to 'src/eval_tune/match_play.py')
| -rwxr-xr-x | src/eval_tune/match_play.py | 165 |
1 files changed, 107 insertions, 58 deletions
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py index b26244a..c4cad6c 100755 --- a/src/eval_tune/match_play.py +++ b/src/eval_tune/match_play.py @@ -393,11 +393,16 @@ class EngineStats: self.ebf_count = 0 self._fmb_weighted_sum = 0.0 self._fmb_weight = 0 + self.depth_sum = 0 + self.depth_count = 0 def add_ply(self, nodes, elapsed, depth): self.total_nodes += nodes self.total_time += elapsed self.plies += 1 + if depth and depth > 0: + self.depth_sum += depth + self.depth_count += 1 if depth and depth > 0 and nodes > 0: self.ebf_sum += nodes ** (1.0 / depth) self.ebf_count += 1 @@ -415,6 +420,8 @@ class EngineStats: self.ebf_count += other.ebf_count self._fmb_weighted_sum += other._fmb_weighted_sum self._fmb_weight += other._fmb_weight + self.depth_sum += other.depth_sum + self.depth_count += other.depth_count @property def nps(self): @@ -429,8 +436,13 @@ class EngineStats: return (self._fmb_weighted_sum / self._fmb_weight if self._fmb_weight else 0.0) + @property + def avg_depth(self): + return self.depth_sum / self.depth_count if self.depth_count else 0.0 + def summary(self): return (f"nps={self.nps:.0f} ebf={self.avg_ebf:.3f} " + f"depth={self.avg_depth:.2f} " f"first_move_beta={self.avg_first_move_beta:.1f}% " f"nodes={self.total_nodes} plies={self.plies}") @@ -654,69 +666,106 @@ def main(): break in_flight.add(pool.submit(run, job)) - while in_flight: - finished, in_flight = wait(in_flight, return_when=FIRST_COMPLETED) - for fut in finished: - (i, candidate_result, game, game_elapsed, - this_candidate_stats, this_head_stats) = fut.result() - done += 1 - candidate_points += candidate_result - game_durations.append(game_elapsed) - candidate_stats.merge(this_candidate_stats) - head_stats.merge(this_head_stats) - print(game, file=pgn_out, end="\n\n") - pgn_out.flush() - if candidate_result == 1.0: - wins += 1 - elif candidate_result == 0.0: - losses += 1 - else: - draws += 1 - if sprt is not None: - sprt.update(candidate_result) - log.write(f"game {i}: candidate_result={candidate_result} " - f"elapsed={game_elapsed:.1f}s\n") - log.flush() + try: + while in_flight: + finished, in_flight = wait(in_flight, return_when=FIRST_COMPLETED) + for fut in finished: + (i, candidate_result, game, game_elapsed, + this_candidate_stats, this_head_stats) = fut.result() + done += 1 + candidate_points += candidate_result + game_durations.append(game_elapsed) + candidate_stats.merge(this_candidate_stats) + head_stats.merge(this_head_stats) + print(game, file=pgn_out, end="\n\n") + pgn_out.flush() + if candidate_result == 1.0: + wins += 1 + elif candidate_result == 0.0: + losses += 1 + else: + draws += 1 + if sprt is not None: + sprt.update(candidate_result) + log.write(f"game {i}: candidate_result={candidate_result} " + f"elapsed={game_elapsed:.1f}s\n") + log.flush() - pending[i] = candidate_result - while next_report_idx in pending: - reported_points += pending.pop(next_report_idx) - reported_count += 1 - next_report_idx += 1 + pending[i] = candidate_result + while next_report_idx in pending: + reported_points += pending.pop(next_report_idx) + reported_count += 1 + next_report_idx += 1 - if done % 10 == 0: - avg_game_sec = sum(game_durations) / len(game_durations) - eta_sec = (avg_game_sec * - max(len(jobs) - done, 0)) / args.workers - score = candidate_points / done - sprt_note = "" - if sprt is not None: - sprt_note = (f": llr={sprt.llr():+.2f}, " - f"H0={sprt.la:+.2f}" - f"{sprt_bar(sprt.llr(), sprt.la, sprt.lb)}" - f"H1={sprt.lb:+.2f}") - print(f"{done} games: -{losses} ={draws} +{wins} " - f"({avg_game_sec:.1f}s avg, eta={eta_sec/3600:.0f}hrs, " - f"score={score:.3f}){sprt_note}", - file=sys.stderr) + if done % 10 == 0: + avg_game_sec = sum(game_durations) / len(game_durations) + eta_sec = (avg_game_sec * + max(len(jobs) - done, 0)) / args.workers + score = candidate_points / done + sprt_note = "" + if sprt is not None: + sprt_note = (f": llr={sprt.llr():+.2f}, " + f"H0={sprt.la:+.2f}" + f"{sprt_bar(sprt.llr(), sprt.la, sprt.lb)}" + f"H1={sprt.lb:+.2f}") + print(f"{done} games: -{losses} ={draws} +{wins} " + f"({avg_game_sec:.1f}s avg, eta={eta_sec/3600:.0f}hrs, " + f"score={score:.3f}){sprt_note}", + file=sys.stderr) + print(f" avg depth: candidate={candidate_stats.avg_depth:.2f} " + f"head={head_stats.avg_depth:.2f} " + f"(nps: candidate={candidate_stats.nps:.0f} " + f"head={head_stats.nps:.0f})", + file=sys.stderr) + print(f" avg ebf: candidate={candidate_stats.avg_ebf:.3f} " + f"head={head_stats.avg_ebf:.3f}", + file=sys.stderr) + + if sprt is not None and sprt_decision is None: + sprt_decision = sprt.decision() + if sprt_decision is not None: + print(f" SPRT concluded: {sprt_decision} after " + f"{sprt.n} games (llr={sprt.llr():+.3f})", + file=sys.stderr) + + if sprt_decision is None: + while len(in_flight) < args.workers: + job = next(job_iter, None) + if job is None: + break + in_flight.add(pool.submit(run, job)) + # else: stop topping up, just let whatever's already + # in_flight finish naturally -- no wasted work beyond games + # already launched before the decision landed. + except KeyboardInterrupt: + print("\n^C caught -- dumping stats for the " + f"{done} game(s) completed so far, then exiting " + "(not waiting for in-flight games to finish naturally):", + file=sys.stderr) + print_final_summary(done, candidate_points, wins, draws, + losses, candidate_stats, head_stats, + sprt, sprt_decision, args) + # os._exit, not sys.exit: skips the `with` block's normal + # ThreadPoolExecutor teardown (wait=True by default -- would + # block until every in-flight game's subprocess-driven game + # loop finishes on its own, which is the exact wait a ^C is + # trying to avoid) and any atexit/finally cleanup. Log/pgn + # writes are already flushed after every completed game + # inside the loop, so nothing buffered is lost by skipping + # the file objects' own __exit__. + os._exit(130) - if sprt is not None and sprt_decision is None: - sprt_decision = sprt.decision() - if sprt_decision is not None: - print(f" SPRT concluded: {sprt_decision} after " - f"{sprt.n} games (llr={sprt.llr():+.3f})", - file=sys.stderr) + print_final_summary(done, candidate_points, wins, draws, losses, + candidate_stats, head_stats, sprt, sprt_decision, + args) - if sprt_decision is None: - while len(in_flight) < args.workers: - job = next(job_iter, None) - if job is None: - break - in_flight.add(pool.submit(run, job)) - # else: stop topping up, just let whatever's already - # in_flight finish naturally -- no wasted work beyond games - # already launched before the decision landed. +def print_final_summary(done, candidate_points, wins, draws, losses, + candidate_stats, head_stats, sprt, sprt_decision, + args): + """Shared by the normal end-of-match path and the ^C handler below -- + same CANDIDATE_SCORE/CANDIDATE_STATS/HEAD_STATS block either way, just + computed from whatever `done` games actually completed.""" n_games = done score = candidate_points / n_games if n_games else 0.5 elo, err = elo_diff(score, n_games) |
