diff options
| author | Scott Gasch <[email protected]> | 2026-10-14 10:49:20 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-14 10:49:20 -0700 |
| commit | 3d54c2cc82366040f00d5901f80dbe4e9d7bfe10 (patch) | |
| tree | e143c4db3a541feb9aa88fac1ef78a9ff77117e0 /src/search.c | |
| parent | dad4620a92f51a1d0825fc99fa98fd2b16c0f280 (diff) | |
Loosen LMP pruning thresholds; trim stale comments/dead code in search.c
Raise uLMPStaticMaxPctThousandths across the board (200/200/150/75/50/
25/12/6/3/1/0 -> 325/300/250/200/150/100/80/25/6/1/0) and
LMP_DYNAMIC_MAX_PCT_THOUSANDTHS (1 -> 10000), letting the search go
deeper/faster by pruning more leftover moves per the dynamic FH%
evidence tables rather than the old hand-picked caps. Partial self-play
testing (two ~1k-game SPRT runs, LLR trending up both times, stopped
early) supports this but hasn't yet crossed +5 Elo.
Also: drop movesup.c's already-commented-out FLEE_BONUS selection-time
nudge (superseded by the evidence-gated countermove work), remove
search.c's now-stale dated commentary and two unused EFP flags
(fTryEFPHere/fThisMoveEFPPruned), and fix searchsup.c's beta-table
sentinel to use the named JOINT_TABLE_SENTINEL instead of a bare
0xFFFF.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/search.c')
| -rw-r--r-- | src/search.c | 511 |
1 files changed, 110 insertions, 401 deletions
diff --git a/src/search.c b/src/search.c index 0a32dcc..3abb44b 100644 --- a/src/search.c +++ b/src/search.c @@ -510,62 +510,12 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ULONG uNullR; ULONG uLegalMoves = 0; FLAG fIsLeftoverMove = FALSE; - // Stamped once below, right after this node's own Eval() call and - // before any MakeMove in the move loop changes pos's live - // signature out from under it -- see EstimateHangingMaterialCp's - // comment (above _CMEvidenceBucket) for why this matters: bbMinor - // Attacks/bbRookAttacks are only trustworthy when they were - // written by a full, non-lazy Eval() call for THIS exact position. FLAG fParentAttackTablesAreFresh; - // 2026-09-28: snapshotted into LOCAL (stack) storage at the same - // point fParentAttackTablesAreFresh is captured -- pos->bbMinor - // Attacks/bbRookAttacks themselves are NOT safe to read later in - // the move loop, because every recursive Search() call made while - // trying an earlier sibling move also calls Eval() on ITS OWN - // position, overwriting these same shared pos-> fields, and - // UnmakeMove does not restore them (they're eval scratch space, - // not real position state -- unlike u64NonPawnSig/u64PawnSig, - // which UnmakeMove DOES restore, which is exactly why the - // signature check above kept reporting "fresh" long after the - // underlying bitboards had been clobbered by a completely - // unrelated node deep in an earlier sibling's subtree). Confirmed - // empirically: every single real fail-high sampled in the queen- - // attacked-by-rook bucket turned out to have NO actual rook - // attacking the destination square once checked by hand -- this - // is why. Local copies can't be touched by recursion. BITBOARD bbMinorAttacksSnapshot[2]; BITBOARD bbRookAttacksSnapshot[2]; - // 2026-09-30: single shared "would we ever consider pruning or - // reducing this move" eligibility gate -- the unified gate EFP and - // LMR both consult now (formerly two separately-coded, slightly - // different checklists), plus the losing-capture admission rule - // the FH-table work has been testing (the old, separate gates - // excluded ALL captures/promotions unconditionally; this - // deliberately admits losing ones, via the SEE sign generate.c - // already cached on the move, matching the population the joint - // table was actually trained on -- see lmr_testing/SPEC.md's - // "gate-1" section). Computed ONCE per move, unconditionally (not - // just under PERF_COUNTERS -- it now gates live search behavior in - // every build profile), so a move that should never actually be - // pruned or reduced (a check, a check-escape, a PV node, an - // already-extended move, a node-wide threat, a mate-magnitude - // alpha) is excluded up front rather than patched around - // downstream. Note this deliberately does NOT include LMR's old - // uLegalMoves > 5 floor -- that requirement is dropped as part of - // 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 fMoveIsPruneReduceEligible = FALSE; ULONG uRawDepthB; #ifdef MP - // 2026-10-13: perf hoist -- every term here is fixed for this - // whole node (uDepth is a parameter, never reassigned anywhere in - // Search(); pi->eMoveScoringScheme is set once, before the move - // loop, and never touched again in our build profile -- DO_IID, - // the only other writer mid-node, isn't compiled in). Computed - // once below (PREPARE_TO_TRY_MOVES) instead of re-deriving the - // same division/array-lookup/subtraction on every single - // move-loop iteration's split-eligibility check. FLAG fNodeSplitEligible; #endif #ifdef MOVE_ORDERING_EVIDENCE_COLLECTION @@ -577,12 +527,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ULONG uThisMoveGapBucket = 0; ULONG uThisMoveNumBucket = 0; ULONG uThisMoveDepthBucket = 0; - // 2026-09-28: which joint-extra-reduction tier (0 == none, the - // GetLMRReduction baseline only) fired for THIS move, if any -- - // stashed at decision time (LMR call site) and consulted again - // later, at the re-search-on-fail-high check, purely for the - // calibration counters below. Never influences search behavior by - // itself. ULONG uThisMoveJointExtraTier = 0; FLAG fThisMoveJointExtraEligible = FALSE; #endif @@ -593,8 +537,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, FLAG fIsDraw; ULONG uStage = TRY_HASH_MOVE; ULONG u; - FLAG fTryEFPHere = FALSE; - FLAG fThisMoveEFPPruned; FLAG fThisMoveLMPPruned; SCORE iMoveSee; #ifdef TEST_NULL @@ -617,9 +559,10 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { goto end; } - DTEnterNode(ctx, uDepth, FALSE, iAlpha, iBeta); pi->eMoveScoringScheme = MOVE_SCORING_NORMAL; + + // Note: pi->fInCheck set by CommonSearchInit. ASSERT((IS_CHECKING_MOVE(mvLast) && (TRUE == pi->fInCheck)) || (!IS_CHECKING_MOVE(mvLast) && (FALSE == pi->fInCheck))); @@ -749,26 +692,14 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, #endif } - // Maybe do nullmove pruning + // Run Eval() at every node. pi->iEval = iEval = Eval(ctx, iAlpha, iBeta, &(pi->iPositional)); SCORE iImprovement = 0; if (ctx->uPly > 1) { iImprovement = (iEval - ctx->sPlyInfo[ctx->uPly - 2].iEval); } - // 2026-10-13: perf hoist -- iImprovement is fixed for this whole - // node (set once above, never reassigned anywhere in the move - // loop), so its bucket is too. Was being recomputed inside the - // per-move pruning block for every eligible move; compute once - // here instead and reuse. Search() is the hottest function in the - // engine -- this repeated work was pure waste on a provably - // node-constant value. ULONG uRawImproveB = ImproveBucket(iImprovement); - // 2026-10-10: opponent's own eval-trend, for the beta-side static- - // NM table below (g_uBetaFHThousandths) -- needs the move before - // the mover's own, hence uPly - 1/uPly - 3 rather than iImprovement's - // uPly - 2, and its own, deeper eligibility guard (uPly - 3 must be - // a valid index). SCORE iEnemyImprovement = 0; if (ctx->uPly > 2) { @@ -794,7 +725,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, bbMinorAttacksSnapshot[WHITE] = bbMinorAttacksSnapshot[BLACK] = 0; bbRookAttacksSnapshot[WHITE] = bbRookAttacksSnapshot[BLACK] = 0; } - GENERATE_NO_MOVES; #ifdef TEST_NULL @@ -873,112 +803,87 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } #endif - // 2026-10-10: static (score-only, no verification search) null- - // move cutoff -- if the eval already clears beta by a wide enough - // margin that the beta-side FH% table (g_uBetaFHThousandths) - // predicts a real null-move search would fail high here with - // >95% confidence, skip the verification search entirely and - // take the cutoff on the table's say-so. Same eligibility - // (not in check, non-PV, not already avoiding null, no fThreat) - // as the real null-move check just below, plus iEval > iBeta - // (the table's own domain) and iBeta < +NMATE (don't short- - // circuit a mate score on a magnitude-based prediction). + // Pruning on beta. if ((FALSE == fSkipNull) && (FALSE == fThreat) && (FALSE == pi->fInCheck) && (NODE_PV != pi->eNodeType) && - (iBeta < +NMATE) && - (iEval > iBeta)) + (iBeta < +NMATE)) { - ULONG uStaticNMDepthB = DepthBucket(uDepth / ONE_PLY); - ULONG uRawEnemyImproveB = ImproveBucket(iEnemyImprovement); + FLAG fSkipNullForLowFH = FALSE; + + // Static nullmove pruning / reverse futility pruning? + if (iEval > iBeta) + { + ULONG uStaticNMDepthB = DepthBucket(uDepth / ONE_PLY); + ULONG uRawEnemyImproveB = ImproveBucket(iEnemyImprovement); - INC(ctx->sCounters.tree.u64QuickNullMoves); - ULONG uBetaPredictedFHThousandths = _BetaTablePredictedFHThousandths( - uStaticNMDepthB, + INC(ctx->sCounters.tree.u64QuickNullMoves); + ULONG uBetaPredictedFHThousandths = _BetaTablePredictedFHThousandths( + uStaticNMDepthB, GapBetaBucket((SCORE)(iEval - iBeta)), uRawImproveB, uRawEnemyImproveB); - if ((uBetaPredictedFHThousandths != 0xFFFF) && - (uBetaPredictedFHThousandths > 95000)) - { - INC(ctx->sCounters.tree.u64QuickNullMoveSuccess); - iBestScore = iBeta; - goto end; - } - } + if ((uBetaPredictedFHThousandths != 0xFFFF) && + (uBetaPredictedFHThousandths > 95000)) + { + INC(ctx->sCounters.tree.u64QuickNullMoveSuccess); - // 2026-10-13: should we even bother trying null-move here? Opposite - // direction from the static-NM block above (iEval <= iAlpha, not - // iEval > iBeta) -- reuse the alpha/leftover-move table - // (g_uJointFHThousandths via _JointTablePredictedFHThousandths) as - // a proxy for "would a free tempo even reach beta here," using - // hang=0 ("nothing hung" -- a made-up placeholder, not a real - // per-move estimate; there is no move here, just the node itself) - // and late=1 ("early move" proxy, same reasoning). Computes its - // own depth bucket locally (same pattern as the static-NM block's - // uStaticNMDepthB above) -- the function-scope uRawDepthB isn't - // assigned until the move loop below, so reading it here would be - // reading an uninitialized value; a stale comment previously - // claimed otherwise. iImprovement is the same per-node value used - // everywhere else. Unlike the static-NM cutoff, this is EV-neutral: - // if wrong, the real move loop below still runs and finds the - // correct score -- we only forgo the cheap null-move probe on the - // rare occasion it would have succeeded. - FLAG fSkipNullForLowFH = FALSE; + // TODO: SF returns a mix of iBeta and iEval here. Test + // this. + iBestScore = iBeta; + goto end; + } + } #if (NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS > 0) - if (iEval <= iAlpha) - { - ULONG uNullSkipDepthB = DepthBucket(uDepth / ONE_PLY); - ULONG uRawGapAlphaB = GapCpBucket((SCORE)(iAlpha - iEval)); - USHORT uAlphaPredictedFH = _JointTablePredictedFHThousandths( - uNullSkipDepthB, + else if (iEval <= iAlpha) + { + ULONG uNullSkipDepthB = DepthBucket(uDepth / ONE_PLY); + ULONG uRawGapAlphaB = GapCpBucket((SCORE)(iAlpha - iEval)); + USHORT uAlphaPredictedFH = _JointTablePredictedFHThousandths( + uNullSkipDepthB, 0, // hang bucket: assume nothing hung uRawGapAlphaB, 1, // late bucket: "early move" proxy uRawImproveB); - if ((uAlphaPredictedFH != 0xFFFF) && - (uAlphaPredictedFH < NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS)) - { - fSkipNullForLowFH = TRUE; - INC(ctx->sCounters.tree.u64NullMoveSkippedLowFH); + if ((uAlphaPredictedFH != 0xFFFF) && + (uAlphaPredictedFH < NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS)) + { + fSkipNullForLowFH = TRUE; + INC(ctx->sCounters.tree.u64NullMoveSkippedLowFH); + } } - } #endif - - if (!fSkipNull && - !fThreat && - (FALSE == fSkipNullForLowFH) && - WeShouldTryNullmovePruning(ctx, + if ((FALSE == fSkipNullForLowFH) && + WeShouldTryNullmovePruning(ctx, + iAlpha, + iBeta, + iEval, + iImprovement, + uNextDepth)) + { + ULONG uNullRBucket = uNullR / ONE_PLY; + if (uNullRBucket >= NULL_R_BUCKETS) + { + uNullRBucket = NULL_R_BUCKETS - 1; + } + INC(ctx->sCounters.tree.u64NullMoveTriesByR[uNullRBucket]); + if (TryNullmovePruning(ctx, + &fThreat, iAlpha, iBeta, - iEval, - iImprovement, - uNextDepth)) - { - ULONG uNullRBucket = uNullR / ONE_PLY; - if (uNullRBucket >= NULL_R_BUCKETS) - { - uNullRBucket = NULL_R_BUCKETS - 1; - } -#ifdef PERF_COUNTERS - INC(ctx->sCounters.tree.u64NullMoveTriesByR[uNullRBucket]); -#endif - if (TryNullmovePruning(ctx, - &fThreat, - iAlpha, - iBeta, - uNextDepth, - &iPositionalExtend, - &iScore, - uNullRBucket)) - { - if (iScore > iBeta) + uNextDepth, + &iPositionalExtend, + &iScore, + uNullRBucket)) { - StoreLowerBound(mvHash, pos, iScore, uDepth, FALSE); + if (iScore > iBeta) + { + StoreLowerBound(mvHash, pos, iScore, uDepth, FALSE); + } + iBestScore = iScore; + goto end; } - iBestScore = iScore; - goto end; } } @@ -989,6 +894,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, iPositionalExtend += HALF_PLY; INC(ctx->sCounters.extension.uMateThreat); } + pf->fAvoidNullmove = FALSE; // Main search loop, try moves under this position. Before we get // into the move loop, save the extensions merited by this @@ -996,7 +902,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // Also clear the avoid null bit in the search flags -- we were // either told to avoid it or not but there is no need to avoid it // for the rest of the line... - pf->fAvoidNullmove = FALSE; do { ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); @@ -1029,19 +934,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, GenerateMoves(ctx, mvHash, GENERATE_ESCAPES); if (MOVE_COUNT(ctx, ctx->uPly)) { - // TODO: Experiment with removing these extensions. -#if 0 - if (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1) - { - iPositionalExtend += QUARTER_PLY; - INC(ctx->sCounters.extension.uMultiCheck); - } - else if (NUM_KING_MOVES(ctx, ctx->uPly) == 0) - { - iPositionalExtend += QUARTER_PLY; - INC(ctx->sCounters.extension.uNoLegalKingMoves); - } -#endif if (ONE_LEGAL_MOVE(ctx, ctx->uPly) && CountKingSafetyDefects(pos, pos->uToMove) > 2) { @@ -1061,14 +953,13 @@ 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. - iPositionalExtend = MIN(iPositionalExtend, ONE_PLY); + ASSERT(iPositionalExtend <= ONE_PLY); // fall through case PREPARE_TO_TRY_MOVES: ASSERT(x == ctx->sMoveStack.uBegin[ctx->uPly]); ASSERT((uLegalMoves == 0) || ((uLegalMoves == 1) && (mvHash.uMove))); -#ifdef DO_IID if (MOVE_COUNT(ctx, ctx->uPly)) { SelectBestNoHistory(ctx, x); @@ -1098,16 +989,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ctx->sSearchFlags.fAvoidNullmove = FALSE; } } -#endif - // 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)) - { - fTryEFPHere = TRUE; - } uStage++; uRawDepthB = DepthBucket(uDepth / ONE_PLY); #ifdef MP @@ -1143,39 +1024,11 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, fThisMoveJointExtraEligible = FALSE; #endif - // Always fully select the best remaining move, - // regardless of tier -- retired the old - // NumLeftoverMovesToSelect budget (a depth-indexed - // cutoff on how many "leftover", i.e. sub-GOOD_MOVE, - // moves were worth a full SelectBestWithHistory scan - // before taking the remainder in whatever order it - // sat in) once this session's evidence-calibration - // work (see chess.h's COUNTERMOVE_EVIDENCE_THRESHOLD/ - // FLEE_BONUS) showed the leftover pool has real, - // findable signal -- countermove matches and - // continuation-history-backed quiet moves both fail - // high at rates well above the pool's average -- so - // a bailout budget was discarding real information - // for a node-count savings that didn't hold up - // net-net once measured properly (solve counts and - // leftover fail-high rates, not raw node counts, - // which are too noisy on small suites to trust - // alone). GOOD_MOVE itself is still meaningful here: - // it's generate.c's own quality floor (below every - // killer tier and SORT_THESE_FIRST's winning/even- - // capture range), used below only to classify a - // move as "leftover" for EFP eligibility and - // instrumentation, not to gate how it's searched. - // - // On an IID-rescored ply, iValue is a real eval-axis - // score (see RescoreMovesViaSearch/ComputeMoveScore) - // -- GOOD_MOVE is meaningless on that axis, so this - // never classifies an IID-rescored move as a - // leftover (matches pre-retirement behavior). + // Select the best move to try next. if (MOVE_SCORING_IID_RESCORED == pi->eMoveScoringScheme) { SelectBestNoHistory(ctx, x); // mv's score is search-based - // WouldGiveCheck already computed in RescoreMovesViaSearch + // Note: WouldGiveCheck already computed in RescoreMovesViaSearch mv = ctx->sMoveStack.mvf[x].mv; } else @@ -1234,7 +1087,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // uLegalMoves/fIsLeftoverMove (genuinely per-move) and // g_uNumHelpersAvailable (genuinely dynamic, mutated by other // threads) still need a fresh check every iteration. - if (((uLegalMoves >= 2) && fIsLeftoverMove) && + if ((uLegalMoves >= 2) && + (TRUE == fIsLeftoverMove) && (0 != g_uNumHelpersAvailable) && fNodeSplitEligible) { @@ -1360,22 +1214,21 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, &iExtend); ASSERT((uLegalMoves > 1) ? (x != 0) : TRUE); + + // Pruning on alpha... late move pruning / late move reduction. fMoveIsPruneReduceEligible = CanWeReduceOrPruneMove(ctx, - mv, - x, - uLegalMoves, - iExtend, - iAlpha, - fThreat, - fIsLeftoverMove); + mv, + x, + uLegalMoves, + iExtend, + iAlpha, + fThreat, + fIsLeftoverMove); fThisMoveLMPPruned = FALSE; - fThisMoveEFPPruned = FALSE; - if (TRUE == fMoveIsPruneReduceEligible) { // In here we consider: - // - LMP (late move pruning), - // - EFP (extended futility pruning), and + // - LMP (late move pruning), and // - LMR (late move reduction). // // All of these have their own conditions for firing but they @@ -1386,49 +1239,33 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ULONG uFHAttempts = 0; ULONG uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uFHAttempts); - // 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. Note: depth - // bucket is per-node and was already computed (once) - // above. + // Our 5D prediction table, given: + // - the gap from iEval to iAlpha here, + // - move lateness (number), + // - board material apparently hung by mv, + // - remaining depth to search, and + // - improvement... + // + // ...it will give us a predicted FH% for the move. - // The amount of material this move seems to hang. + ULONG uRawGapB = GapCpBucket((SCORE)(iAlpha - iEval)); + ULONG uRawLateB = MoveNumBucket(uLegalMoves); SCORE iHangCp = EstimateHangingMaterialCp(ctx, mv, (x - 1), bbMinorAttacksSnapshot, bbRookAttacksSnapshot); ULONG uRawDumbB = HangCpBucket(iHangCp); - // Gap from iEval to current iAlpha bucket. - ULONG uRawGapB = GapCpBucket((SCORE)(iAlpha - iEval)); - - // Late move number bucket. - 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). uRawImproveB is node-level, hoisted - // above (not recomputed here -- see its declaration - // comment). - ULONG uPredictedFH = _JointTablePredictedFHThousandths( - uRawDepthB, uRawDumbB, uRawGapB, uRawLateB, uRawImproveB); + // Note: 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. + ULONG uPredictedFH = _JointTablePredictedFHThousandths(uRawDepthB, + uRawDumbB, + uRawGapB, + uRawLateB, + uRawImproveB); // 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( ctx, uDepth, @@ -1437,97 +1274,12 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, uFHPctThousandths, uPredictedFH); - // 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(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 < 300); -#if 0 - if (fThisMoveEFPPruned && - uPredictedFH != 0xFFFF && - 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 - } - } - - // If neither LMP nor EFP fired, we are going to search below - // this move. But if the move is very late, we might reduce + // If LMP didn't fire, 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; @@ -1551,20 +1303,12 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, #endif } } - } // LMP, EFP, LMR -- move pruning and reduction + } // LMP, LMR -- move pruning and reduction - if ((TRUE == fThisMoveEFPPruned) || - (TRUE == fThisMoveLMPPruned)) + if (TRUE == fThisMoveLMPPruned) { #ifdef PERF_COUNTERS - if (fThisMoveEFPPruned) - { - INC(ctx->sCounters.tree.u64EFPPruned); - } - else if (fThisMoveLMPPruned) - { - INC(ctx->sCounters.tree.u64LMPPruned); - } + INC(ctx->sCounters.tree.u64LMPPruned); #endif UnmakeMove(ctx, mv); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); @@ -1576,40 +1320,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { 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) { @@ -1674,6 +1384,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // from a completely different sibling move, not the // one actually played. pi->iExtensionAmount = iExtend; + pf->iCumulativeExtend += iExtend; // Compute the next search depth for this move/subtree. uNextDepth = uDepth - ONE_PLY + iExtend; @@ -1681,7 +1392,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, uNextDepth = 0; } - pf->iCumulativeExtend += iExtend; ASSERT(pf->fAvoidNullmove == FALSE); if (iBestScore == -INFINITY_SCORE) { @@ -1717,7 +1427,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } } - // Research deeper if history pruning failed + // Deeper verification search late move reduction FH. if ((iExtend < 0) && (iScore >= iBeta)) { #ifdef PERF_COUNTERS @@ -1735,7 +1445,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, INC(ctx->sCounters.tree.u64JointExtraResearch[uThisMoveJointExtraTier]); } #endif - uNextDepth -= iExtend; // undo the full reduction, whatever its magnitude + // Undo the full reduction, whatever its magnitude... + uNextDepth -= iExtend; pi->iExtensionAmount = 0; iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth); } @@ -1976,7 +1687,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, goto end; } - // Check results + // Check results of search below this move. ASSERT(iBestScore <= iAlpha); ASSERT(iAlpha < iBeta); if (iScore > iBestScore) @@ -2100,10 +1811,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } // Not checkmate/stalemate; store the result of this search in the - // hash table. - - // Do not store exact hash scores for nodes where we pruned (EFP/LMP) - // some moves. + // hash table. TODO: consider adjusting the hash table store to + // a bound if we pruned or reduced aggressively here? if (iAlpha != iInitialAlpha) { ASSERT(mvBest.uMove != 0); |
