summaryrefslogtreecommitdiff
path: root/src/eval_tune/match_play.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-18 09:43:17 -0700
committerScott Gasch <[email protected]>2026-09-18 09:43:17 -0700
commitf698c02de213d0ed8968fb7cca5f70df51fa0763 (patch)
tree080d6443fbe62d71de092f9acde61da387dff408 /src/eval_tune/match_play.py
parent4a7ec9ac91939957fb3d3e5564fc24a385b422ff (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-xsrc/eval_tune/match_play.py102
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: