diff options
| author | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
| commit | 8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch) | |
| tree | 7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src/search.c | |
| parent | 9d1e32db698855b939c4bc5df6b6a82509dd381c (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/search.c')
| -rw-r--r-- | src/search.c | 113 |
1 files changed, 83 insertions, 30 deletions
diff --git a/src/search.c b/src/search.c index fae2567..55d2006 100644 --- a/src/search.c +++ b/src/search.c @@ -1563,6 +1563,32 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx, } +// 2026-09-08: was one flat FUTILITY_BASE_MARGIN (150) regardless of +// 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 ctx->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. +// +// 2026-09-11: also reused (unmodified) by QSearchFromCheckNoStandPat's +// own stand-pat-safety-net prune below -- moved up here so both +// functions can share one definition instead of duplicating/guessing +// a second margin. +static const SCORE FUTILITY_BASE_MARGIN_BY_SOURCE[EVAL_POSITIONAL_SOURCE_COUNT] = +{ + FUTILITY_BASE_MARGIN_FULL, // EVAL_POSITIONAL_SOURCE_FULL + FUTILITY_BASE_MARGIN_LAZY, // EVAL_POSITIONAL_SOURCE_LAZY + FUTILITY_BASE_MARGIN_SUPERLAZY, // EVAL_POSITIONAL_SOURCE_SUPERLAZY +}; + + /** Routine description: @@ -1596,9 +1622,7 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, SCORE iBestScore = MATED_SCORE(ctx->uPly); SCORE iScore; MOVE mv; -#if defined(DEBUG) || defined(PERF_COUNTERS) ULONG uLegalMoves = 0; -#endif ULONG uQsearchCheckExtension = 0; ASSERT(InCheck(pos, pos->uToMove)); @@ -1614,9 +1638,9 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, (pf->fCouldStandPat[pos->uToMove] == FALSE) && (CountKingSafetyDefects(pos, pos->uToMove) > 4)) { - if ((uMoveCount == 1) || - (NUM_KING_MOVES(ctx, ctx->uPly) == 0) || - (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1)) + if (ONE_LEGAL_MOVE(ctx, ctx->uPly) || + (NUM_KING_MOVES(ctx, ctx->uPly) == 0) || + (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1)) { uQsearchCheckExtension = 2; INC(ctx->sCounters.extension.uQExtend); @@ -1625,6 +1649,24 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, } } + // If we could have stood pat at an ancestor node, though, then any + // work we do here to determine "how bad" things really are is very + // likely to be wasted effort (since, at the ancestor node, side to + // move can stand pat instead of take this line's _bad_ score). + // + // iEval itself is computed lazily below, on the first quiet + // (non-checking) move actually reached, rather than unconditionally + // here -- Eval() is a real cost (full/lazy/super-lazy tiers, not + // free), and _ScoreAllEscapes tiers captures/checks above quiet + // moves, so a beta cutoff on an earlier capture/check is common and + // means this never needs to run at all. Self-play measurement + // (avg depth/nps regression vs HEAD under equal time) showed this + // call's own cost was eating into the node savings this whole + // mechanism exists to redirect elsewhere. + SCORE rgiPositional[2]; + SCORE iEval = 0; + FLAG fEvalComputed = FALSE; + for (x = ctx->sMoveStack.uBegin[ctx->uPly]; x < ctx->sMoveStack.uEnd[ctx->uPly]; x++) @@ -1637,13 +1679,44 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, ctx->sMoveStack.mvf[x].bvFlags |= MVF_MOVE_SEARCHED; #endif - // Note: no selectivity at in-check nodes; search every reply. - // IDEA: prune if the side in check could have stood pat before. + // See above... this is an effort to avoid wasting time + // figuring out exactly "how bad" things get here if side to + // move already has a "get out of jail free" card in their + // pocket (in the form of a stand pat at some ancestor + // position). + if (TRUE == pf->fCouldStandPat[pos->uToMove] && + uLegalMoves > 0 && + !IS_CHECKING_MOVE(mv)) + { + if (FALSE == fEvalComputed) + { + iEval = Eval(ctx, iAlpha, iBeta, &rgiPositional); + ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT); + fEvalComputed = TRUE; + } + + // Same shape (and same calibrated per-tier margin) as + // normal QSearch's own futility gate below -- see + // FUTILITY_BASE_MARGIN_BY_SOURCE's derivation comment. + // That margin was calibrated against *ordinary* quiet + // positions; resolving a check can swing the eval more + // than an ordinary quiet move can (a king escaping to a + // genuinely safe square vs. one that only looks escaped), + // so this reuses the same margin rather than inventing a + // separate, unvalidated one -- better to be at least as + // conservative as the calibrated case than to guess looser. + if (iEval < iAlpha && + ((iEval + PIECE_VALUE(mv.pCaptured)) + rgiPositional[pos->uToMove] + + FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource]) < iAlpha) + { + continue; + } + } + if (MakeMove(ctx, mv)) { -#if defined(DEBUG) || defined(PERF_COUNTERS) uLegalMoves++; -#endif + pf->uQsearchNodes++; pf->uQsearchDepth++; ASSERT(uQsearchCheckExtension < 3); @@ -1692,27 +1765,6 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, } -// 2026-09-08: was one flat FUTILITY_BASE_MARGIN (150) regardless of -// 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 ctx->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. -static const SCORE FUTILITY_BASE_MARGIN_BY_SOURCE[EVAL_POSITIONAL_SOURCE_COUNT] = -{ - FUTILITY_BASE_MARGIN_FULL, // EVAL_POSITIONAL_SOURCE_FULL - FUTILITY_BASE_MARGIN_LAZY, // EVAL_POSITIONAL_SOURCE_LAZY - FUTILITY_BASE_MARGIN_SUPERLAZY, // EVAL_POSITIONAL_SOURCE_SUPERLAZY -}; - - /** Routine description: @@ -1823,6 +1875,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, if (IS_CHECKING_MOVE(mvLast)) { ASSERT(InCheck(pos, pos->uToMove)); + INC(ctx->sCounters.extension.uQExtendChaseNodes); iBestScore = QSearchFromCheckNoStandPat(ctx, iAlpha, iBeta); goto end; } |
