summaryrefslogtreecommitdiff
path: root/src/search.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
committerScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
commit8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch)
tree7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src/search.c
parent9d1e32db698855b939c4bc5df6b6a82509dd381c (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.c113
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;
}