summaryrefslogtreecommitdiff
path: root/src/search.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-05 09:01:25 -0700
committerScott Gasch <[email protected]>2026-10-05 09:01:25 -0700
commit64b5d3307a7a95975f7f8c0248f3f1febedd8617 (patch)
tree993fffd62a68bec962261caf033a8f05c6370ea9 /src/search.c
parentbbdf1bda8f410df6aa9be0a38e71b7db04982198 (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.c1519
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;
}
}
}