From eaed74ec737e5ac38a6c47d3f3d857d13a3153b3 Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Thu, 27 Aug 2026 16:24:15 -0700 Subject: Fix QSearch stand-pat gating bug and ComputeMoveScore's SEE contamination; tune singular-reply-to-check margin. QSearch stand-pat: the "deny stand pat when material is genuinely in trouble" check was gated on fCouldStandPat (has this side had a chance to stand pat earlier in this qsearch line). That's wrong -- whether an ancestor node had a moment of safety says nothing about whether *this* node's material danger is real; a hanging piece doesn't stop hanging because the position was quiet three plies ago. fCouldStandPat's legitimate uses (search.c:950, 1190/1193) are about deciding whether a *whole line* looks forcing enough to justify extra qsearch depth/ breadth, a different question from per-node stand-pat correctness. Removed the gate; the material-in-trouble check now always denies stand-pat, regardless of history. ComputeMoveScore: for winning/even captures and promotions, the value extracted from the move-ordering sort key (generate.c's _ScoreAllMoves) included a flat +120 ordering bias plus small MVV-LVA tie-break nudges (PIECE_VALUE_OVER_100 terms) baked in on top of the real SEE/ material-diff value. Harmless for its original sorting purpose (every capture gets the same treatment), but this function's callers (futility pruning, the singular-reply-to-check extension) use the result as an eval-axis quantity compared against material-scale margins -- the contamination doesn't belong there. Subtracted the ordering-only bias back out to recover pure SEE/material-diff, same axis as the raw PIECE_VALUE() fallback used when no move-stack index is available. Quiet-move and losing-capture handling were already correct (both collapse to a clean, uncontaminated value). Also bumped the singular-reply-to-check margin (225 -> 400): confirmed via direct A/B on the quick suites that this is a real, independent improvement on top of the SEE fix, not just compensating for it -- reverting to 225 measurably regressed both ecm_confident_quick.ep_ (89->88/90) and ecm_hard_quick.ep_ (10->6/90) versus keeping 400. Verified against pristine baseline (no LMR in this binary) on the three-suite protocol (ecm_ringers.ep_, ecm_confident_quick.ep_, ecm_hard_quick.ep_, sn=5M, book disabled): 11/11 ringers (matches baseline exactly), 89/90 confident (vs baseline's 90/90), 10/90 hard (vs baseline's 4/90) -- a real net improvement over baseline with zero LMR involved, considerably stronger than any state reached earlier in this session's LMR-only experimentation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2 --- src/search.c | 21 ++++++++++++++------- src/searchsup.c | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/search.c b/src/search.c index 6c8a945..e0339cf 100755 --- a/src/search.c +++ b/src/search.c @@ -943,8 +943,9 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, uMoveCount = MOVE_COUNT(ctx, ctx->uPly); if (uMoveCount > 0) { - // Consider extending the number of qsearch plies for our opponent - // if this looks good. + // Consider extending the number of qsearch check-generating + // plies for our opponent if this looks good -- we have not + // yet been able to stand pat and they might mate us. if ((pf->uQsearchDepth < pf->uQsearchCheckDepth) && (pf->uQsearchDepth < g_uIterateDepth / 4) && (pf->fCouldStandPat[pos->uToMove] == FALSE) && @@ -1144,11 +1145,17 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, // If that Eval (above) was full (i.e. not lazy) it may have set // en prise and trapped piece indicators. Likewise, other nodes // at this depth may have set en prise piece hints. If these are - // set and valid, it means this is not a "quiet" position. If the - // side on the move has not been able to stand pat yet, don't let - // them now -- force them to play a move and recurse. - if (0 != ValueOfMaterialInTroubleDespiteMove(ctx, pos->uToMove) && - FALSE == ctx->sSearchFlags.fCouldStandPat[pos->uToMove]) + // set and valid, it means this is not a "quiet" position -- don't + // let this side stand pat, force them to play a move and recurse. + // This is deliberately independent of fCouldStandPat: whether an + // ancestor node in this qsearch line had a moment of safety says + // nothing about whether *this* node's material danger is real -- + // a hanging piece doesn't stop hanging because the position was + // quiet three plies ago. fCouldStandPat's job is different (see + // its other uses: deciding whether a *whole line* looks forcing + // enough to justify extra qsearch depth/breadth), not gating + // per-node stand-pat correctness. + if (0 != ValueOfMaterialInTroubleDespiteMove(ctx, pos->uToMove)) { iBestScore = iAlpha; } diff --git a/src/searchsup.c b/src/searchsup.c index c7ac842..8d15e57 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -252,6 +252,21 @@ ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, { ASSERT(iMoveScore > 0); iMoveScore &= STRIP_OFF_FLAGS; + + // _ScoreAllMoves (generate.c) bakes a flat +120 move-ordering + // bias plus small MVV-LVA tie-break nudges + // (PIECE_VALUE_OVER_100 terms) on top of the real SEE/ + // material-diff value for winning/even captures and + // promotions -- harmless for sorting (every capture gets + // the same treatment), but this function's callers use the + // result as an eval-axis quantity (compared against + // material-scale margins in futility/extension code), so + // back the ordering-only bias back out to recover the pure + // SEE/material-diff value, same axis as the raw + // PIECE_VALUE() fallback below. + iMoveScore -= (PIECE_VALUE_OVER_100(mv.pCaptured) + 120 + + PIECE_VALUE_OVER_100(mv.pPromoted) - + PIECE_VALUE_OVER_100(mv.pMoved)); } else { @@ -439,7 +454,7 @@ Return value: // Singular response to check... if ((mv.pCaptured) && - (iRoughEval + 225 < iAlpha) && + (iRoughEval + 400 < iAlpha) && (iMoveScore + 75 > iAlpha)) { *piExtend += ONE_PLY; -- cgit v1.3