diff options
| author | Scott Gasch <[email protected]> | 2026-09-18 09:43:17 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-18 09:43:17 -0700 |
| commit | f698c02de213d0ed8968fb7cca5f70df51fa0763 (patch) | |
| tree | 080d6443fbe62d71de092f9acde61da387dff408 /src/eval_tune/match_play.py | |
| parent | 4a7ec9ac91939957fb3d3e5564fc24a385b422ff (diff) | |
Redesign lazy/super-lazy eval margins with position-specific percentile
tables, and fix four real search/recognizer bugs found while validating it
Main theme: replace the flat, heavily-padded LAZY_EVAL_BASE_MARGIN /
SUPER_LAZY_MARGIN_BY_ARMY constants with per-material-bucket percentile
tables (king safety by attacker army, residual by own army, separate
tables for the lazy vs super-lazy baselines) derived from a 30k-position
calibration pool, targeting a measured ~1-1.5% exceeded-margin rate
instead of an unmeasured "add lots of headroom" floor. QSearch's
FUTILITY_BASE_MARGIN_LAZY/SUPERLAZY were re-derived to match the new,
now-meaningful (p50-based) iPositional this produces. Self-play confirms
the working tree beats both HEAD, a more liberal (~5% miss target)
variant, and a more conservative (near-zero miss) variant of the same
margin mechanism.
Along the way, an initial self-play regression (this margin work bundled
with several changes in one pass, one of which broke things) led to a
day of ZFS-snapshot bisection and then targeted DEBUG-build fuzzing
against a stratified 200k-position corpus (tests/cross_sectional_200k.ep_,
new), which is what actually found and fixed four distinct, real,
pre-existing engine bugs -- none of them related to the margin redesign
itself:
- searchsup.c (CommonSearchInit): three early-return paths (timer
abort, draw, too-deep bailout) left a node's own PV[] row stale
instead of clearing it, letting an ancestor's UpdatePV splice a
leftover move from a completely unrelated node into the displayed
PV -- root cause of a WalkPV/MakeMove assert ("moving a piece to
capture, but nothing was on the target square").
- dynamic.c (RecordEnprisePieceAtPly): the two-slot en-prise dedup
compared (square, piece) instead of square alone, so a same-square
re-record with a different piece (legitimate -- a fail-high
capture's victim is restored by UnmakeMove and can differ across
calls at the same ply) shifted a duplicate square into both slots,
tripping ValueOfMaterialInTroubleDespiteMove's two-distinct-squares
assert.
- recogn.c (_RecognizeKBKP): the wrong-colored-bishop corner-fortress
draw shortcut fired even when the weak side has its own pawn(s),
which can hand the strong side real zugzwang resources the fortress
logic doesn't account for -- produced a false hard draw bound where
EGTB shows a near-forced loss.
- eval.c (_EvalPasserRacesAgainstLoneKings): a rook pawn's race was
credited as winning even when the strong side's own king sits
exactly on its own pawn's queening square (distance 0 specifically --
adjacent is fine, the square is empty) -- a classic stalemate
resource the race heuristic can't see. Only reachable through
_RecognizeKPK's pawn-hash-cache-hit path, which is why
testrecogn.c's TestRecognExhaustiveKPK reported "0 wrong" the whole
time; extended it to cover both color assignments and the full board
(no mirror-shortcut) and documented the remaining cache-hit coverage
gap for a future session to close.
Also: match_play.py gains a local_ebf metric (geometric-mean of
depth-normalized node-growth ratios between consecutive completed
iterations) alongside the existing cumulative nodes**(1/depth) ebf,
which turned out to be misleading when comparing engines with
different lazy-exit aggressiveness -- and probe.c/hash.c pick up two
small, independently-found correctness fixes (TB root-probe promotion
piece missing its color bit; ASSERT(g_pHashTable) crashing on the
legitimate "--hash none" config) noticed along the way.
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 | 102 |
1 files changed, 96 insertions, 6 deletions
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py index e2e089a..d8a8e7b 100755 --- a/src/eval_tune/match_play.py +++ b/src/eval_tune/match_play.py @@ -31,8 +31,8 @@ Usage: Prints: CANDIDATE_SCORE=<0..1> GAMES=<n> WINS=<a> DRAWS=<b> LOSSES=<c> ELO=<+/-x.x> LOWER95=<x> [SPRT=... LLR=... BOUNDS=... ELO0=... ELO1=...] - CANDIDATE_STATS: nps=... ebf=... first_move_beta=...% nodes=... plies=... - HEAD_STATS: nps=... ebf=... first_move_beta=...% nodes=... plies=... + CANDIDATE_STATS: nps=... ebf=... local_ebf=... first_move_beta=...% nodes=... plies=... + HEAD_STATS: nps=... ebf=... local_ebf=... first_move_beta=...% nodes=... plies=... for the orchestrator to parse. Speed/tree-shape stats (EngineStats) are pulled from the same per-move PostMoveSearchReport block every "go" already prints, not a separate benchmark -- see EngineStats' docstring. @@ -69,6 +69,21 @@ _SEARCHED_RE = re.compile( _DEPTH_RE = re.compile(r"^tellothers d(\d+),") _FIRST_MOVE_BETA_RE = re.compile( r"^First move beta cutoff rate was\s+([\d.]+) percent\.") +# 2026-09-18: per-iteration line UtilPrintPV (util.c) prints once per +# completed depth during a move's search (both under-xboard "%2u %6d +# %5u %-12llu %s" and human "%2u[+-]? %5s %-9s %-11llu %s" formats -- +# see util.c's UtilPrintPV; engines here run under-xboard since Engine +# sends "xboard" at startup, per _STATS_TRAILING_LINES' comment above, +# but this regex tolerates either since it only cares about the depth +# and node-count fields, not the score/time/PV formatting in between). +# u64TotalNodeCount is a running total for the WHOLE search (not reset +# per iteration), so consecutive lines' node counts are monotonic +# non-decreasing including across an aspiration-window fail-low/high +# re-search at the SAME depth (root.c re-enters Search() with a wider +# 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") # 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 @@ -283,6 +298,21 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen, # way around -- keep reading a bounded number of further # lines to pick it up before moving on to the next ply. ply_nodes = ply_time = ply_depth = ply_fmb = None + # 2026-09-18: local (per-iteration) branching factor, an + # alternative to the cumulative nodes**(1/depth) EBF below + # that isn't sensitive to which integer depth a move's + # search happens to finish on -- see _ITER_RE's comment and + # EngineStats' docstring for the full rationale. Track the + # most recently seen (depth, nodes) pair; each time depth + # actually advances (not just a same-depth aspiration + # re-search line), record nodes_now/nodes_prev as one + # branching-factor sample for this ply, geometric-mean them + # 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 for _ in range(_STATS_TRAILING_LINES): line = _readline_or_none(mover.stdout, _STATS_IDLE_TIMEOUT_SEC) if line is None: @@ -309,8 +339,27 @@ 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 if ply_nodes is not None and ply_time is not None: - mover_stats.add_ply(ply_nodes, ply_time, ply_depth) + ply_local_ebf = (math.exp(iter_log_ratio_sum / iter_ratio_count) + if iter_ratio_count else None) + mover_stats.add_ply(ply_nodes, ply_time, ply_depth, + ply_local_ebf) if ply_fmb is not None: if white_to_move: white_last_fmb = ply_fmb @@ -376,7 +425,33 @@ class EngineStats: (nodes**(1/depth) per move, see head_reference notes on why this can diverge from first-move-beta-cutoff-rate when pruning/aspiration- window re-searches inflate node counts independent of true tree - shape), then averaged across all moves an engine played. First-move + shape), then averaged across all moves an engine played. + + 2026-09-18: that cumulative nodes**(1/depth) EBF turned out to be + misleading when comparing two engines with different lazy-eval-exit + aggressiveness -- one engine measured LOWER nps and HIGHER avg_depth + than the other while ALSO reporting HIGHER ebf, which is not + reconcilable if ebf is really "nodes needed per unit depth" (lower + nps + higher depth implies fewer total nodes were needed to get + there, which should mean lower ebf, not higher). Root cause: this + formula uses the SEARCH-WIDE cumulative node count raised to + 1/(the single integer depth reported at the end), so it's extremely + sensitive to exactly which whole ply a move's search happens to + finish on within its time budget -- a difference in time-management + timing that pushes a move to complete one more (or one fewer) full + iteration can swing this number a lot with no real change in tree + efficiency, and this effect doesn't reliably average out because + node-count magnitude varies hugely across game phases/positions. + `local_ebf` (see add_ply) fixes this by computing depth-normalized + branching ratios from CONSECUTIVE completed-iteration node counts + within a single move's search (nodes_now/nodes_prev, depth-delta- + normalized, geometric-meaned across a move's iterations, then + arithmetic-meaned across moves same as avg_ebf) -- a true local + branching-factor measurement, insensitive to which absolute depth + the search happened to stop at. Kept the old ebf/avg_ebf fields + alongside rather than replacing them, since the cumulative version + is still what script.c's own suite runs report and there's value in + being able to cross-check the two against each other. First-move beta cutoff rate is read as a running total inside a single engine process's lifetime (root.c never resets those counters mid-process), and since play_one_game spawns a fresh process per game, the value @@ -391,12 +466,14 @@ class EngineStats: self.plies = 0 self.ebf_sum = 0.0 self.ebf_count = 0 + self.local_ebf_sum = 0.0 + self.local_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): + def add_ply(self, nodes, elapsed, depth, local_ebf=None): self.total_nodes += nodes self.total_time += elapsed self.plies += 1 @@ -406,6 +483,9 @@ class EngineStats: if depth and depth > 0 and nodes > 0: self.ebf_sum += nodes ** (1.0 / depth) self.ebf_count += 1 + if local_ebf is not None: + self.local_ebf_sum += local_ebf + self.local_ebf_count += 1 def add_game_first_move_beta(self, pct, plies_in_game): if pct is not None and plies_in_game > 0: @@ -418,6 +498,8 @@ class EngineStats: self.plies += other.plies self.ebf_sum += other.ebf_sum self.ebf_count += other.ebf_count + self.local_ebf_sum += other.local_ebf_sum + self.local_ebf_count += other.local_ebf_count self._fmb_weighted_sum += other._fmb_weighted_sum self._fmb_weight += other._fmb_weight self.depth_sum += other.depth_sum @@ -432,6 +514,11 @@ class EngineStats: return self.ebf_sum / self.ebf_count if self.ebf_count else 0.0 @property + def avg_local_ebf(self): + return (self.local_ebf_sum / self.local_ebf_count + if self.local_ebf_count else 0.0) + + @property def avg_first_move_beta(self): return (self._fmb_weighted_sum / self._fmb_weight if self._fmb_weight else 0.0) @@ -442,6 +529,7 @@ class EngineStats: def summary(self): return (f"nps={self.nps:.0f} ebf={self.avg_ebf:.3f} " + f"local_ebf={self.avg_local_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}") @@ -718,7 +806,9 @@ def main(): 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}", + f"head={head_stats.avg_ebf:.3f} " + f"(local: candidate={candidate_stats.avg_local_ebf:.3f} " + f"head={head_stats.avg_local_ebf:.3f})", file=sys.stderr) if sprt is not None and sprt_decision is None: |
