summaryrefslogtreecommitdiff
path: root/src/searchsup.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/searchsup.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/searchsup.c')
-rw-r--r--src/searchsup.c39
1 files changed, 38 insertions, 1 deletions
diff --git a/src/searchsup.c b/src/searchsup.c
index 2ec001f..26856cd 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -1154,7 +1154,13 @@ Return value:
//
if (WE_SHOULD_STOP_SEARCHING)
{
+ // 2026-09-18: same staleness hazard as the IsDraw/too-deep
+ // paths below -- clear this ply's own PV[] slot too, not just
+ // mvBest. See the IsDraw branch's comment for the full
+ // mechanism (an ancestor's UpdatePV blindly copies this row
+ // forward assuming it's fresh).
pi->mvBest.uMove = 0;
+ pi->PV[ctx->uPly].uMove = 0;
*piScore = INVALID_SCORE;
goto end;
}
@@ -1191,10 +1197,33 @@ Return value:
pi->mvBest.uMove = 0;
INC(ctx->sCounters.tree.u64LeafCount);
*piScore = g_iDrawScore[ctx->sPosition.uToMove];
+ // 2026-09-18: pi->PV[ctx->uPly] has the exact same staleness
+ // hazard as pi->mvBest above, but was only ever cleared
+ // conditionally, via UpdatePV(ctx, DRAWMOVE) -- and only when
+ // the draw score actually falls in the open (alpha, beta)
+ // window. When it doesn't, this ply's PV[] row is left
+ // whatever an unrelated earlier node last wrote there.
+ // sPlyInfo[] rows are shared/reused across every node that
+ // ever occupies a given ply (see UpdatePV's own sentinel-case
+ // comment), so a later ancestor's UpdatePV can blindly copy
+ // that stale row forward via `this->PV[u] = next->PV[u]`,
+ // trusting its zero-terminator as proof of freshness --
+ // splicing a leftover move from a completely different
+ // subtree onto the real line. This is the root cause of a
+ // WalkPV/MakeMove assertion failure (move.c:881,
+ // "ASSERT(!IS_EMPTY(pCaptured))" -- a move that's real
+ // *somewhere* but whose capture target is empty on the
+ // actual board) reproduced against tests/cross_sectional_
+ // 200k.ep_. Clear unconditionally so a non-window draw score
+ // can't leave this row stale.
if ((*piAlpha < *piScore) && (*piScore < *piBeta))
{
UpdatePV(ctx, DRAWMOVE);
}
+ else
+ {
+ pi->PV[ctx->uPly].uMove = 0;
+ }
DTTRACE("<i bold=\"draw\"/>");
goto end;
}
@@ -1245,8 +1274,16 @@ Return value:
*piScore = Eval(ctx, *piAlpha, *piBeta, NULL);
pi->iEval = *piScore;
}
- // See the IsDraw branch above -- same staleness hazard, same fix.
+ // See the IsDraw branch above -- same staleness hazard, same
+ // fix -- this path returns a live, legitimate score (either a
+ // real Eval() call or *piAlpha) that absolutely can raise an
+ // ancestor's alpha, and is reached constantly (any node
+ // against the ply ceiling or qsearch-extend limit), making it
+ // the most likely real source of the reproduced WalkPV
+ // staleness bug (see the IsDraw branch's comment for the full
+ // mechanism and the move.c:881 assert it produces).
pi->mvBest.uMove = 0;
+ pi->PV[ctx->uPly].uMove = 0;
DTTRACE("<toodeep/>");
goto end;
}