summaryrefslogtreecommitdiff
path: root/src/search.c
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/search.c
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/search.c')
-rw-r--r--src/search.c42
1 files changed, 32 insertions, 10 deletions
diff --git a/src/search.c b/src/search.c
index 91e3b2b..cb82ea4 100644
--- a/src/search.c
+++ b/src/search.c
@@ -354,7 +354,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
}
// Maybe do nullmove pruning
- pi->iEval = iEval = Eval(ctx, iAlpha, iBeta, NULL);
+ pi->iEval = iEval = Eval(ctx, iAlpha, iBeta, &(pi->iPositional));
SCORE iImprovement = 0;
if (ctx->uPly > 1)
{
@@ -544,6 +544,25 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
if (iEval < iAlpha)
{
+ // 2026-09-17: tried folding + pi->iPositional[
+ // pos->uToMove] into both conditions below (same
+ // role as QSearch's FUTILITY_BASE_MARGIN_BY_
+ // SOURCE[tier] + iPositional[side]) -- rolled
+ // back, untested in isolation, after a bundled
+ // self-play run (also carrying the margin_a/
+ // margin_b percentile switch, the QSearch
+ // futility margin recalibration, the _EvalKing
+ // fix, and the drawish-in-both-lazy-tiers change)
+ // came back flat/slightly negative with no way
+ // to attribute the result to any one change. See
+ // TODO (EFP_BASE_MARGIN_BY_SOURCE entry) -- the
+ // idea itself isn't rejected, just never
+ // validated on its own. PLY_INFO.iPositional
+ // (chess.h) and the Eval() call populating it
+ // (just above, in this function) are left in
+ // place -- useful per-ply infrastructure
+ // regardless of whether this exact gate ends up
+ // using it.
if ((uDepth <= ONE_PLY + THREE_QUARTERS_PLY) &&
(iEval + VALUE_KNIGHT + iImprovement <= iAlpha))
{
@@ -1689,15 +1708,18 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
// which Eval() exit tier produced iEval/rgiPositional this call.
// CALIBRATE_QSEARCH_FUTILITY data (1500 real-game positions,
// tests/twic_sample.ep_, sd 6) showed the three tiers need very
-// different margins to reach a similar surprise rate: full-eval
-// source was still failing 1.65-6.4% of diagnosed rejects even
-// hundreds of centipawns short of the bar, regular-lazy was already
-// close to safe (1.26% down to 0.43%), super-lazy was only risky very
-// close to the bar (7.96% at distance 0-25, 0.54% by 400+). Indexed
-// by PLY_INFO.uLastPositionalSource (set inside Eval() -- see
-// EVAL_POSITIONAL_SOURCE_* in chess.h). Provisional, derived from one
-// run at sd 6; re-derive with `calibrate qsearchfutility` if search
-// behavior affecting typical qsearch iEval/iFutility gaps changes.
+// different margins to reach a similar surprise rate. Indexed by
+// PLY_INFO.uLastPositionalSource (set inside Eval() -- see
+// EVAL_POSITIONAL_SOURCE_* in chess.h). Re-derive with `calibrate
+// qsearchfutility` if search behavior affecting typical qsearch
+// iEval/iFutility gaps changes.
+//
+// 2026-09-17 re-recalibration: LAZY and SUPERLAZY re-derived (same
+// harness/pool/depth) after eval.c's lazy/super-lazy iPositional
+// stopped being flat 0 and became a real, signed p50-based estimate --
+// see FUTILITY_BASE_MARGIN_LAZY/SUPERLAZY's own derivation comment in
+// chess.h for the full before/after surprise-rate table. FULL is
+// untouched (its iPositional wasn't part of that change).
//
// 2026-09-11: also reused (unmodified) by QSearchFromCheckNoStandPat's
// own stand-pat-safety-net prune below -- moved up here so both