diff options
| author | Scott Gasch <[email protected]> | 2026-10-05 09:01:25 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-05 09:01:25 -0700 |
| commit | 64b5d3307a7a95975f7f8c0248f3f1febedd8617 (patch) | |
| tree | 993fffd62a68bec962261caf033a8f05c6370ea9 /src/search.c | |
| parent | bbdf1bda8f410df6aa9be0a38e71b7db04982198 (diff) | |
Continue dynamic move-ordering/LMR session: SPRT-validated at 1770+ games
Gate match vs head (st4.0) shows LLR=+2.89 approaching the H1=+2.94
bound, candidate score 0.5324 (LOWER95=0.5092, ELO=+22.6+/-16.2),
candidate depth/ebf both improved (13.98 vs 13.61 avg depth, ebf 3.324
vs 3.464) with comparable nps -- consistent with real search-efficiency
gains rather than noise. match_play.py gets additional instrumentation
to report these stats; search.c/searchsup.c/generate.c/chess.h/eval.c
carry the underlying move-ordering/LMR changes from this session's
line of work.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/search.c')
| -rw-r--r-- | src/search.c | 1519 |
1 files changed, 715 insertions, 804 deletions
diff --git a/src/search.c b/src/search.c index 713d35e..ee055a4 100644 --- a/src/search.c +++ b/src/search.c @@ -146,10 +146,11 @@ ShipLateBucketPublic(ULONG uRawLateBucket) return 14; } -// Local mirror of searchsup.c's _JointTablePredictedFHThousandths, -// including its dumb-bucket-1 sentinel rule (that bucket is ~97% -// SEE-scored losing captures/underpromotions misfiled onto the quiet -// axis, so its literal low value is not a real "safe" signal). +// _JointTablePredictedFHThousandths (searchsup.c, declared in chess.h) +// is called directly from this file's hoisted per-move lookup block +// below -- no local mirror, unlike ShipDepthBucketPublic/ +// ShipLateBucketPublic above, which really do need file-local +// standalone copies (see that function's own note on why). // Local mirrors of searchsup.c's file-static AttemptsBucket/RateBucket // (calibration-only; see ShipDepthBucketPublic's note on duplication). #if defined(PERF_COUNTERS) && defined(PRUNE_VERIFY) @@ -454,7 +455,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, MOVE mv, mvBest, mvHash; ULONG x = 0; SCORE iScore; - INT iOrigExtend = 0; + INT iPositionalExtend = 0; INT iExtend; ULONG uNextDepth; ULONG uLegalMoves = 0; @@ -504,13 +505,13 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // unifying onto this gate, per direct instruction, since it was // LMR-specific rather than part of the shared "unremarkable move" // definition gate-1 describes. - FLAG fPruneReduceEligible = FALSE; + FLAG fMoveIsPruneReduceEligible = FALSE; #ifdef PERF_COUNTERS // See the node-type counter comment below: guards the surprise // counters at end: so a node short-circuited by CommonSearchInit // (and therefore never typed for this visit) cannot be scored // against a stale eNodeType left by a previous node at this ply. - FLAG fNodeTypedThisVisit = FALSE; + FLAG fNoteTypedThisCall = FALSE; FLAG fThisMoveIsCountermoveMatch = FALSE; ULONG uCMEvidenceBucket = 0; ULONG uThisMoveHangBucket = 0; @@ -532,9 +533,10 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, FLAG fIsDraw; ULONG uStage = TRY_HASH_MOVE; ULONG u; - ULONG uFutilityMargin = 0; - FLAG fAnyMoveEFPPruned = FALSE; - FLAG fThisMoveEFPPruned = FALSE; + FLAG fTryEFPHere = FALSE; + FLAG fAnyMovePruned = FALSE; + FLAG fThisMoveEFPPruned; + FLAG fThisMoveLMPPruned; SCORE iMoveSee; #ifdef TEST_NULL FLAG fNullCalibEligible = FALSE; @@ -617,7 +619,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // // NOTE: the NODE_TYPE enum and every behavioral consumer of it // (all of which only ever test against NODE_PV) are untouched. - fNodeTypedThisVisit = TRUE; + fNoteTypedThisCall = TRUE; ASSERT((ULONG)pi->eNodeType < 3); INC(ctx->sCounters.tree.u64NodeTypeCount[(ULONG)pi->eNodeType]); #endif @@ -634,10 +636,10 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // Check the transposition table. This may give us a cutoff // without doing any work if we have previously stored the score - // of this search in the hash. It also may set mvHash even if it - // can't give us a cutoff. It also may set fSkipNull (see below) - // based on uNextDepth to inform us that a nullmove search is - // unlikely to succeed here. + // of this position in the hash. It also may set mvHash even if + // it can't give us a cutoff. It also may set fSkipNull (see + // below) based on uNextDepth to inform us that a nullmove search + // is unlikely to succeed here. mvHash.uMove = 0; pHash = HashLookup(ctx, uDepth, @@ -654,11 +656,11 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, u = pHash->bvFlags & HASH_FLAG_VALID_BOUNDS; if (0 != mvHash.uMove) { - // This is an idea posted by Dieter Brusser on CCC: If we + // This is an idea posted by Dieter Brusser on CCC: If we // get a hash hit that leads to a draw then only accept it // if it has a score of zero, allows us to fail high when // a draw would also have allowed a fail high, or allows a - // fail low when a draw would also have allowed a fail + // fail low when a draw would also have allowed a failed // low. VERIFY(MakeMove(ctx, mvHash)); fIsDraw = IsDraw(ctx); @@ -753,16 +755,13 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { iImprovement = (iEval - ctx->sPlyInfo[ctx->uPly - 2].iEval); } - // Must be captured HERE, before any MakeMove in the loop below - // changes pos's live signature out from under it. + + // If the Eval call above was a full eval (i.e. not lazy or super + // lazy), there are usable attack bitboards. We'll consult them + // below to determine if moves likely lose material but grab this + // NOW, before any MakeMoves or recursion. fParentAttackTablesAreFresh = (pi->u64FullEvalSig == (pos->u64NonPawnSig ^ pos->u64PawnSig)); - - // Snapshot into local storage RIGHT HERE too, same reasoning -- - // see this array's declaration comment. If not fresh, zero them - // rather than leave garbage; EstimateHangingMaterialCp still - // checks fParentAttackTablesAreFresh first and won't consult - // these when it's FALSE, but zeroing avoids depending on that. if (TRUE == fParentAttackTablesAreFresh) { bbMinorAttacksSnapshot[WHITE] = pos->bbMinorAttacks[WHITE]; @@ -868,10 +867,11 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, iAlpha, iBeta, uNextDepth, - &iOrigExtend, + &iPositionalExtend, &iScore)) { - if (iScore > iBeta) { + if (iScore > iBeta) + { StoreLowerBound(mvHash, pos, iScore, uDepth, FALSE); } iBestScore = iScore; @@ -883,7 +883,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // or hash table results. if (fThreat) { - iOrigExtend += THREE_QUARTERS_PLY; + iPositionalExtend += THREE_QUARTERS_PLY; INC(ctx->sCounters.extension.uMateThreat); } @@ -929,12 +929,20 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // TODO: Experiment with removing these extensions. if (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1) { - iOrigExtend += QUARTER_PLY; + iPositionalExtend += QUARTER_PLY; INC(ctx->sCounters.extension.uMultiCheck); - } else if (NUM_KING_MOVES(ctx, ctx->uPly) == 0) { - iOrigExtend += QUARTER_PLY; + } + else if (NUM_KING_MOVES(ctx, ctx->uPly) == 0) + { + iPositionalExtend += QUARTER_PLY; INC(ctx->sCounters.extension.uNoLegalKingMoves); } + else if (ONE_LEGAL_MOVE(ctx, ctx->uPly) && + CountKingSafetyDefects(pos, pos->uToMove) > 2) + { + iPositionalExtend += HALF_PLY; + INC(ctx->sCounters.extension.uOneLegalMove); + } } } else @@ -948,7 +956,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // combined per-position extension to what the rest of the // code (e.g. split.c's HelpSearch) assumes is the max for // a single node. - iOrigExtend = MIN(iOrigExtend, ONE_PLY); + iPositionalExtend = MIN(iPositionalExtend, ONE_PLY); // fall through case PREPARE_TO_TRY_MOVES: @@ -986,77 +994,14 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } } #endif - // Ernst Heinz's forward-pruning-by-material-margin idea, - // rewritten to match his book's actual two-tier - // schedule (this previously used a single flat - // VALUE_ROOK margin across the whole uDepth <= TWO_PLY - // range, which is neither of the two numbers Heinz - // actually gives for that range): "selective futility" - // at the frontier (VALUE_KNIGHT, his 200-400 - // pawn-equivalent range) and "extended futility - // pruning" proper one ply further back (VALUE_ROOK, his - // 500-600 range). Common conditions (PV-node guard, - // ply floor, no per-position extension here or two - // plies back) are the same for both tiers, so they're - // checked once; only the depth band and margin differ - // per tier. Deliberately drops the old - // ValueOfMaterialInTroubleDespiteMove requirement (an - // en-prise/trapped-piece safety net) -- this is meant - // to fire on ordinary quiet positions too, not just - // ones where a piece is already known to be in danger. - // - // Tier boundaries are relative to THREE_QUARTERS_PLY - // (the actual QSearch cutoff just below, not ONE_PLY -- - // lowered when the check-extension rework made a lone - // check buy exactly one extra full-width ply rather - // than a blanket extra 1/4 ply): "one ply above the - // QSearch jump" is (THREE_QUARTERS_PLY, ONE_PLY + - // THREE_QUARTERS_PLY], "two plies above" is the next - // such band. - // - // "Limited razoring" (Heinz's third tier, pre-pre- - // frontier, VALUE_QUEEN, ~900-1000) is a different - // technique -- a per-node depth reduction, not a - // per-move prune -- and is deliberately not implemented - // here; see lmr_testing/RESULTS.md. - // - // PV-node guard: HEAD's original condition had none - // (unlike GetLMRReduction, which has always required - // FALSE == fPvNode) -- pruning a fail-high inside a PV - // node can silently corrupt the actual principal - // variation, not just tighten a sibling's bound, so - // this closes a real gap rather than relying on it not - // mattering in practice. - ASSERT(!uFutilityMargin); - if ((NODE_PV != pi->eNodeType) && - (iOrigExtend == 0) && + // Decide whether to do EFP; see below. + ASSERT(FALSE == fTryEFPHere); + if ((iEval < iAlpha) && + (pi->eNodeType != NODE_PV) && (ctx->uPly >= 2) && (ctx->sPlyInfo[ctx->uPly - 2].iExtensionAmount <= 0)) { - ASSERT(uDepth >= THREE_QUARTERS_PLY); - ASSERT(iAlpha + 1 == iBeta); - - if (iEval < iAlpha) - { - // TODO: EFP is 100% based on the "gap" dimension: i.e., - // the distance between iEval and iAlpha makes a FH under - // this move unlikely. However, there are other good - // signals that we are ignoring here. Such as: does the - // move seem to hang material? If so, we should prune - // more willingly. If not, we should be less eager to - // prune here (maybe). - if ((uDepth <= ONE_PLY + THREE_QUARTERS_PLY) && - (iEval + VALUE_KNIGHT + iImprovement <= iAlpha)) - { - uFutilityMargin = (iAlpha - iEval) / 2; - } - else if ((uDepth > ONE_PLY + THREE_QUARTERS_PLY) && - (uDepth <= TWO_PLY + THREE_QUARTERS_PLY) && - (iEval + VALUE_ROOK + iImprovement <= iAlpha)) - { - uFutilityMargin = (iAlpha - iEval) / 2; - } - } + fTryEFPHere = TRUE; } uStage++; ASSERT(x == ctx->sMoveStack.uBegin[ctx->uPly]); @@ -1067,6 +1012,21 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { ASSERT(x >= ctx->sMoveStack.uBegin[ctx->uPly]); + fIsLeftoverMove = FALSE; +#ifdef PERF_COUNTERS + // Reset every iteration, BEFORE the LMR decision + // block (below) can set it for this move -- without + // this, a move that skips the extra-reduction + // branch entirely (e.g. GetLMRReduction's own + // baseline didn't fire) would silently inherit and + // re-report a PRIOR move's stale tier at the + // re-search-check site later in this same + // iteration... except stale across ITERATIONS, not + // within one -- this reset must happen before this + // iteration's own LMR decision, not after. + fThisMoveJointExtraEligible = FALSE; +#endif + // Always fully select the best remaining move, // regardless of tier -- retired the old // NumLeftoverMovesToSelect budget (a depth-indexed @@ -1096,20 +1056,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // -- GOOD_MOVE is meaningless on that axis, so this // never classifies an IID-rescored move as a // leftover (matches pre-retirement behavior). - fIsLeftoverMove = FALSE; -#ifdef PERF_COUNTERS - // Reset every iteration, BEFORE the LMR decision - // block (below) can set it for this move -- without - // this, a move that skips the extra-reduction - // branch entirely (e.g. GetLMRReduction's own - // baseline didn't fire) would silently inherit and - // re-report a PRIOR move's stale tier at the - // re-search-check site later in this same - // iteration... except stale across ITERATIONS, not - // within one -- this reset must happen before this - // iteration's own LMR decision, not after. - fThisMoveJointExtraEligible = FALSE; -#endif if (MOVE_SCORING_IID_RESCORED == pi->eMoveScoringScheme) { SelectBestNoHistory(ctx, x); @@ -1171,7 +1117,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, if (((uLegalMoves >= 2) && fIsLeftoverMove) && (0 != g_uNumHelpersAvailable) && (MOVE_SCORING_IID_RESCORED != pi->eMoveScoringScheme) && - (0 == uFutilityMargin) && + (FALSE == fTryEFPHere) && (uDepth >= ONE_PLY) && (TRUE == g_fCanSplit[uDepth / ONE_PLY - 1]) && (MOVE_COUNT(ctx, ctx->uPly) > 4)) @@ -1189,7 +1135,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, &iBestScore, &mvBest, (x - 1), - iOrigExtend, + iPositionalExtend, uDepth); ASSERT(iAlpha < iBeta); ASSERT((IS_SAME_MOVE(pi->PV[ctx->uPly], mvBest)) || @@ -1288,660 +1234,704 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, (!IS_CHECKING_MOVE(mv) && !InCheck(pos, pos->uToMove))); // Compute per-move extension (as opposed to per-position - // extensions which are represented by iOrigExtend). - iExtend = iOrigExtend; + // extensions which are represented by iPositionalExtend). + iExtend = iPositionalExtend; ComputeMoveExtension(ctx, - iAlpha, - iBeta, - (x - 1), // Note: x==0 if doing mvHash - iEval, - uDepth, - iMoveSee, - fIsLeftoverMove, - &iExtend); + iAlpha, + iBeta, + (x - 1), // Note: x==0 if doing mvHash + iEval, + uDepth, + iMoveSee, + fIsLeftoverMove, + &iExtend); - // Note: MAX_EXTEND_PER_LINE (a flat, non-depth-relative cap on - // total extension spent per line) used to be applied here. - // Removed -- g_uExtensionReduction[] (consumed inside - // ComputeMoveExtension, scaled off g_uIterateDepth) is the - // sole extension-runaway guard now; see root.c's construction - // of that table. - - // See this flag's declaration comment above. Computed - // unconditionally now -- both EFP and LMR gate live - // pruning/reduction decisions on it, not just the - // PERF_COUNTERS instrumentation that used to be its only - // consumer. - // - // Note: no separate (x != 0) check -- x is only ever 0 - // during the TRY_HASH_MOVE stage's single iteration, which - // is always the very first move tried at this node - // (uLegalMoves == 0 going in, per that stage's own ASSERT), - // so uLegalMoves can be at most 1 while x == 0. The - // (uLegalMoves > 1) test below already rules that out. ASSERT((uLegalMoves > 1) ? (x != 0) : TRUE); - fPruneReduceEligible = - (uLegalMoves > 1) && - (NODE_PV != pi->eNodeType) && - (TRUE == fIsLeftoverMove) && - (iExtend <= 0) && - (!IS_ESCAPING_CHECK(mv)) && - (!IS_CHECKING_MOVE(mv)) && - (!fThreat) && - (iAlpha < +NMATE) && - ((!IS_CAPTURE_OR_PROMOTION(mv)) || - (!(ctx->sMoveStack.mvf[x - 1].bvFlags & MVF_SEE_NONNEGATIVE))); + fMoveIsPruneReduceEligible = CanWeReduceOrPruneMove(ctx, + mv, + x, + uLegalMoves, + iExtend, + iAlpha, + fThreat, + fIsLeftoverMove); + fThisMoveLMPPruned = FALSE; + fThisMoveEFPPruned = FALSE; - FLAG fThisMoveLMPPruned = FALSE; - if (TRUE == fPruneReduceEligible) + if (TRUE == fMoveIsPruneReduceEligible) { + // In here we consider: + // - LMP (late move pruning), + // - EFP (extended futility pruning), and + // - LMR (late move reduction). + // + // All of these have their own conditions for firing but they + // are based on dynamic signals about the move. Compute these + // once, here, at the top instead of N times, below. + + // Dynamic move ordering FH attempt and FH/attempts: + ULONG uFHAttempts = 0; + ULONG uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uFHAttempts); + + // The amount of material this move seems to hang. + SCORE iHangCp = EstimateHangingMaterialCp(ctx, mv, (x - 1), + bbMinorAttacksSnapshot, + bbRookAttacksSnapshot); + + // Our 4D prediction table, given: late, hang, + // remaining depth, and gap to alpha will give us a + // pessimistic FH% prediction for the move. Compute + // the buckets needed to look it up. + ULONG uRawDumbB = HangCpBucket(iHangCp); + ULONG uRawDepthB = DepthBucket(uDepth / ONE_PLY); + ULONG uRawGapB = GapCpBucket((SCORE)(iAlpha - iEval)); + ULONG uRawLateB = MoveNumBucket(uLegalMoves); + + // Given the above signals, the (pessimistic) + // predicted FH% of mv in thousandths of a percent. + // i.e. 100 = 100/1000 = 0.1% (1/10th of a percent). + // + // A normal fIsLeftoverMove move has a FH% rate of + // about 0.4% but this is misleading -- the population + // is actually bimodal, split between moves near 0% + // and moves legitimately much higher. + // + // uPredictedFH can also be 0xFFFF, a sentinel value + // meaning "I don't know". This happens when there's + // not enough data (never, anymore) or when the + // buckets are noisy (e.g. "dumb" bucket 1 is crazy, + // no signal). + ULONG uPredictedFH = _JointTablePredictedFHThousandths( + uRawDepthB, uRawDumbB, uRawGapB, uRawLateB); + + // Late move pruning; skip searching under this move? + // Because this is a prune, we only do it when we are + // extremely confident. That said, we do it at any + // remaining depth. The more depth remaining the + // better... fThisMoveLMPPruned = ShouldLatePrune( - iEval, iAlpha, ctx, uDepth, - uLegalMoves, mv, (x - 1), - bbMinorAttacksSnapshot, - bbRookAttacksSnapshot, - NULL); + ctx, + uDepth, + uLegalMoves, + uFHAttempts, + uFHPctThousandths, + uPredictedFH); #ifdef PERF_COUNTERS if (TRUE == fThisMoveLMPPruned) { INC(ctx->sCounters.tree.u64LMPPruned); } #endif - } - // Decide how much (if any) to reduce this move's depth -- - // graded LMR. Gated on the unified fPruneReduceEligible now - // (2026-09-30) instead of its own separately-coded - // checklist -- note this deliberately drops the old - // uLegalMoves > 5 floor (fPruneReduceEligible only requires - // > 1), and newly admits losing captures (fPruneReduceEligible's - // SEE-sign rule) where the old checklist excluded all - // captures unconditionally. uDepth > TWO_PLY stays as LMR's - // own extra threshold on top -- not part of the shared gate. - // Skipped for an LMP-pruned move -- no point grading a - // reduction for a move that's not being searched at all. - if ((uDepth > TWO_PLY) && - (TRUE == fPruneReduceEligible) && - (FALSE == fThisMoveLMPPruned)) - { - ULONG uExtraTier = 0; - INT iLMR = GetLMRReduction(iEval, - iAlpha, - iBeta, - iImprovement, - ctx, - uDepth, - uLegalMoves, - mv, - (x - 1), // Note: x==0 if hash - iExtend, - bbMinorAttacksSnapshot, - bbRookAttacksSnapshot, - &uExtraTier); - if (iLMR < 0) + // Extended futility pruning (EFP); this is an idea + // proposed by Ernst Heinz (Scalable Search in + // Computer Chess, papers). It only operates near the + // horizon between search/qsearch and prunes moves + // when we are far enough below alpha that we think + // normal qsearch/lazy eval pruning will get this line + // in 1-2 ply anyway. We have "enhanced" it in a + // couple of ways: + // - Using iHangCp so that moves that seem to lose + // material (according to the SEE/IID/attack + // tables in eval) are easier to prune. + // - Allowing the uPredictFH (4D table signal) to + // veto EFP when it thinks a quiet move actually + // seems like it might FH here. + if (TRUE == fTryEFPHere) { - ASSERT(iExtend == 0); - iExtend = iLMR; -#ifdef PERF_COUNTERS - uThisMoveJointExtraTier = uExtraTier; - fThisMoveJointExtraEligible = TRUE; - INC(ctx->sCounters.tree.u64JointExtraTries[uExtraTier]); + ASSERT(uDepth >= THREE_QUARTERS_PLY); // or we'd be in qsearch already + SCORE iEstimatedMoveValue = (iEval + iImprovement - iHangCp); + if (((uDepth <= ONE_PLY + THREE_QUARTERS_PLY) && + (iEstimatedMoveValue + VALUE_KNIGHT <= iAlpha)) || + ((uDepth > ONE_PLY + THREE_QUARTERS_PLY) && + (uDepth <= TWO_PLY + THREE_QUARTERS_PLY) && + (iEstimatedMoveValue + VALUE_ROOK <= iAlpha))) + { + fThisMoveEFPPruned = + (uFHAttempts < EFP_FH_MIN_SAMPLES) || + (uFHPctThousandths <= EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS); + fThisMoveEFPPruned = + fThisMoveEFPPruned && + (uPredictedFH == 0xFFFF || uPredictedFH < 100); +#if 0 + if (fThisMoveEFPPruned && + uPredictedFH != 0xFFFF && // JOINT_TABLE_SENTINEL -- fix me + uPredictedFH > 100) + { + // Which branch of EstimateHangingMaterialCp + // actually produced iHangCp. Named exactly + // by eMoveScoringScheme when mv is a + // capture/promotion (the only case that + // routes through ComputeMoveScore at all) -- + // MOVE_SCORING_ESCAPE is the one to watch: + // its capture formula is (mv.pCaptured) - + // (mv.pMoved), with NO IS_KING(mv.pMoved) + // guard the way EstimateHangingMaterialCp's + // own non-capture path has (searchsup.c:300) + // -- a king capturing the checking piece + // (an ordinary, often-forced escape move) + // subtracts VALUE_KING (INFINITY_SCORE) + // as if it were real material, producing a + // huge bogus "hang" estimate. + const char *szHangSource; + if (!IS_CAPTURE_OR_PROMOTION(mv)) + { + szHangSource = "quiet-move heuristic (pawn/king/minor/rook attack, or none)"; + } + else + { + switch (ctx->sPlyInfo[ctx->uPly - 1].eMoveScoringScheme) + { + case MOVE_SCORING_NORMAL: szHangSource = "MOVE_SCORING_NORMAL (SEE/tier)"; break; + case MOVE_SCORING_ESCAPE: szHangSource = "MOVE_SCORING_ESCAPE (raw pCaptured-pMoved, no king guard)"; break; + case MOVE_SCORING_QSEARCH_CHECKS: szHangSource = "MOVE_SCORING_QSEARCH_CHECKS (SEE/tier)"; break; + case MOVE_SCORING_QSEARCH_NO_CHECKS: szHangSource = "MOVE_SCORING_QSEARCH_NO_CHECKS (SEE/tier)"; break; + case MOVE_SCORING_IID_RESCORED: szHangSource = "MOVE_SCORING_IID_RESCORED (real searched score)"; break; + default: szHangSource = "UNKNOWN eMoveScoringScheme"; break; + } + } + + Trace("Board _AFTER_ the move (%s):\n", MoveToIcs(mv)); + DumpPosition(pos); + Trace("iEval = %d\n", iEval); + Trace("iAlpha = %d\n", iAlpha); + Trace("iHangCp = %d (source: %s, pMoved=%d pCaptured=%d pPromoted=%d)\n", + iHangCp, szHangSource, mv.pMoved, mv.pCaptured, mv.pPromoted); + Trace("uGapBucket = %u\n", uRawGapB); + Trace("uRawDepthB = %d\n", uRawDepthB); + Trace("uRawDumbB = %d\n", uRawDumbB); + Trace("uRawLateB = %d\n", uRawLateB); + Trace("PREDICTED FH% = %u / 1000 %\n", uPredictedFH); + Trace("HERE -- EFP says 'prune' be it looks dodgy?\n\n\n"); + } #endif + } } - } - // Extended futility pruning -- per-move checklist. EFP - // pruning a fail-high is unrecoverable (unlike an LMR - // reduction, which only delays discovery), so on top of the - // unified fPruneReduceEligible gate this keeps its own, - // stricter downstream checks: a killer-adjacency exemption - // (ply-1 and ply-3, borrowed from GetLMRReduction), a - // well-evidenced fail-high-history exemption - // (GetMoveFailHighPctThousandths, requiring at least - // EFP_FH_MIN_SAMPLES observations before trusting the - // rate either way). See lmr_testing/RESULTS.md for - // the individual experiments that arrived at this - // checklist. - // - // Gated on fPruneReduceEligible now (2026-09-30) instead of - // its own separately-coded checklist -- this newly admits - // losing captures (fPruneReduceEligible's SEE-sign rule) - // where the old checklist excluded all captures - // unconditionally, matching the population the joint table - // was actually trained on (see lmr_testing/SPEC.md's - // "gate-1" section). - // - // No separate ComputeMoveScore(...) < uFutilityMargin test - // -- verified always true given fPruneReduceEligible, not - // just an ASSERT of convenience: fPruneReduceEligible's - // (!IS_ESCAPING_CHECK(mv)) already rules out - // MOVE_SCORING_ESCAPE (generate.c's _ScoreAllEscapes sets - // MOVE_FLAG_ESCAPING_CHECK unconditionally on every move it - // scores, so a node whose own side is in check can never - // reach here), leaving only MOVE_SCORING_NORMAL live at - // this call site. Under that scheme, DecodeMoveValueEstimate - // (searchsup.c) returns MIN0(iRawValue) for every leftover - // move -- an ordinary quiet leftover's iValue (PSQT only, - // 0..1000, generate.c's _ScoreAllMoves) is always < SORT_ - // THESE_FIRST so it clamps to exactly 0, and a losing - // capture's iValue is already negative (SEE's own result, - // never bumped into the winning-capture SORT_THESE_FIRST - // tier) so it passes through unchanged -- either way <= 0. - // uFutilityMargin is always > 0 here (the truthy check just - // above). So (<=0) < (>0) always, for this call site only -- - // this equivalence is NOT claimed for any other caller of - // ComputeMoveScore. - if ((FALSE == fThisMoveEFPPruned) && - (TRUE == fPruneReduceEligible) && - (uFutilityMargin)) - { - ULONG uFHAttempts = 0; - ULONG uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uFHAttempts); - fThisMoveEFPPruned = - ((uFHAttempts < EFP_FH_MIN_SAMPLES) || - (uFHPctThousandths <= EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS)) && - (!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0])) && - (!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1])) && - ((ctx->uPly < 3) || - (!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) && - !IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1]))); -#ifdef DISABLE_EFP - // Audit-only: everything above still runs (so - // uFutilityMargin, the FH-history lookup, etc. are all - // still exercised/measurable), just never actually - // prunes -- isolates the joint-table LMR's own - // behavior from EFP's single-axis margin pruning. See - // GNUmakefile's DISABLE_EFP comment. - fThisMoveEFPPruned = FALSE; + // If neither LMP nor EFP fired, we are going to search below + // this move. But if the move is very late, we might reduce + // the depth of the search a bit. LMR = late move reductions. + // We never reduce a move such that it drops directly into + // QSearch though; hence the TWO_PLY requirement below. + if ((FALSE == fThisMoveLMPPruned) && + (FALSE == fThisMoveEFPPruned) && + (uDepth > TWO_PLY)) + { + ULONG uExtraTier = 0; + INT iLMR = GetLMRReduction(ctx, + uDepth, + uLegalMoves, + mv, + iExtend, + uFHAttempts, + uFHPctThousandths, + uPredictedFH, + &uExtraTier); + if (iLMR < 0) + { + ASSERT(iExtend == 0); + iExtend = iLMR; +#ifdef PERF_COUNTERS + uThisMoveJointExtraTier = uExtraTier; + fThisMoveJointExtraEligible = TRUE; + INC(ctx->sCounters.tree.u64JointExtraTries[uExtraTier]); #endif - } - if (TRUE == fThisMoveEFPPruned) + } + } + } // LMP, EFP, LMR -- move pruning and reduction + + if ((TRUE == fThisMoveEFPPruned) || + (TRUE == fThisMoveLMPPruned)) { - fAnyMoveEFPPruned = TRUE; + fAnyMovePruned = TRUE; UnmakeMove(ctx, mv); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); + continue; } - else - { + #ifdef PERF_COUNTERS - if (TRUE == fIsLeftoverMove) - { - INC(ctx->sCounters.tree.u64LeftoverTries); - // 2026-09-28: gated on fPruneReduceEligible now, - // not just fIsLeftoverMove -- excludes checks, - // check-escapes, PV nodes, already-extended moves, - // threat nodes, and mate-magnitude alpha up front, - // matching HEAD's own EFP/LMR eligibility gates - // (see that flag's declaration comment). Captures - // and quiet moves both flow into the SAME buckets - // again -- back to measuring the combined signal, - // now with the contaminating populations removed - // instead of split apart to isolate them. Computed - // here (before UnmakeMove) and reused at the - // FH-side increment below -- calling this again - // after UnmakeMove would evaluate the WRONG - // (restored, side-to-move-flipped-back) position. - // 2026-10-03: this whole 4D-collection block is now - // gated on FH_TABLE_COLLECTION rather than riding - // along with PERF_COUNTERS. It is COLLECTION - // instrumentation -- it exists to build - // g_uJointFHThousandths, not to report on a normal - // search -- and CLAUDE.md already says - // FH_TABLE_COLLECTION is deliberately kept out of - // the standard profile precisely because this - // measurably costs nps. This block escaped that - // gating. - // - // It is not a counter increment: it calls - // EstimateHangingMaterialCp per eligible move, which - // does real attack-bitboard work. It was also being - // computed TWICE per move in any build that also had - // a prune gate enabled, since that gate needs the - // same hang bucket -- which inflated the candidate's - // nps deficit in the PRUNE_LIVE self-play match - // (-6.0% vs head) and made that match mildly unfair - // to the candidate. + if (TRUE == fIsLeftoverMove) + { + INC(ctx->sCounters.tree.u64LeftoverTries); + + // 2026-09-28: gated on fPruneReduceEligible now, + // not just fIsLeftoverMove -- excludes checks, + // check-escapes, PV nodes, already-extended moves, + // threat nodes, and mate-magnitude alpha up front, + // matching HEAD's own EFP/LMR eligibility gates + // (see that flag's declaration comment). Captures + // and quiet moves both flow into the SAME buckets + // again -- back to measuring the combined signal, + // now with the contaminating populations removed + // instead of split apart to isolate them. Computed + // here (before UnmakeMove) and reused at the + // FH-side increment below -- calling this again + // after UnmakeMove would evaluate the WRONG + // (restored, side-to-move-flipped-back) position. + // 2026-10-03: this whole 4D-collection block is now + // gated on FH_TABLE_COLLECTION rather than riding + // along with PERF_COUNTERS. It is COLLECTION + // instrumentation -- it exists to build + // g_uJointFHThousandths, not to report on a normal + // search -- and CLAUDE.md already says + // FH_TABLE_COLLECTION is deliberately kept out of + // the standard profile precisely because this + // measurably costs nps. This block escaped that + // gating. + // + // It is not a counter increment: it calls + // EstimateHangingMaterialCp per eligible move, which + // does real attack-bitboard work. It was also being + // computed TWICE per move in any build that also had + // a prune gate enabled, since that gate needs the + // same hang bucket -- which inflated the candidate's + // nps deficit in the PRUNE_LIVE self-play match + // (-6.0% vs head) and made that match mildly unfair + // to the candidate. #ifdef FH_TABLE_COLLECTION - if (TRUE == fPruneReduceEligible) - { - SCORE iHangCp; + if (TRUE == fPruneReduceEligible) + { + SCORE iHangCp; - INC(ctx->sCounters.tree.u64EligibleLeftoverTries); - iHangCp = EstimateHangingMaterialCp( - ctx, mv, x - 1, - bbMinorAttacksSnapshot, - bbRookAttacksSnapshot); - uThisMoveHangBucket = HangCpBucket(iHangCp); - INC(ctx->sCounters.tree.u64HangEstimateTries[uThisMoveHangBucket]); + INC(ctx->sCounters.tree.u64EligibleLeftoverTries); + iHangCp = EstimateHangingMaterialCp( + ctx, mv, x - 1, + bbMinorAttacksSnapshot, + bbRookAttacksSnapshot); + uThisMoveHangBucket = HangCpBucket(iHangCp); + INC(ctx->sCounters.tree.u64HangEstimateTries[uThisMoveHangBucket]); - // "Down axis": how far below alpha is this - // node's own static eval? Same eligibility - // gate as the dumb axis -- iAlpha/iEval are - // both plain node-local SCOREs (not shared, - // mutable pos-> fields like bbRookAttacks - // was), so no snapshot/staleness concern here. - uThisMoveGapBucket = GapCpBucket( - (SCORE)(iAlpha - iEval)); - INC(ctx->sCounters.tree.u64GapEstimateTries[uThisMoveGapBucket]); + // "Down axis": how far below alpha is this + // node's own static eval? Same eligibility + // gate as the dumb axis -- iAlpha/iEval are + // both plain node-local SCOREs (not shared, + // mutable pos-> fields like bbRookAttacks + // was), so no snapshot/staleness concern here. + uThisMoveGapBucket = GapCpBucket( + (SCORE)(iAlpha - iEval)); + INC(ctx->sCounters.tree.u64GapEstimateTries[uThisMoveGapBucket]); - // "Late axis": this move's own 1-based ordinal - // position among moves actually tried here. - uThisMoveNumBucket = MoveNumBucket(uLegalMoves); - INC(ctx->sCounters.tree.u64MoveNumEstimateTries[uThisMoveNumBucket]); + // "Late axis": this move's own 1-based ordinal + // position among moves actually tried here. + uThisMoveNumBucket = MoveNumBucket(uLegalMoves); + INC(ctx->sCounters.tree.u64MoveNumEstimateTries[uThisMoveNumBucket]); - // "Depth axis": remaining search depth at - // this node, before this move's own - // extension/reduction. - uThisMoveDepthBucket = DepthBucket(uDepth / ONE_PLY); - INC(ctx->sCounters.tree.u64DepthEstimateTries[uThisMoveDepthBucket]); + // "Depth axis": remaining search depth at + // this node, before this move's own + // extension/reduction. + uThisMoveDepthBucket = DepthBucket(uDepth / ONE_PLY); + INC(ctx->sCounters.tree.u64DepthEstimateTries[uThisMoveDepthBucket]); - // Joint 4D cell, same four bucket indices, in - // addition to (not instead of) the marginals - // above -- keeps the marginals live this run as - // a cheap cross-check that the joint reduces to - // what we already understand. - INC(ctx->sCounters.tree.u64JointTries - [uThisMoveDepthBucket][uThisMoveHangBucket] - [uThisMoveGapBucket][uThisMoveNumBucket]); - } -#endif - } - if (TRUE == fThisMoveIsCountermoveMatch) - { - INC(ctx->sCounters.tree.u64CMEvidenceTries[uCMEvidenceBucket]); + // Joint 4D cell, same four bucket indices, in + // addition to (not instead of) the marginals + // above -- keeps the marginals live this run as + // a cheap cross-check that the joint reduces to + // what we already understand. + INC(ctx->sCounters.tree.u64JointTries + [uThisMoveDepthBucket][uThisMoveHangBucket] + [uThisMoveGapBucket][uThisMoveNumBucket]); } #endif - // Record the extension/reduction actually applied to - // THIS move, unconditionally -- not just when it's - // negative. Leaving this stale from whatever earlier - // move at this ply last happened to set it let a child - // node checking "was my parent's move reduced" via - // (pi-1)->iExtensionAmount < 0 read a leftover value - // from a completely different sibling move, not the - // one actually played. - pi->iExtensionAmount = iExtend; + } + if (TRUE == fThisMoveIsCountermoveMatch) + { + INC(ctx->sCounters.tree.u64CMEvidenceTries[uCMEvidenceBucket]); + } +#endif + // Record the extension/reduction actually applied to + // THIS move, unconditionally -- not just when it's + // negative. Leaving this stale from whatever earlier + // move at this ply last happened to set it let a child + // node checking "was my parent's move reduced" via + // (pi-1)->iExtensionAmount < 0 read a leftover value + // from a completely different sibling move, not the + // one actually played. + pi->iExtensionAmount = iExtend; - // Compute the next search depth for this move/subtree. - uNextDepth = uDepth - ONE_PLY + iExtend; - if (uNextDepth >= MAX_DEPTH_PER_SEARCH) uNextDepth = 0; - pf->iCumulativeExtend += iExtend; - ASSERT(pf->fAvoidNullmove == FALSE); - if (iBestScore == -INFINITY_SCORE) - { - // First move, full a..b window. - ASSERT(uLegalMoves == 1); + // Compute the next search depth for this move/subtree. + uNextDepth = uDepth - ONE_PLY + iExtend; + if (uNextDepth >= MAX_DEPTH_PER_SEARCH) { + uNextDepth = 0; + } + + pf->iCumulativeExtend += iExtend; + ASSERT(pf->fAvoidNullmove == FALSE); + if (iBestScore == -INFINITY_SCORE) + { + // First move, full a..b window. + ASSERT(uLegalMoves == 1); #ifdef TEST_NULL - // Cost side of the null-move calibration harness' - // try-vs-skip EV estimate: this is the "R=0" - // alternative the null probe (see above) is being - // weighed against -- the real first move's own - // full-depth subtree cost. uLegalMoves==1 guarantees - // this fires exactly once per node. - if (fNullCalibEligible) - { - UINT64 u64NodesBeforeFirstMove = - ctx->sCounters.tree.u64TotalNodeCount; - iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); - uNullCalibFirstMoveNodeCost = - ctx->sCounters.tree.u64TotalNodeCount - - u64NodesBeforeFirstMove; - } - else -#endif + // Cost side of the null-move calibration harness' + // try-vs-skip EV estimate: this is the "R=0" + // alternative the null probe (see above) is being + // weighed against -- the real first move's own + // full-depth subtree cost. uLegalMoves==1 guarantees + // this fires exactly once per node. + if (fNullCalibEligible) + { + UINT64 u64NodesBeforeFirstMove = + ctx->sCounters.tree.u64TotalNodeCount; iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); + uNullCalibFirstMoveNodeCost = + ctx->sCounters.tree.u64TotalNodeCount - + u64NodesBeforeFirstMove; } else +#endif + iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); + } + else + { + // Moves 2..N, try a minimal window search + iScore = -Search(ctx, -iAlpha - 1, -iAlpha, uNextDepth); + if ((iAlpha < iScore) && (iScore < iBeta)) { - // Moves 2..N, try a minimal window search - iScore = -Search(ctx, -iAlpha - 1, -iAlpha, uNextDepth); - if ((iAlpha < iScore) && (iScore < iBeta)) - { - iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); - } + iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); } + } - // Research deeper if history pruning failed - if ((iExtend < 0) && (iScore >= iBeta)) - { + // Research deeper if history pruning failed + if ((iExtend < 0) && (iScore >= iBeta)) + { #ifdef PERF_COUNTERS - // Calibration for the joint-extra-reduction tiers - // above: did the tier this move was assigned (0 == - // GetLMRReduction's baseline only, 1-3 == extra - // plies the table added) actually predict a - // re-search here? If low-predicted-FH tiers show an - // ELEVATED research rate rather than a lower one, - // that's the distribution-shift failure this - // project's history keeps producing, caught here - // (a suite run) instead of in a self-play match. - if (TRUE == fThisMoveJointExtraEligible) - { - INC(ctx->sCounters.tree.u64JointExtraResearch[uThisMoveJointExtraTier]); - } -#endif - uNextDepth -= iExtend; // undo the full reduction, whatever its magnitude - pi->iExtensionAmount = 0; - iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); + // Calibration for the joint-extra-reduction tiers + // above: did the tier this move was assigned (0 == + // GetLMRReduction's baseline only, 1-3 == extra + // plies the table added) actually predict a + // re-search here? If low-predicted-FH tiers show an + // ELEVATED research rate rather than a lower one, + // that's the distribution-shift failure this + // project's history keeps producing, caught here + // (a suite run) instead of in a self-play match. + if (TRUE == fThisMoveJointExtraEligible) + { + INC(ctx->sCounters.tree.u64JointExtraResearch[uThisMoveJointExtraTier]); } +#endif + uNextDepth -= iExtend; // undo the full reduction, whatever its magnitude + pi->iExtensionAmount = 0; + iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); + } + #if defined(PERF_COUNTERS) && defined(JOINT_EXTRA_VERIFY) - // "Silent miss" shadow verification -- only reachable - // when the branch above did NOT fire, i.e. this move's - // extra-reduced score was < beta and so triggered no - // real re-search. Would removing just the EXTRA plies - // (restoring the depth HEAD's own baseline -ONE_PLY - // reduction alone would have used) have found a - // fail-high we're about to silently miss? Full - // coverage, every extra-reduced move that reaches here - // -- see GNUmakefile's JOINT_EXTRA_VERIFY comment for - // why this is calibration-only, never a normal build. - else if ((TRUE == fThisMoveJointExtraEligible) && - (uThisMoveJointExtraTier > 0)) - { - ULONG uShadowDepth = - uNextDepth + (uThisMoveJointExtraTier * ONE_PLY); - SCORE iShadowScore; + // "Silent miss" shadow verification -- only reachable + // when the branch above did NOT fire, i.e. this move's + // extra-reduced score was < beta and so triggered no + // real re-search. Would removing just the EXTRA plies + // (restoring the depth HEAD's own baseline -ONE_PLY + // reduction alone would have used) have found a + // fail-high we're about to silently miss? Full + // coverage, every extra-reduced move that reaches here + // -- see GNUmakefile's JOINT_EXTRA_VERIFY comment for + // why this is calibration-only, never a normal build. + else if ((TRUE == fThisMoveJointExtraEligible) && + (uThisMoveJointExtraTier > 0)) + { + ULONG uShadowDepth = + uNextDepth + (uThisMoveJointExtraTier * ONE_PLY); + SCORE iShadowScore; #ifdef NEUTRALIZE_NULL_BAILOUT - // 2026-09-29: searchsup.c's null-move "history- - // reduced bailout" branch (Tord Romstad idea) reads - // (pi-1)->iExtensionAmount -- OUR OWN reduction on - // mv -- to decide whether to fabricate a "please - // re-search me" placeholder score instead of a - // real one. In a real move-loop search that's - // correct: the fabricated score negates up to - // exactly iBeta+1 one level up, which search.c's - // own re-search-on-fail-high machinery correctly - // interprets and acts on. Our shadow verification - // is a bespoke one-off Search() call with no such - // wrapper, so we'd misread that signal as a - // genuinely-computed fail-high. Temporarily zero - // OUR OWN pi->iExtensionAmount so this one specific - // check can't fire for mv -- everything deeper in - // the shadow subtree still goes through the normal - // move loop (with its wrapper intact) if the same - // mechanism legitimately fires there for some - // other, unrelated reduced move. - INT iSavedExtensionAmount = pi->iExtensionAmount; - pi->iExtensionAmount = 0; + // 2026-09-29: searchsup.c's null-move "history- + // reduced bailout" branch (Tord Romstad idea) reads + // (pi-1)->iExtensionAmount -- OUR OWN reduction on + // mv -- to decide whether to fabricate a "please + // re-search me" placeholder score instead of a + // real one. In a real move-loop search that's + // correct: the fabricated score negates up to + // exactly iBeta+1 one level up, which search.c's + // own re-search-on-fail-high machinery correctly + // interprets and acts on. Our shadow verification + // is a bespoke one-off Search() call with no such + // wrapper, so we'd misread that signal as a + // genuinely-computed fail-high. Temporarily zero + // OUR OWN pi->iExtensionAmount so this one specific + // check can't fire for mv -- everything deeper in + // the shadow subtree still goes through the normal + // move loop (with its wrapper intact) if the same + // mechanism legitimately fires there for some + // other, unrelated reduced move. + INT iSavedExtensionAmount = pi->iExtensionAmount; + pi->iExtensionAmount = 0; #endif - iShadowScore = - -Search(ctx, -iBeta, -iAlpha, uShadowDepth); + iShadowScore = + -Search(ctx, -iBeta, -iAlpha, uShadowDepth); #ifdef NEUTRALIZE_NULL_BAILOUT - pi->iExtensionAmount = iSavedExtensionAmount; + pi->iExtensionAmount = iSavedExtensionAmount; #endif #ifdef SILENT_MISS_TRACE - // UpdatePV is only ever called on the iScore > - // iAlpha branch that does NOT also fail high - // (search.c's main move loop) -- a beta cutoff - // jumps straight to "end" without touching the PV - // array at all, since a fail-high score is only a - // lower bound, not a real line. Since our whole - // diagnostic signal IS a fail-high (iShadowScore >= - // iBeta), WalkPV would always come back empty here - // -- not useful. What DOES get written on a cutoff - // is the hash table (StoreLowerBound, just before - // that same "end" jump), so walk a short hash - // chain instead, the same side-effect-free idiom - // FinishPVTailFromHash (util.c) already uses for - // exactly this purpose. - CHAR szShadowPV[SMALL_STRING_LEN_CHAR] = ""; - { - MOVE mvChain[8]; - ULONG uChainLen = 0; - MOVE mvNext; + // UpdatePV is only ever called on the iScore > + // iAlpha branch that does NOT also fail high + // (search.c's main move loop) -- a beta cutoff + // jumps straight to "end" without touching the PV + // array at all, since a fail-high score is only a + // lower bound, not a real line. Since our whole + // diagnostic signal IS a fail-high (iShadowScore >= + // iBeta), WalkPV would always come back empty here + // -- not useful. What DOES get written on a cutoff + // is the hash table (StoreLowerBound, just before + // that same "end" jump), so walk a short hash + // chain instead, the same side-effect-free idiom + // FinishPVTailFromHash (util.c) already uses for + // exactly this purpose. + CHAR szShadowPV[SMALL_STRING_LEN_CHAR] = ""; + { + MOVE mvChain[8]; + ULONG uChainLen = 0; + MOVE mvNext; - while ((uChainLen < 8) && - (0 != (mvNext = GetPonderMove(pos)).uMove)) - { - strncat(szShadowPV, MoveToSan(mvNext, pos), - ARRAY_LENGTH(szShadowPV) - strlen(szShadowPV) - 1); - strncat(szShadowPV, " ", - ARRAY_LENGTH(szShadowPV) - strlen(szShadowPV) - 1); - mvChain[uChainLen++] = mvNext; - if (FALSE == MakeMove(ctx, mvNext)) - { - uChainLen--; - break; - } - } - while (uChainLen > 0) + while ((uChainLen < 8) && + (0 != (mvNext = GetPonderMove(pos)).uMove)) + { + strncat(szShadowPV, MoveToSan(mvNext, pos), + ARRAY_LENGTH(szShadowPV) - strlen(szShadowPV) - 1); + strncat(szShadowPV, " ", + ARRAY_LENGTH(szShadowPV) - strlen(szShadowPV) - 1); + mvChain[uChainLen++] = mvNext; + if (FALSE == MakeMove(ctx, mvNext)) { - UnmakeMove(ctx, mvChain[--uChainLen]); + uChainLen--; + break; } } + while (uChainLen > 0) + { + UnmakeMove(ctx, mvChain[--uChainLen]); + } + } #endif - // EFP-tier-aligned bucket for the FULLY (base + - // extra) reduced search's actual remaining depth -- - // see _PostReduceDepthBucketEfpAligned's comment. - ULONG uPostReduceDepthBucket = - _PostReduceDepthBucketEfpAligned(uNextDepth); + // EFP-tier-aligned bucket for the FULLY (base + + // extra) reduced search's actual remaining depth -- + // see _PostReduceDepthBucketEfpAligned's comment. + ULONG uPostReduceDepthBucket = + _PostReduceDepthBucketEfpAligned(uNextDepth); - INC(ctx->sCounters.tree.u64JointExtraVerifyTries + INC(ctx->sCounters.tree.u64JointExtraVerifyTries + [uThisMoveJointExtraTier][uPostReduceDepthBucket]); + if (iShadowScore >= iBeta) + { + INC(ctx->sCounters.tree.u64JointExtraVerifySilentMiss [uThisMoveJointExtraTier][uPostReduceDepthBucket]); - if (iShadowScore >= iBeta) + if (iShadowScore == (iBeta + 1)) { - INC(ctx->sCounters.tree.u64JointExtraVerifySilentMiss - [uThisMoveJointExtraTier][uPostReduceDepthBucket]); - if (iShadowScore == (iBeta + 1)) - { - INC(ctx->sCounters.tree.u64JointExtraVerifyNullBailoutSignature - [uThisMoveJointExtraTier]); - } + INC(ctx->sCounters.tree.u64JointExtraVerifyNullBailoutSignature + [uThisMoveJointExtraTier]); + } #ifdef SILENT_MISS_TRACE - // pos currently reflects the position AFTER mv - // (MakeMove already ran earlier this - // iteration, UnmakeMove hasn't run yet) -- - // pi->sPosition (the pre-move parent) is - // DEBUG-only, not available in a release - // build, so print the POST-move FEN plus the - // move in coordinate notation (MoveToIcs takes - // just the move, no pre-move disambiguation - // context needed) rather than SAN. - Trace("SILENT_MISS_TRACE tier=%u bucket=%u " - "uNextDepth=%lu uShadowDepth=%lu " - "alpha=%d beta=%d " - "realScore=%d shadowScore=%d " - "move=%s postMoveFen=\"%s\" " - "shadowRefutation=\"%s\"\n", - uThisMoveJointExtraTier, - uPostReduceDepthBucket, - uNextDepth, uShadowDepth, - iAlpha, iBeta, - iScore, iShadowScore, - MoveToIcs(mv), - PositionToFen(pos), - szShadowPV); + // pos currently reflects the position AFTER mv + // (MakeMove already ran earlier this + // iteration, UnmakeMove hasn't run yet) -- + // pi->sPosition (the pre-move parent) is + // DEBUG-only, not available in a release + // build, so print the POST-move FEN plus the + // move in coordinate notation (MoveToIcs takes + // just the move, no pre-move disambiguation + // context needed) rather than SAN. + Trace("SILENT_MISS_TRACE tier=%u bucket=%u " + "uNextDepth=%lu uShadowDepth=%lu " + "alpha=%d beta=%d " + "realScore=%d shadowScore=%d " + "move=%s postMoveFen=\"%s\" " + "shadowRefutation=\"%s\"\n", + uThisMoveJointExtraTier, + uPostReduceDepthBucket, + uNextDepth, uShadowDepth, + iAlpha, iBeta, + iScore, iShadowScore, + MoveToIcs(mv), + PositionToFen(pos), + szShadowPV); #endif - } } + } #endif #if defined(PERF_COUNTERS) && defined(PRUNE_VERIFY) - // The audited move has now had its ordinary search. If - // it failed high, the deep-prune gate would have thrown - // away a move that actually raised beta -- a silent - // miss, attributed to the band/depth the decision was - // made in. The margin is accumulated so that - // boundary-noise clears (a score that only just beats a - // tight inherited PVS window, JOINT_EXTRA_VERIFY's - // documented false-positive class) can be separated - // from real misses afterwards rather than hand-traced. - if (TRUE == fVerifyFlagged) + // The audited move has now had its ordinary search. If + // it failed high, the deep-prune gate would have thrown + // away a move that actually raised beta -- a silent + // miss, attributed to the band/depth the decision was + // made in. The margin is accumulated so that + // boundary-noise clears (a score that only just beats a + // tight inherited PVS window, JOINT_EXTRA_VERIFY's + // documented false-positive class) can be separated + // from real misses afterwards rather than hand-traced. + if (TRUE == fVerifyFlagged) + { + if (iScore >= iBeta) { - if (iScore >= iBeta) + INC(ctx->sCounters.tree.u64PruneVerifyFailHigh + [uVerifyBand][uVerifyDepthB]); + if (uVerifyBand <= 4) { - INC(ctx->sCounters.tree.u64PruneVerifyFailHigh - [uVerifyBand][uVerifyDepthB]); - if (uVerifyBand <= 4) - { - INC(ctx->sCounters.tree.u64PruneVerifyDynMiss - [uVerifyAttB][uVerifyRateB]); - } - ctx->sCounters.tree.u64PruneVerifyMarginSum - [uVerifyBand][uVerifyDepthB] += - (UINT64)(iScore - iBeta); + INC(ctx->sCounters.tree.u64PruneVerifyDynMiss + [uVerifyAttB][uVerifyRateB]); + } + ctx->sCounters.tree.u64PruneVerifyMarginSum + [uVerifyBand][uVerifyDepthB] += + (UINT64)(iScore - iBeta); #ifdef SILENT_MISS_TRACE - // pos is the POST-move position here (UnmakeMove - // has not run yet) -- same convention as the - // JOINT_EXTRA_VERIFY trace above. - // 2026-10-03: log the DYNAMIC signals alongside - // the static prediction, so a real miss can be - // interrogated for "what would have saved us, and - // at what threshold". Motivated by the band-0 - // miss at - // 6k1/p4p1p/3p2q1/2p1rP2/2P3p1/P7/1P1Qn1PK/3R1R2, - // Qg6 -- a move whose queen only LOOKS hung - // (White is losing too badly to take it; best - // line grabs the e2 knight and White is -14 at - // depth 19), i.e. a genuine structural - // mispredict of the dumbness axis, which no - // amount of sampling conservatism in the static - // table can catch. - // - // fhpct/fhatt: this exact move identity's own - // observed rate and sample count - // (GetMoveFailHighPctThousandths) -- answers - // "what GetMoveFailHigh threshold would have - // vetoed this". - // cm: countermove-table level -- 0 none, 1 slot - // 0 match, 2 slot 1 match, against the move - // played two plies back (the index - // generate.c's _ScoreAllMoves uses). - // hist/cont: accumulated history and - // continuation-history evidence for this - // [piece][to]. - // killer: 1 if this ply's killer, 2 if ply-3's. - { - ULONG uDynAtt = 0; - ULONG uDynPct = GetMoveFailHighPctThousandths(mv, &uDynAtt); - ULONG uCmLevel = 0; - ULONG uHist = g_HistoryCounters[mv.pMoved][mv.cTo]; - ULONG uKiller = 0; + // pos is the POST-move position here (UnmakeMove + // has not run yet) -- same convention as the + // JOINT_EXTRA_VERIFY trace above. + // 2026-10-03: log the DYNAMIC signals alongside + // the static prediction, so a real miss can be + // interrogated for "what would have saved us, and + // at what threshold". Motivated by the band-0 + // miss at + // 6k1/p4p1p/3p2q1/2p1rP2/2P3p1/P7/1P1Qn1PK/3R1R2, + // Qg6 -- a move whose queen only LOOKS hung + // (White is losing too badly to take it; best + // line grabs the e2 knight and White is -14 at + // depth 19), i.e. a genuine structural + // mispredict of the dumbness axis, which no + // amount of sampling conservatism in the static + // table can catch. + // + // fhpct/fhatt: this exact move identity's own + // observed rate and sample count + // (GetMoveFailHighPctThousandths) -- answers + // "what GetMoveFailHigh threshold would have + // vetoed this". + // cm: countermove-table level -- 0 none, 1 slot + // 0 match, 2 slot 1 match, against the move + // played two plies back (the index + // generate.c's _ScoreAllMoves uses). + // hist/cont: accumulated history and + // continuation-history evidence for this + // [piece][to]. + // killer: 1 if this ply's killer, 2 if ply-3's. + { + ULONG uDynAtt = 0; + ULONG uDynPct = GetMoveFailHighPctThousandths(mv, &uDynAtt); + ULONG uCmLevel = 0; + ULONG uHist = g_HistoryCounters[mv.pMoved][mv.cTo]; + ULONG uKiller = 0; - if (ctx->uPly >= 2) - { - MOVE mvPrev = ctx->sPlyInfo[ctx->uPly - 2].mv; - if (0 != mvPrev.uMove) - { - ULONG uCi = MOVE_TO_INDEX(mvPrev); - if (IS_SAME_MOVE(mv, ctx->mvCounter[uCi][0])) uCmLevel = 1; - else if (IS_SAME_MOVE(mv, ctx->mvCounter[uCi][1])) uCmLevel = 2; - } - } - if (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0]) || - IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1])) - { - uKiller = 1; - } - else if ((ctx->uPly >= 3) && - (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) || - IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1]))) + if (ctx->uPly >= 2) + { + MOVE mvPrev = ctx->sPlyInfo[ctx->uPly - 2].mv; + if (0 != mvPrev.uMove) { - uKiller = 2; + ULONG uCi = MOVE_TO_INDEX(mvPrev); + if (IS_SAME_MOVE(mv, ctx->mvCounter[uCi][0])) uCmLevel = 1; + else if (IS_SAME_MOVE(mv, ctx->mvCounter[uCi][1])) uCmLevel = 2; } - Trace("PRUNEMISS pred=%u band=%u depthb=%u " - "alpha=%d beta=%d score=%d margin=%d " - "fhpct=%u fhatt=%u cm=%u hist=%u killer=%u " - "mv=%s postfen=%s\n", - uVerifyPred, uVerifyBand, uVerifyDepthB, - iAlpha, iBeta, iScore, (iScore - iBeta), - uDynPct, uDynAtt, uCmLevel, uHist, uKiller, - MoveToIcs(mv), PositionToFen(pos)); } -#endif + if (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0]) || + IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1])) + { + uKiller = 1; + } + else if ((ctx->uPly >= 3) && + (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) || + IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1]))) + { + uKiller = 2; + } + Trace("PRUNEMISS pred=%u band=%u depthb=%u " + "alpha=%d beta=%d score=%d margin=%d " + "fhpct=%u fhatt=%u cm=%u hist=%u killer=%u " + "mv=%s postfen=%s\n", + uVerifyPred, uVerifyBand, uVerifyDepthB, + iAlpha, iBeta, iScore, (iScore - iBeta), + uDynPct, uDynAtt, uCmLevel, uHist, uKiller, + MoveToIcs(mv), PositionToFen(pos)); } - } #endif - UnmakeMove(ctx, mv); - pf->iCumulativeExtend -= iExtend; - ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); - if (WE_SHOULD_STOP_SEARCHING) - { - iBestScore = iScore; - goto end; } + } +#endif + UnmakeMove(ctx, mv); + pf->iCumulativeExtend -= iExtend; + ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); + if (WE_SHOULD_STOP_SEARCHING) + { + iBestScore = iScore; + goto end; + } - // Check results - ASSERT(iBestScore <= iAlpha); - ASSERT(iAlpha < iBeta); - if (iScore > iBestScore) - { - iBestScore = iScore; - mvBest = mv; - pi->mvBest = mv; + // Check results + ASSERT(iBestScore <= iAlpha); + ASSERT(iAlpha < iBeta); + if (iScore > iBestScore) + { + iBestScore = iScore; + mvBest = mv; + pi->mvBest = mv; - if (iScore > iAlpha) + if (iScore > iAlpha) + { + if (iScore >= iBeta) { - if (iScore >= iBeta) - { #ifdef PERF_COUNTERS - if (TRUE == fIsLeftoverMove) - { - INC(ctx->sCounters.tree.u64LeftoverFH); - // Gated on fPruneReduceEligible now, - // matching the tries side -- must - // match exactly, or this would reuse a - // stale bucket left over from a - // different (eligible) move for one - // that never got a bucket computed - // this iteration. - if (TRUE == fPruneReduceEligible) - { - INC(ctx->sCounters.tree.u64EligibleLeftoverFH); - INC(ctx->sCounters.tree.u64HangEstimateFH[uThisMoveHangBucket]); - INC(ctx->sCounters.tree.u64GapEstimateFH[uThisMoveGapBucket]); - INC(ctx->sCounters.tree.u64MoveNumEstimateFH[uThisMoveNumBucket]); - INC(ctx->sCounters.tree.u64DepthEstimateFH[uThisMoveDepthBucket]); - INC(ctx->sCounters.tree.u64JointFH - [uThisMoveDepthBucket][uThisMoveHangBucket] - [uThisMoveGapBucket][uThisMoveNumBucket]); - } - } - if (TRUE == fThisMoveIsCountermoveMatch) + if (TRUE == fIsLeftoverMove) + { + INC(ctx->sCounters.tree.u64LeftoverFH); + // Gated on fPruneReduceEligible now, + // matching the tries side -- must + // match exactly, or this would reuse a + // stale bucket left over from a + // different (eligible) move for one + // that never got a bucket computed + // this iteration. + if (TRUE == fMoveIsPruneReduceEligible) { - INC(ctx->sCounters.tree.u64CMEvidenceFH[uCMEvidenceBucket]); + INC(ctx->sCounters.tree.u64EligibleLeftoverFH); + INC(ctx->sCounters.tree.u64HangEstimateFH[uThisMoveHangBucket]); + INC(ctx->sCounters.tree.u64GapEstimateFH[uThisMoveGapBucket]); + INC(ctx->sCounters.tree.u64MoveNumEstimateFH[uThisMoveNumBucket]); + INC(ctx->sCounters.tree.u64DepthEstimateFH[uThisMoveDepthBucket]); + INC(ctx->sCounters.tree.u64JointFH + [uThisMoveDepthBucket][uThisMoveHangBucket] + [uThisMoveGapBucket][uThisMoveNumBucket]); } + } + if (TRUE == fThisMoveIsCountermoveMatch) + { + INC(ctx->sCounters.tree.u64CMEvidenceFH[uCMEvidenceBucket]); + } #endif - // Update history and killers list and store in - // the transposition table. - UpdateDynamicMoveOrdering(ctx, - uDepth, - mv, - iScore, - x); - StoreLowerBound(mv, pos, iScore, uDepth, fThreat); + // Update history and killers list and store in + // the transposition table. + UpdateDynamicMoveOrdering(ctx, + uDepth, + mv, + iScore, + x); + StoreLowerBound(mv, pos, iScore, uDepth, fThreat); - // A fail-high capturing a non-pawn piece is - // search-proven evidence that piece was en - // prise -- but the victim belongs to the - // *other* side, i.e. whoever is to move at - // ctx->uPly - 1 (ply parity), not here -- - // "despite the move you're about to make, - // this piece stays in trouble." Skip near - // mate: a fail-high there means the whole - // subtree is winning regardless of this - // particular piece, not that it was - // specifically hanging. - if (mv.pCaptured && !IS_PAWN(mv.pCaptured) && - (iBeta < +NMATE)) - { - ASSERT(ctx->uPly > 0); - RecordEnprisePieceAtPly(ctx, ctx->uPly - 1, - mv.cTo); - } - KEEP_TRACK_OF_FIRST_MOVE_FHs(uLegalMoves == 1); - ASSERT(SanityCheckMoves(ctx, x, VERIFY_BEFORE)); - goto end; - } - else + // A fail-high capturing a non-pawn piece is + // search-proven evidence that piece was en + // prise -- but the victim belongs to the + // *other* side, i.e. whoever is to move at + // ctx->uPly - 1 (ply parity), not here -- + // "despite the move you're about to make, + // this piece stays in trouble." Skip near + // mate: a fail-high there means the whole + // subtree is winning regardless of this + // particular piece, not that it was + // specifically hanging. + if (mv.pCaptured && !IS_PAWN(mv.pCaptured) && + (iBeta < +NMATE)) { + ASSERT(ctx->uPly > 0); + RecordEnprisePieceAtPly(ctx, ctx->uPly - 1, + mv.cTo); + } + KEEP_TRACK_OF_FIRST_MOVE_FHs(uLegalMoves == 1); + ASSERT(SanityCheckMoves(ctx, x, VERIFY_BEFORE)); + goto end; + } + else + { #ifdef PERF_COUNTERS - if (TRUE == fIsLeftoverMove) - { - INC(ctx->sCounters.tree.u64LeftoverAlpha); - } -#endif - // PV move... - UpdatePV(ctx, mv); - iAlpha = iScore; + if (TRUE == fIsLeftoverMove) + { + INC(ctx->sCounters.tree.u64LeftoverAlpha); } +#endif + // PV move... + UpdatePV(ctx, mv); + iAlpha = iScore; } } } @@ -1982,22 +1972,10 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // Not checkmate/stalemate; store the result of this search in the // hash table. - // - // Ernst Heinz's warning (the book EFP is from): a node whose search - // depended on alpha/beta via forward pruning (a move skipped - // entirely, not just reduced -- LMR still searches its move, just - // shallower, so it isn't affected) cannot have its result stored as - // an exact score or a sound upper bound. If EFP skipped a move here - // without searching it, that move might have actually been the - // best one -- the true value could be *higher* than what we - // computed, in either case. An "exact" claim needs to know nothing - // better existed; an upper-bound claim needs the true value to be - // <= what we stored, both of which a skipped-but-possibly-better - // move can violate. mvBest/iBestScore (when found) remains a sound - // LOWER bound regardless -- we have a real line proving the - // position is at least this good -- so that's the most this node - // can honestly claim once fAnyMoveEFPPruned is set. - if ((iAlpha != iInitialAlpha) && (FALSE == fAnyMoveEFPPruned)) + + // Do not store exact hash scores for nodes where we pruned (EFP/LMP) + // some moves. + if (iAlpha != iInitialAlpha) { ASSERT(mvBest.uMove != 0); if (!IS_CAPTURE_OR_PROMOTION(mvBest)) @@ -2008,24 +1986,25 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, iBestScore, 0); } - StoreExactScore(mvBest, pos, iBestScore, uDepth, fThreat, ctx->uPly); - } - else if ((iAlpha != iInitialAlpha) && (TRUE == fAnyMoveEFPPruned)) - { - // Downgrade: mvBest proves a real achieving line, so this is a - // sound lower bound, just not provably exact. - ASSERT(mvBest.uMove != 0); - if (!IS_CAPTURE_OR_PROMOTION(mvBest)) + if (FALSE == fAnyMovePruned) { - UpdateDynamicMoveOrdering(ctx, - uDepth, - mvBest, - iBestScore, - 0); + StoreExactScore(mvBest, + pos, + iBestScore, + uDepth, + fThreat, + ctx->uPly); + } + else + { + StoreLowerBound(mvBest, + pos, + iBestScore, + uDepth, + fThreat); } - StoreLowerBound(mvBest, pos, iBestScore, uDepth, fThreat); } - else if (FALSE == fAnyMoveEFPPruned) + else if (FALSE == fAnyMovePruned) { // IDEA: "I am very well aware of the fact, that the scores // you get back outside of the window, are not trustable at @@ -2066,7 +2045,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // this line report "PV failed high/low = 106.2% of PV nodes" -- // more surprises than nodes of that type. if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore) && - (TRUE == fNodeTypedThisVisit)) + (TRUE == fNoteTypedThisCall)) { // 2026-10-03: compare against iInitialAlpha, NOT iAlpha. // search.c:2048 raises iAlpha IN PLACE as moves improve the @@ -2784,25 +2763,6 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, uQsearchCheckExtension = 2; INC(ctx->sCounters.extension.uQExtend); } - // 2026-10-03: was `= uQsearchCheckExtension` (0 or 2), a - // UNIT MISMATCH. uQsearchCheckExtension counts qsearch - // CHECK PLIES (it is added to pf->uQsearchCheckDepth), but - // iExtensionAmount is ONE_PLY-scaled everywhere else in the - // codebase -- root.c writes HALF_PLY (32), split.c writes - // iLMR (a negative multiple of 64), search.c writes - // iExtend. Writing 2 here meant 2/64 of a ply in this - // field's own units. - // - // Benign as it stood, because both readers only test sign - // (searchsup.c's Tord Romstad null-move bailout tests - // `< 0`; search.c's EFP gate tests `<= 0`) and 2 behaves - // like any positive extension. But this field is already a - // documented stale-state hazard (see the "Record the - // extension/reduction actually applied to THIS move" - // comment in the main move loop), so a latent 32x error - // sitting in it is not worth keeping. Scaled to the field's - // real units; the sign, and therefore every current - // reader's behavior, is unchanged. ctx->sPlyInfo[ctx->uPly].iExtensionAmount = (INT)(uQsearchCheckExtension * ONE_PLY); } @@ -2852,23 +2812,6 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, iEval = Eval(ctx, iAlpha, iBeta, &rgiPositional); ASSERT(ctx->sPlyInfo[ctx->uPly].uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT); - // 2026-10-03: publish it. This function used to keep the - // score purely local, so pi->iEval retained whatever a - // PREVIOUS visit to this ply slot left behind for every - // in-check qsearch node. The one reader - // (iImprovement, from sPlyInfo[uPly-2].iEval in Search) - // should never see a qsearch ply given that Search sits - // strictly above qsearch -- but that invariant is held - // by layering alone, and iImprovement feeds null-move - // and EFP decisions, so a stale value here is not worth - // leaving around once we have paid for the Eval anyway. - // - // NOTE this is still only set when Eval actually RAN. - // The common in-check path never calls Eval at all (no - // stand-pat is offered, so there is nothing to compute - // it for), and those nodes still leave pi->iEval stale. - // Making that safe properly means invalidating it on - // entry, which is a separate change. ctx->sPlyInfo[ctx->uPly].iEval = iEval; fEvalComputed = TRUE; } @@ -2966,8 +2909,6 @@ Routine description: moves that stabilize the position -- once it is quiescence (quiet) we will run a static evaluation on it and return the score. - TODO: experiment with probing and storing in the hash table here. - Parameters: SEARCHER_THREAD_CONTEXT *ctx : the searcher context @@ -3099,6 +3040,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, ASSERT(!InCheck(pos, pos->uToMove)); pi->iEval = iEval = iBestScore = Eval(ctx, iAlpha, iBeta, &rgiPositional); + // 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 @@ -3112,54 +3054,23 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, // its other uses: deciding whether a *whole line* looks forcing // enough to justify extra qsearch depth/breadth), not gating // per-node stand-pat correctness. - { - SCORE iInTrouble = ValueOfMaterialInTroubleDespiteMove(ctx, pos->uToMove); + SCORE iInTrouble = ValueOfMaterialInTroubleDespiteMove(ctx, pos->uToMove); - if (0 != iInTrouble) - { - // REVERTED 2026-10-03, same day it was tried. The change was - // `iBestScore = MAX(iAlpha, iEval - iInTrouble)`, motivated by - // a real observation: the beta test below lives only in the - // else-branch, so a node with iEval far ABOVE beta and hanging - // material returns a fail-low instead of cutting off. - // - // But the fix was unsound AND expensive, for the same reason. - // Work the signs: a parent searching this node passed window - // (-iBeta_p, -iAlpha_p), so iAlpha here == -iBeta_p. The parent - // fails high iff -V >= iBeta_p, i.e. iff V <= iAlpha. So - // returning exactly iAlpha is not a crude placeholder -- it is - // precisely the value that HANDS THE PARENT ITS CUTOFF. That is - // the standard fail-low idiom: refuse the stand-pat, start at - // alpha, and if nothing beats it report a fail-low. - // - // Returning MAX(iAlpha, iEval - iInTrouble) instead reports a - // better-than-alpha value, denying the parent that cutoff -- - // measured as ~0.45 ply of lost search depth in self-play - // (candidate 13.10 vs 13.55 for the same tree without this - // change, against a comparable head). And it asserts the node - // is worth at least iEval - iInTrouble WITHOUT SEARCHING - // ANYTHING, i.e. a stand-pat credit at a discount -- exactly - // what this branch exists to refuse. If every move from here is - // terrible, the true value can be far below that. - // - // The original observation is still valid and still unfixed: a - // sound fix needs a lower bound the node has actually EARNED, - // which iEval - iInTrouble is not. A beta test on a - // search-established bound would be the way, not a static one. - iBestScore = iAlpha; - } - else + if (0 != iInTrouble) + { + iBestScore = iAlpha; + } + else + { + ctx->sSearchFlags.fCouldStandPat[pos->uToMove] = TRUE; + if (iBestScore > iAlpha) { - ctx->sSearchFlags.fCouldStandPat[pos->uToMove] = TRUE; - if (iBestScore > iAlpha) + iAlpha = iBestScore; + ASSERT(ctx->sPlyInfo[ctx->uPly].PV[ctx->uPly].uMove == 0); + ASSERT(pi->mvBest.uMove == 0); + if (iBestScore >= iBeta) { - iAlpha = iBestScore; - ASSERT(ctx->sPlyInfo[ctx->uPly].PV[ctx->uPly].uMove == 0); - ASSERT(pi->mvBest.uMove == 0); - if (iBestScore >= iBeta) - { - goto end; - } + goto end; } } } |
