diff options
| author | Scott Gasch <[email protected]> | 2026-09-15 23:17:33 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-15 23:17:33 -0700 |
| commit | c0ac3d7845f60c7f6f2c3bfc306ceb0e4ac045ba (patch) | |
| tree | e2b7427e212bd5b557f15131b815517a86b20486 /src | |
| parent | 0bb4ae3052cb8ff9ffcf951b3ef0e6f50a7efdfa (diff) | |
Rework check/pawn-push/recapture extension gating on real SEE (GetMoveSee, generalized from GetCheckSee), restrict LMR to leftover moves only, and tighten recapture's window back to 1x after measuring a 6.8x node blowup from sharing the wider window.
Head-to-head match at 1170 games confirms parity with the prior head (score=0.510, LLR still between bounds) -- landing as a neutral-or-better baseline rather than a regression.
Diffstat (limited to 'src')
| -rwxr-xr-x | src/chess.h | 8 | ||||
| -rw-r--r-- | src/generate.c | 25 | ||||
| -rw-r--r-- | src/root.c | 15 | ||||
| -rw-r--r-- | src/search.c | 36 | ||||
| -rw-r--r-- | src/searchsup.c | 169 | ||||
| -rw-r--r-- | src/split.c | 21 |
6 files changed, 196 insertions, 78 deletions
diff --git a/src/chess.h b/src/chess.h index 4d7015e..16cdca9 100755 --- a/src/chess.h +++ b/src/chess.h @@ -1113,10 +1113,13 @@ typedef struct _COUNTERS struct { ULONG uPawnPush; + ULONG uPawnPushByQuarterPly[5]; // indexed by granted amount / QUARTER_PLY ULONG uCheck; + ULONG uCheckByQuarterPly[5]; // indexed by granted amount / QUARTER_PLY ULONG uMateThreat; ULONG uRecapture; ULONG uOneLegalMove; + ULONG uOneLegalMoveByQuarterPly[5]; // indexed by granted amount / QUARTER_PLY ULONG uNoLegalKingMoves; ULONG uMultiCheck; ULONG uSingularMove; @@ -3247,7 +3250,7 @@ ComputeReactionToCheckExtension(IN OUT SEARCHER_THREAD_CONTEXT *ctx, IN OUT INT *piOrigExtend); SCORE -GetCheckSee(IN SEARCHER_THREAD_CONTEXT *ctx, +GetMoveSee(IN SEARCHER_THREAD_CONTEXT *ctx, IN MOVE mv, IN ULONG uMoveNum); @@ -3258,7 +3261,8 @@ ComputeMoveExtension(IN OUT SEARCHER_THREAD_CONTEXT *ctx, IN ULONG uMoveNum, IN SCORE iRoughEval, IN ULONG uDepth, - IN SCORE iCheckSee, + IN SCORE iMoveSee, + IN FLAG fIsLeftoverMove, IN OUT INT *piExtend); SCORE diff --git a/src/generate.c b/src/generate.c index a04b4c6..e3db067 100644 --- a/src/generate.c +++ b/src/generate.c @@ -4171,6 +4171,21 @@ Return value: pStack->mvf[u].bvFlags |= MVF_SEE_KNOWN | ((s >= 0) ? MVF_SEE_NONNEGATIVE : 0); } + else + { + // s > 0 here: the captured piece is worth strictly + // more than the piece making the capture. The real + // SEE(pos, mv) is mathematically guaranteed >= this + // material floor (the side to move can always stop + // the exchange right after this capture), so its + // sign is already known to be nonnegative without + // paying for the actual exchange walk -- avoids a + // later GetMoveSee() cache miss (and a redundant + // SEE() call) on exactly the most common "good + // capture" case. + pStack->mvf[u].bvFlags |= MVF_SEE_KNOWN | + MVF_SEE_NONNEGATIVE; + } if (s >= 0) { @@ -4384,7 +4399,15 @@ Return value: pStack->mvf[u].bvFlags |= MVF_SEE_KNOWN | ((s >= 0) ? MVF_SEE_NONNEGATIVE : 0); } - + else + { + // Same reasoning as _ScoreAllMoves' identical + // shortcut: s > 0 here is a guaranteed floor on the + // real SEE, so its sign is already known. + pStack->mvf[u].bvFlags |= MVF_SEE_KNOWN | + MVF_SEE_NONNEGATIVE; + } + if (s > 0) { // Winning captures: take with pieces before with king @@ -598,6 +598,9 @@ Return value: #endif Trace("Extensions: (%u +, %u q+, %u 1mv, %u !kmvs, %u mult+, %u pawn\n" " %u threat, %u zug, %u sing, %u endg, %u bm, %u recap)\n" + " check by amount (1/4,1/2,3/4,1 ply): %u %u %u %u\n" + " 1mv by amount (1/4,1/2,3/4,1 ply): %u %u %u %u\n" + " pawn push by amount (1/4,1/2,3/4,1 ply): %u %u %u %u\n" " %u / %llu qsearch nodes (%.1f%%) were in-check " "evasion nodes\n" " too-deep bailout: %llu ply-limit, %llu " @@ -614,6 +617,18 @@ Return value: ctx->sCounters.extension.uEndgame, ctx->sCounters.extension.uBotvinnikMarkoff, ctx->sCounters.extension.uRecapture, + ctx->sCounters.extension.uCheckByQuarterPly[1], + ctx->sCounters.extension.uCheckByQuarterPly[2], + ctx->sCounters.extension.uCheckByQuarterPly[3], + ctx->sCounters.extension.uCheckByQuarterPly[4], + ctx->sCounters.extension.uOneLegalMoveByQuarterPly[1], + ctx->sCounters.extension.uOneLegalMoveByQuarterPly[2], + ctx->sCounters.extension.uOneLegalMoveByQuarterPly[3], + ctx->sCounters.extension.uOneLegalMoveByQuarterPly[4], + ctx->sCounters.extension.uPawnPushByQuarterPly[1], + ctx->sCounters.extension.uPawnPushByQuarterPly[2], + ctx->sCounters.extension.uPawnPushByQuarterPly[3], + ctx->sCounters.extension.uPawnPushByQuarterPly[4], ctx->sCounters.extension.uQExtendChaseNodes, ctx->sCounters.tree.u64QNodeCount, (ctx->sCounters.tree.u64QNodeCount ? diff --git a/src/search.c b/src/search.c index 4e89a2c..573f78c 100644 --- a/src/search.c +++ b/src/search.c @@ -189,7 +189,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ULONG uFutilityMargin = 0; FLAG fAnyMoveEFPPruned = FALSE; FLAG fThisMoveEFPPruned = FALSE; - SCORE iCheckSee; + SCORE iMoveSee; #ifdef DEBUG ASSERT(IS_VALID_SCORE(iAlpha)); ASSERT(IS_VALID_SCORE(iBeta)); @@ -745,14 +745,22 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, #endif // SEE must be computed on the PRE-move position -- see.c's - // exchange walk needs the piece still sitting on cFrom. Only - // needed for checking moves, where ComputeMoveExtension uses it - // to gate the check extension on soundness (Crafty-style: don't - // extend a checking move that's really just a losing sacrifice). - iCheckSee = 0; - if (IS_CHECKING_MOVE(mv)) + // exchange walk needs the piece still sitting on cFrom. + // ComputeMoveExtension uses it for two soundness gates: the + // check extension (Crafty-style: don't extend a checking move + // that's really just a losing sacrifice) and the recapture + // extension (don't extend a "recapture" that actually loses + // material). uDepth <= THREE_PLY mirrors the recapture + // extension's own depth gate -- cheap to test here since + // uDepth is already loop-invariant, and it keeps this from + // computing a SEE on every single non-pawn capture at every + // depth. One call per move regardless of which gate(s) end up + // wanting it -- SEE is too hot a routine to pay for twice. + iMoveSee = 0; + if (IS_CHECKING_MOVE(mv) || + (mv.pCaptured && !IS_PAWN(mv.pCaptured) && (uDepth <= THREE_PLY))) { - iCheckSee = GetCheckSee(ctx, + iMoveSee = GetMoveSee(ctx, mv, (uStage == TRY_GENERATED_MOVES) ? (x - 1) : (ULONG)-1); @@ -773,7 +781,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, (x - 1), // Note: x==0 if doing mvHash iEval, uDepth, - iCheckSee, + iMoveSee, + fIsLeftoverMove, &iExtend); // Note: MAX_EXTEND_PER_LINE (a flat, non-depth-relative cap on @@ -786,8 +795,9 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // Decide how much (if any) to reduce this move's depth -- // graded LMR. if ((uDepth > TWO_PLY) && - (NODE_PV != pi->eNodeType) && + (pi->eNodeType != NODE_PV) && (uLegalMoves > 5) && + (fIsLeftoverMove) && (0 == iExtend) && (!IS_ESCAPING_CHECK(mv)) && (!IS_CAPTURE_OR_PROMOTION(mv)) && @@ -1570,7 +1580,7 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx, // a real (winning/even) iMoveValue gets the same value-plus- // flat-bonus treatment as any other capture; a move with no // usable value (SEE already called it losing) falls back to - // GetCheckSee's real tactical judgment, exactly like the + // GetMoveSee's real tactical judgment, exactly like the // quiet-check (VALUE_BISHOP) branch already does below. if (IS_CHECKING_MOVE(mv) && (TRUE == fGeneratedChecks)) { @@ -1590,7 +1600,7 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx, return(fConsider); } { - FLAG fConsider = (GetCheckSee(ctx, mv, uMoveNum) >= 0); + FLAG fConsider = (GetMoveSee(ctx, mv, uMoveNum) >= 0); #ifdef CALIBRATE_QSEARCH_FUTILITY if (FALSE == fConsider) { @@ -1640,7 +1650,7 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx, return(TRUE); } { - FLAG fConsider = (GetCheckSee(ctx, mv, uMoveNum) >= 0); + FLAG fConsider = (GetMoveSee(ctx, mv, uMoveNum) >= 0); #ifdef CALIBRATE_QSEARCH_FUTILITY if (FALSE == fConsider) { diff --git a/src/searchsup.c b/src/searchsup.c index 32e1be8..7ab8695 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -437,17 +437,25 @@ ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, SCORE -GetCheckSee(IN SEARCHER_THREAD_CONTEXT *ctx, +GetMoveSee(IN SEARCHER_THREAD_CONTEXT *ctx, IN MOVE mv, IN ULONG uMoveNum) /** Routine description: - SEE for a checking move, used by ComputeMoveExtension's callers - (Search, HelpSearch, and eventually QSearch) to decide how much to - extend/reduce a check. Must be computed on the PRE-move position -- - see.c's exchange walk needs the piece still sitting on cFrom. + SEE for a checking move or a non-pawn capture, used by + ComputeMoveExtension's callers (Search, HelpSearch, and eventually + QSearch) to decide how much to extend/reduce a check, and (as of + the recapture-extension soundness gate) to judge a recapture too. + One call site, one SEE per move regardless of how many of + ComputeMoveExtension's gates end up wanting it -- SEE is one of + the hottest routines in the tree, so callers compute this at most + once per move and pass the single result into ComputeMoveExtension + via iMoveSee, rather than each gate calling it independently. Must + be computed on the PRE-move position -- see.c's exchange walk needs + the piece still sitting on cFrom, and by the time + ComputeMoveExtension runs the move has already been played. Move generation/scoring (_ScoreAllMoves, _ScoreAllEscapes in generate.c) already runs a real SEE on this same (pos, mv) pair @@ -460,7 +468,7 @@ Routine description: Parameters: SEARCHER_THREAD_CONTEXT *ctx : searcher thread context - MOVE mv : the checking move + MOVE mv : the checking move or capture needing a soundness read ULONG uMoveNum : mv's index in ctx->sMoveStack.mvf[], or (ULONG)-1 if mv did not come from the move stack (e.g. the hash move) @@ -472,7 +480,7 @@ Return value: { BITV bvFlags; - ASSERT(IS_CHECKING_MOVE(mv)); + ASSERT(IS_CHECKING_MOVE(mv) || (mv.pCaptured != NULL)); if (uMoveNum != (ULONG)-1) { ASSERT(uMoveNum < MAX_MOVE_STACK); @@ -493,7 +501,8 @@ void ComputeMoveExtension(IN SEARCHER_THREAD_CONTEXT *ctx, IN ULONG uMoveNum, IN SCORE iRoughEval, IN ULONG uDepth, - IN SCORE iCheckSee, + IN SCORE iMoveSee, + IN FLAG fIsLeftoverMove, IN OUT INT *piExtend) /** @@ -561,36 +570,65 @@ Return value: { ASSERT(InCheck(pos, pos->uToMove)); INC(ctx->sCounters.extension.uCheck); - if (iCheckSee < 0) + if (iMoveSee < 0) { *piExtend += QUARTER_PLY; - goto enforce_ceiling; + INC(ctx->sCounters.extension.uCheckByQuarterPly[QUARTER_PLY / QUARTER_PLY]); } else { - *piExtend += THREE_QUARTERS_PLY; if (pos->uNonPawnMaterial[FLIP(pos->uToMove)] > (VALUE_KING + VALUE_BISHOP)) { - goto enforce_ceiling; + *piExtend += THREE_QUARTERS_PLY; + INC(ctx->sCounters.extension.uCheckByQuarterPly[THREE_QUARTERS_PLY / QUARTER_PLY]); + } + else + { + *piExtend += QUARTER_PLY; + INC(ctx->sCounters.extension.uCheckByQuarterPly[QUARTER_PLY / QUARTER_PLY]); } - *piExtend -= HALF_PLY; } } - // Pawn push extension - if (IS_PAWN(mv.pMoved)) + // Pawn push extension. RANK7/RANK2 already mean "one step from + // promotion", so the pawn is a passer by definition -- no separate + // passed-pawn test needed. Two cheap, eval-independent refinements + // (both read only incrementally-maintained POSITION bitboards, no + // Eval()/pawn-hash dependency, no sig-check needed): + // + // 1. Skip entirely if the promotion square itself is already + // occupied (friend or foe) -- a push that can just be captured + // or that's blocked from ever queening isn't worth the ply. + // 2. The size of the bonus depends on whether the pawn's own + // king/rook/queen actually defends it on cTo, not just + // whether the king happens to be nearby (a king 2 squares + // away that can't recapture doesn't help this pawn at all). + if (IS_PAWN(mv.pMoved) && (RANK7(mv.cTo) || RANK2(mv.cTo))) { - if (((GET_COLOR(mv.pMoved) == WHITE) && RANK7(mv.cTo)) || - ((GET_COLOR(mv.pMoved) == BLACK) && RANK2(mv.cTo))) + ULONG uPawnColor = GET_COLOR(mv.pMoved); + COOR cPromo = mv.cTo + ((uPawnColor == WHITE) ? 0x10 : -0x10); + + if (!(pos->bbOccupied & COOR_TO_BB(cPromo))) { - *piExtend += THREE_QUARTERS_PLY; - if (DISTANCE(pos->cNonPawns[GET_COLOR(mv.pMoved)][0], mv.cTo) < 3) + *piExtend += HALF_PLY; + if (!fIsLeftoverMove || + ((g_KingAttacksBB[mv.cTo] & + COOR_TO_BB(pos->cNonPawns[uPawnColor][0])) || + (_RookAttacksBB(mv.cTo, pos->bbOccupied) & + (pos->bbPieces[uPawnColor][ROOK] | + pos->bbPieces[uPawnColor][QUEEN])) || + (_BishopAttacksBB(mv.cTo, pos->bbOccupied) & + pos->bbPieces[uPawnColor][QUEEN]))) { *piExtend += QUARTER_PLY; + INC(ctx->sCounters.extension.uPawnPushByQuarterPly[THREE_QUARTERS_PLY / QUARTER_PLY]); + } + else + { + INC(ctx->sCounters.extension.uPawnPushByQuarterPly[HALF_PLY / QUARTER_PLY]); } INC(ctx->sCounters.extension.uPawnPush); - goto enforce_ceiling; } } @@ -600,28 +638,27 @@ Return value: ASSERT(IS_ESCAPING_CHECK(mv)); iMoveScore = iRoughEval + ComputeMoveScore(ctx, mv, uMoveNum); - // One legal move in reply to check... threshold doubled - // 2026-09-06, same stopgap reasoning as search.c's qsearch - // check-extension gate (board_representation/EVAL.md section - // 9) -- was ">1", stale against CountKingSafetyDefects' old - // CHECK_VECTOR-based scale. + // This is forced and cheap (1 move at the parent node). if (ONE_LEGAL_MOVE(ctx, ctx->uPly - 1) && CountKingSafetyDefects(&ctx->sPosition, uColor) > 2) { - *piExtend += (QUARTER_PLY + - HALF_PLY * - (iMoveScore + VALUE_KNIGHT > iAlpha)); + FLAG fBigBonus = (iMoveScore + VALUE_KNIGHT > iAlpha); + *piExtend += (QUARTER_PLY + HALF_PLY * fBigBonus); INC(ctx->sCounters.extension.uOneLegalMove); + INC(ctx->sCounters.extension.uOneLegalMoveByQuarterPly[ + fBigBonus ? + (THREE_QUARTERS_PLY / QUARTER_PLY) : + (QUARTER_PLY / QUARTER_PLY)]); } - // Singular response to check... - if ((mv.pCaptured) && - (iRoughEval + 400 < iAlpha) && - (iMoveScore + 75 > iAlpha)) + // One "good" way to response to check? Kinda dodgy but "works". + if (!fIsLeftoverMove && + mv.pCaptured && + (iRoughEval + 300 < iAlpha) && + (iMoveScore + 50 > iAlpha)) { *piExtend += ONE_PLY; INC(ctx->sCounters.extension.uSingularReplyToCheck); - goto enforce_ceiling; } } @@ -643,30 +680,34 @@ Return value: } } - // Recapture extension + // Recapture extension. Own tighter (1x) window restored -- + // measured on ECM.721 (ringers): sharing the outer 2x window + // grew this position's recapture-fire count 6.8x vs HEAD + // (17,569,874 -> 34,337,674 total nodes), far more than the + // ~2x growth every other extension category saw from stacking + // alone -- all in refuted alternative lines, not the winning + // PV (which has zero recaptures on it). The 2x window did + // recover ECM.735/ECM.443 by making trade-dense *correct* + // lines eligible, but it's a net loss on positions where the + // expensive trades are all in the lines being ruled out + // instead. Back to the tighter window as the better tradeoff. if ((iRoughEval >= (g_iRootScore[uColor] - _window[ctx->uPly])) && - (iRoughEval <= (g_iRootScore[uColor] + _window[ctx->uPly]))) + (iRoughEval <= (g_iRootScore[uColor] + _window[ctx->uPly])) && + (uDepth < THREE_PLY)) { - if (uDepth <= THREE_PLY) + if (!fIsLeftoverMove && + mv.pCaptured && + !IS_PAWN(mv.pCaptured) && + (iMoveSee >= 0) && + ((PIECE_VALUE(mv.pCaptured) == PIECE_VALUE(mvLast.pCaptured)) || + ((mvLast.pPromoted) && (mv.cTo == mvLast.cTo)))) { - if ((mv.pCaptured) && !IS_PAWN(mv.pCaptured) && - ((PIECE_VALUE(mv.pCaptured) == - PIECE_VALUE(mvLast.pCaptured)) || - ((mvLast.pPromoted) && (mv.cTo == mvLast.cTo)))) - { - iMoveScore = ComputeMoveScore(ctx, mv, uMoveNum); - if (iMoveScore >= 0) - { - *piExtend += HALF_PLY; - INC(ctx->sCounters.extension.uRecapture); - } - } + *piExtend += HALF_PLY; + INC(ctx->sCounters.extension.uRecapture); } } } - enforce_ceiling: - // Don't let extensions add up to > 1 ply #ifdef DEBUG iM = MIN(ONE_PLY, *piExtend); @@ -675,13 +716,29 @@ Return value: *piExtend = MINU(ONE_PLY, *piExtend); ASSERT(iM == *piExtend); - // Scale back extensions that are very far from the root: + // Scale back extensions that are very far from the root. Table + // built in root.c from g_uIterateDepth (see its own construction + // there for the exact arithmetic -- this is just a summary, keep + // the two in sync): + // + // ┌─────────────────────────┬───────────┬────────────────────────────────┐ + // │ ply range (× iterate │ reduction │ net effect │ + // │ depth) │ │ │ + // ├─────────────────────────┼───────────┼────────────────────────────────┤ + // │ u < 1.5× │ 0 │ full extension kept │ + // ├─────────────────────────┼───────────┼────────────────────────────────┤ + // │ 1.5× ≤ u < 1.875× │ -1/4 ply │ │ + // ├─────────────────────────┼───────────┼────────────────────────────────┤ + // │ 1.875× ≤ u < 2.25× │ -1/2 ply │ │ + // ├─────────────────────────┼───────────┼────────────────────────────────┤ + // │ 2.25× ≤ u < 3× │ -3/4 ply │ only a +1 ply extension │ + // │ │ │ can survive here │ + // ├─────────────────────────┼───────────┼────────────────────────────────┤ + // │ u ≥ 3× (hard limit) │ -all ply │ no extensions beyond here │ + // └─────────────────────────┴───────────┴────────────────────────────────┘ + // + // NOTE: check extension in qsearch is handled differently, see search.c. // - // Iterate g_Soft g_Hard - // | | | | | | | | | - // 0 1 2 3/2 3 4 (x Iterate) - // |-------------------|----|----|---------|----... - // |full full full full|-1/4|-1/2|-3/4 -3/4|none... ASSERT(ctx->uPly != 0); ASSERT(ctx->uPly <= MAX_PLY_PER_SEARCH); *piExtend -= g_uExtensionReduction[ctx->uPly-1]; diff --git a/src/split.c b/src/split.c index 3a69ab4..679d26d 100644 --- a/src/split.c +++ b/src/split.c @@ -1081,7 +1081,7 @@ Return value: INT iOrigExtend; INT iExtend; MOVE mv; - SCORE iCheckSee; + SCORE iMoveSee; #ifdef DEBUG POSITION board; @@ -1120,10 +1120,11 @@ Return value: ASSERT(iExtend <= +ONE_PLY); // SEE on the PRE-move position -- see search.c's identical comment. - iCheckSee = 0; - if (IS_CHECKING_MOVE(mv)) + iMoveSee = 0; + if (IS_CHECKING_MOVE(mv) || + (mv.pCaptured && !IS_PAWN(mv.pCaptured) && (uDepth <= THREE_PLY))) { - iCheckSee = GetCheckSee(ctx, + iMoveSee = GetMoveSee(ctx, mv, ctx->sMoveStack.uBegin[ctx->uPly] + uMoveNum); @@ -1134,14 +1135,22 @@ Return value: ASSERT((IS_CHECKING_MOVE(mv) && InCheck(&ctx->sPosition, ctx->sPosition.uToMove)) || (!IS_CHECKING_MOVE(mv) && !InCheck(&ctx->sPosition, ctx->sPosition.uToMove))); - // Compute extension + // Compute extension. fIsLeftoverMove is unconditionally TRUE + // here: search.c only ever starts a split when the move it + // was about to try was itself already classified as a + // leftover move (see its own fIsLeftoverMove-gated split + // condition) -- every move HelpSearch pulls from the shared + // pool for that split is drawn from that same leftover + // range, so there's no "early, well-ordered move" case to + // distinguish here. ComputeMoveExtension(ctx, iAlpha, iBeta, (ctx->sMoveStack.uBegin[ctx->uPly - 1] + uMoveNum), iEval, uDepth, - iCheckSee, + iMoveSee, + TRUE, &iExtend); // |
