summaryrefslogtreecommitdiff
path: root/src/eval_tune
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
committerScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
commit8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch)
tree7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src/eval_tune
parent9d1e32db698855b939c4bc5df6b6a82509dd381c (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')
-rwxr-xr-xsrc/eval_tune/match_play.py165
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)