summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-14 10:49:20 -0700
committerScott Gasch <[email protected]>2026-10-14 10:49:20 -0700
commit3d54c2cc82366040f00d5901f80dbe4e9d7bfe10 (patch)
treee143c4db3a541feb9aa88fac1ef78a9ff77117e0
parentdad4620a92f51a1d0825fc99fa98fd2b16c0f280 (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]>
-rw-r--r--src/TODO5
-rw-r--r--src/movesup.c40
-rw-r--r--src/search.c511
-rw-r--r--src/searchsup.c40
4 files changed, 142 insertions, 454 deletions
diff --git a/src/TODO b/src/TODO
index 0dbdb8d..d7bff4c 100644
--- a/src/TODO
+++ b/src/TODO
@@ -79,3 +79,8 @@ hashed eval terms.
2026-10-04: Modify self-play harness to record eval score / PVs per
move in the PGN output. Make self-play harness able to play crafty
and then test the HEAD/candidate against crafty.
+
+Eval term for connected passers vs. rooks. Or 2+ passers vs. rooks.
+
+Poor king play -- tell it to support own passers or blockade enemy
+passers once king activity phase starts.
diff --git a/src/movesup.c b/src/movesup.c
index 4b86947..7266177 100644
--- a/src/movesup.c
+++ b/src/movesup.c
@@ -1456,20 +1456,6 @@ Return value:
return;
}
- // Flee-to-safety: a small, same-tier selection-time nudge (not a
- // GOOD_MOVE promotion -- that class was retired, see generate.c's
- // RETIRED comment) for a quiet move whose origin square is
- // currently en prise. NOT loop-invariant across repeated calls at
- // the same node the way TotalNonPawnCount was in search.c/eval.c's
- // recent hoists -- RecordEnprisePiece (dynamic.c) only populates
- // this ply's cache from inside Eval(), which doesn't necessarily
- // run before every single SelectBestWithHistory call at this node,
- // so a later call in the same node can legitimately see a fresher
- // answer than an earlier one. Recomputed every call (2026-09-16:
- // briefly hoisted to the caller, reverted after a DEBUG-build
- // ASSERT here caught the mismatch -- see git log).
- // cEnprise = FindEnprisePiece(ctx, ctx->sPosition.uToMove);
-
// Continuation history: "did a move like this tend to fail high
// right after a move like that?", keyed by (previous move, this
// move) at the same [piece][to] coarseness g_HistoryCounters
@@ -1498,29 +1484,19 @@ Return value:
fHaveContinuation *
CONTINUATION_SCALE *
g_ContinuationHistory[(uPrevContKey * CONT_KEY_RANGE) + MOVE_TO_CONT_KEY(mv)]);
- //if (mv.cFrom == cEnprise)
- //{
- // iBestVal += FLEE_BONUS;
- //}
}
uLoc = u;
for (v = u + 1; v < uEnd; v++)
{
- iVal = ctx->sMoveStack.mvf[v].iValue;
mv = ctx->sMoveStack.mvf[v].mv;
- if (!IS_CAPTURE_OR_PROMOTION(mv))
- {
- iVal += g_HistoryCounters[mv.pMoved][mv.cTo];
- iVal += (
- fHaveContinuation *
- CONTINUATION_SCALE *
- g_ContinuationHistory[(uPrevContKey * CONT_KEY_RANGE) + MOVE_TO_CONT_KEY(mv)]);
-// if (mv.cFrom == cEnprise)
-// {
-// iVal += FLEE_BONUS;
-// }
- }
+ iVal = ctx->sMoveStack.mvf[v].iValue;
+ iVal += !IS_CAPTURE_OR_PROMOTION(mv) * (
+ g_HistoryCounters[mv.pMoved][mv.cTo] +
+ fHaveContinuation *
+ CONTINUATION_SCALE *
+ g_ContinuationHistory[(uPrevContKey * CONT_KEY_RANGE) + MOVE_TO_CONT_KEY(mv)]
+ );
if (iVal > iBestVal)
{
iBestVal = iVal;
@@ -1528,9 +1504,7 @@ Return value:
}
}
- //
// Note: the if here slows down the code, just swap em.
- //
mvfTemp = ctx->sMoveStack.mvf[u];
ctx->sMoveStack.mvf[u] = ctx->sMoveStack.mvf[uLoc];
ctx->sMoveStack.mvf[uLoc] = mvfTemp;
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);
diff --git a/src/searchsup.c b/src/searchsup.c
index 53e7a85..a048d39 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -181,11 +181,11 @@ Return value:
}
}
- //
+ //
// Also check raw node count limit here.
//
if (g_Options.u64MaxNodeCount != 0ULL &&
- ctx->sCounters.tree.u64TotalNodeCount > g_Options.u64MaxNodeCount)
+ ctx->sCounters.tree.u64TotalNodeCount > g_Options.u64MaxNodeCount)
{
Trace("NODE COUNT LIMIT (limit %llu, searched %llu) "
"--> stop searching now\n", g_Options.u64MaxNodeCount,
@@ -582,10 +582,10 @@ _JointTablePredictedFHThousandths(IN ULONG uRawDepthBucket,
return JOINT_TABLE_SENTINEL;
}
return g_uJointFHThousandths[_ShipDepthBucket(uRawDepthBucket)]
- [uRawDumbBucket]
- [uRawGapBucket]
- [_ShipLateBucket(uRawLateBucket)]
- [uRawImproveBucket];
+ [uRawDumbBucket]
+ [uRawGapBucket]
+ [_ShipLateBucket(uRawLateBucket)]
+ [uRawImproveBucket];
}
// Looks up this node's predicted FH% (thousandths of a percent, i.e.
@@ -611,12 +611,12 @@ _BetaTablePredictedFHThousandths(IN ULONG uRawDepthBucket,
IN ULONG uEnemyImproveBucket)
{
USHORT uRawGap = g_uBetaFHThousandths[_ShipDepthBucket(uRawDepthBucket)]
- [uGapBetaBucket]
- [uOwnImproveBucket]
- [uEnemyImproveBucket];
- if (0xFFFF == uRawGap)
+ [uGapBetaBucket]
+ [uOwnImproveBucket]
+ [uEnemyImproveBucket];
+ if (JOINT_TABLE_SENTINEL == uRawGap)
{
- return 0xFFFF;
+ return JOINT_TABLE_SENTINEL;
}
return (ULONG)(100000 - uRawGap);
}
@@ -781,13 +781,13 @@ _FlatTableExtraPlies(IN ULONG uRemainingDepth, IN ULONG uMoveNum)
FLAG
CanWeReduceOrPruneMove(IN SEARCHER_THREAD_CONTEXT *ctx,
- IN MOVE mv,
- IN ULONG x,
- IN ULONG uLegalMoves,
- IN SCORE iExtend,
- IN SCORE iAlpha,
- IN FLAG fThreat,
- IN FLAG fIsLeftoverMove)
+ IN MOVE mv,
+ IN ULONG x,
+ IN ULONG uLegalMoves,
+ IN SCORE iExtend,
+ IN SCORE iAlpha,
+ IN FLAG fThreat,
+ IN FLAG fIsLeftoverMove)
/**
Routine description:
@@ -1060,7 +1060,7 @@ Return value:
#define LMP_BASE_MOVENUM (8) // currently unused, see above
#define LMP_PER_PLY_MOVENUM (1) // currently unused, see above
#define LMP_DYNAMIC_MIN_SAMPLES (1) // well-evidenced, not a guess
-#define LMP_DYNAMIC_MAX_PCT_THOUSANDTHS (1)
+#define LMP_DYNAMIC_MAX_PCT_THOUSANDTHS (10000)
FLAG
@@ -1149,7 +1149,7 @@ Return value:
**/
{
static ULONG uLMPStaticMaxPctThousandths[] = {
- 200, 200, 150, 75, 50, 25, 12, 6, 3, 1, 0
+ 325, 300, 250, 200, 150, 100, 80, 25, 6, 1, 0
};
ASSERT(ctx->uPly > 0);
ASSERT(IS_VALID_DEPTH(uRemainingDepth));