summaryrefslogtreecommitdiff
path: root/src/search.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-08 18:47:22 -0700
committerScott Gasch <[email protected]>2026-09-08 18:47:22 -0700
commit379a03bbd993247c8de9c1f1b163fcfab2fa1d69 (patch)
treef14f59d43ab613ca33ae9ff874d256784ada710c /src/search.c
parent85b71cf793a85074d8d4483b21a2a06c84553d1d (diff)
Land super-lazy exit, material-based lazy floor, qsearch futility rework
Brings in the last remaining piece from stash@{0}: the super-lazy exit point (material-only pre-check before the regular lazy gate), a material-bucket floor under the regular lazy exit's margin (iSwingFloorByArmy), and search.c's qsearch futility rework (FUTILITY_BASE_MARGIN_BY_SOURCE, indexed by which Eval() exit tier produced the score). Required Eval()'s signature change from a single SCORE* to SCORE(*)[2] (positional estimate per side instead of one munged magnitude) -- search.c's futility margin folds in rgiPositional[pos->uToMove], which the earlier bad-trades investigation found to be a meaningfully predictive signal. search.c and chess.h brought in wholesale from the stash (both were either completely untouched by prior commits or contained no divergence worth preserving). eval.c required hand-merging on top of this session's already-applied bad-trades fix, B-over-N removal, and xColor/reorder cleanups -- ported the super-lazy exit block, the regular-lazy material floor, the per-color piPositional writes (all three exit sites: super-lazy, regular-lazy x2, full-eval), the super-lazy calibration harness (RecordSuperLazyMarginSafetySwing, dual-regime DumpMarginSafetyCalibration), and moved uArmyScaler/ uNumTrapped initialization to match the new ordering the super-lazy exit depends on. Verified: clean release + DEBUG build (only the previously-flagged _EvalTrappedPieces warning), DEBUG smoke test pass, and a 40-game st1 match against clean 434fa04 (score 0.487, llr -0.04) -- landing this margin machinery as-is from the stash, before any retuning, does not regress strength on its own. This confirms the original regression (0.15-0.225 score seen early in this investigation) was fully explained by the bad-trades unsigned-underflow bug, not by these margins being unsound. Values are the original, as-derived-from-calibration ones (see inline comments: SUPER_LAZY_MARGIN_BY_ARMY from 100 positions/sd8 with ~15-20% headroom, iSwingFloorByArmy from 1500 positions/sd8 with ~25% headroom, FUTILITY_BASE_MARGIN_BY_SOURCE from a separate 1500-position/sd6 surprise-rate calibration). Not yet retuned -- suspected to carry more headroom than necessary, which costs real search speed (speed=depth). Next step: re-run the margin-safety calibration fresh against the current build and trim the super-lazy and regular-lazy floor headroom down from the built-in database, leaving the qsearch futility margins alone (different calibration method, already risk-tolerant by construction). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ka9o3S2eKqh4jNfmxVZ6fH
Diffstat (limited to 'src/search.c')
-rwxr-xr-xsrc/search.c522
1 files changed, 474 insertions, 48 deletions
diff --git a/src/search.c b/src/search.c
index 25318ed..fdcb955 100755
--- a/src/search.c
+++ b/src/search.c
@@ -1087,18 +1087,311 @@ Return value:
FALSE if it can be skipped
**/
-#define QSEARCH_CONSIDER_MARGIN (120)
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+// 2026-09-08: measures whether _ShouldWeConsiderThisMove's futility
+// gates are set correctly, by -- at the moment a move would be
+// rejected -- actually searching it anyway (fully unpruned, via
+// ctx->fDiagUnprunedSubtree) and checking whether it would genuinely
+// have raised alpha. Never changes real search behavior: the
+// diagnostic re-search's result is used only to log a sample, then
+// discarded (same non-interference pattern as CALIBRATE_MARGIN_SAFETY
+// in eval.c).
+//
+// Bucketed by (gate, margin-neutral distance short of the relevant
+// threshold, piPositional bucket, which Eval() tier supplied
+// piPositional) so a single run answers several questions at once:
+// is the margin itself wide enough (distance-vs-surprise-rate curve
+// within a gate), does piPositional actually predict surprises
+// (compare curves across piPositional buckets at the same distance),
+// and does that answer differ by tier (super-lazy vs regular-lazy vs
+// full eval).
+#define QFUT_DIST_BUCKETS (6)
+#define QFUT_POS_BUCKETS (4)
+
+static UINT64 g_uQFutTries[QFUT_GATE_COUNT][QFUT_DIST_BUCKETS][QFUT_POS_BUCKETS][EVAL_POSITIONAL_SOURCE_COUNT];
+static UINT64 g_uQFutSurprises[QFUT_GATE_COUNT][QFUT_DIST_BUCKETS][QFUT_POS_BUCKETS][EVAL_POSITIONAL_SOURCE_COUNT];
+
+static ULONG
+_QFutDistanceBucket(IN SCORE iDistance)
+/* iDistance: how far short of the relevant threshold this move was
+ (positive = short; a move that actually cleared the bar never gets
+ here, so this should always be > 0, but negative/zero is folded
+ into bucket 0 defensively rather than asserting -- a measurement
+ harness should never crash a calibration run over its own bucketing
+ edge case). */
+{
+ if (iDistance <= 25) return(0);
+ if (iDistance <= 50) return(1);
+ if (iDistance <= 100) return(2);
+ if (iDistance <= 200) return(3);
+ if (iDistance <= 400) return(4);
+ return(5);
+}
+
+static ULONG
+_QFutPositionalBucket(IN SCORE iPositional)
+{
+ if (iPositional < 0) return(0);
+ if (iPositional < 25) return(1);
+ if (iPositional < 75) return(2);
+ return(3);
+}
+
+static void
+_QFutDiagnoseReject(IN SEARCHER_THREAD_CONTEXT *ctx,
+ IN ULONG uMoveNum,
+ IN SCORE iAlpha,
+ IN SCORE iBeta,
+ IN SCORE iPositional,
+ IN ULONG uPositionalSource,
+ IN ULONG uGate,
+ IN SCORE iDistance,
+ IN FLAG fGeneratedChecks)
+/**
+
+Routine description:
+
+ A move is about to be rejected by one of _ShouldWeConsiderThisMove's
+ futility gates. Before rejecting it for real, search it anyway
+ (fully unpruned) to see whether it would actually have raised
+ alpha, and log the outcome. The diagnostic search's result is
+ discarded -- this function never changes what the caller does.
+
+Return value:
+
+ void
+
+**/
+{
+ MOVE mv;
+ SCORE iScore;
+ ULONG uDistBucket, uPosBucket;
+
+ // Never diagnose from inside an already-diagnostic (fully
+ // unpruned) subtree -- structurally shouldn't happen anyway, since
+ // nothing gets rejected while fDiagUnprunedSubtree is set, but
+ // guard explicitly rather than relying on that.
+ if (TRUE == ctx->fDiagUnprunedSubtree)
+ {
+ return;
+ }
+ ASSERT(uGate < QFUT_GATE_COUNT);
+ ASSERT(uPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT);
+
+ mv = ctx->sMoveStack.mvf[uMoveNum].mv;
+ // Mirror QSearch's own move loop exactly: GenerateMoves only tags
+ // MVF_CHECK/the checking-move bit when checks were actually being
+ // generated this ply. Skipping this (as an earlier version of this
+ // function did) leaves the flag unset on a move that objectively
+ // does give check, which MakeMove/ply-info bookkeeping trusts
+ // blindly -- the next ply's fInCheck-vs-InCheck() consistency
+ // ASSERT (searchsup.c) catches the mismatch immediately.
+ if (FALSE == fGeneratedChecks)
+ {
+ mv.bvFlags |= WouldGiveCheck(ctx, mv);
+ }
+ if (FALSE == MakeMove(ctx, mv))
+ {
+ return;
+ }
+ ctx->fDiagUnprunedSubtree = TRUE;
+ ctx->sSearchFlags.uQsearchDepth++;
+ iScore = -QSearch(ctx, -iBeta, -iAlpha);
+ ctx->sSearchFlags.uQsearchDepth--;
+ ctx->fDiagUnprunedSubtree = FALSE;
+ UnmakeMove(ctx, mv);
+
+ uDistBucket = _QFutDistanceBucket(iDistance);
+ uPosBucket = _QFutPositionalBucket(iPositional);
+ g_uQFutTries[uGate][uDistBucket][uPosBucket][uPositionalSource]++;
+ if (iScore > iAlpha)
+ {
+ g_uQFutSurprises[uGate][uDistBucket][uPosBucket][uPositionalSource]++;
+ }
+}
+
+
+static CHAR *g_szQFutGateNames[QFUT_GATE_COUNT] =
+{
+ "generic capture/promo",
+ "checking capture/promo (VALUE_ROOK)",
+ "quiet check (VALUE_BISHOP)",
+};
+static CHAR *g_szQFutSourceNames[EVAL_POSITIONAL_SOURCE_COUNT] =
+{
+ "full eval",
+ "regular lazy",
+ "super lazy",
+};
+
+void
+DumpQSearchFutilityCalibration(void)
+/**
+
+Routine description:
+
+ Print, per (gate, piPositional-source tier), the surprise rate
+ (fraction of diagnostically-re-searched rejects that actually
+ raised alpha) by distance-short-of-threshold bucket, and
+ separately by piPositional bucket at the widest distance bucket --
+ read the former for "is this gate's margin wide enough," the
+ latter (compared across piPositional buckets at a fixed distance)
+ for "does piPositional actually predict surprises."
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ ULONG g, d, p, s;
+ static CHAR *szDistLabel[QFUT_DIST_BUCKETS] =
+ { "0-25", "25-50", "50-100", "100-200", "200-400", "400+" };
+ static CHAR *szPosLabel[QFUT_POS_BUCKETS] =
+ { "<0", "0-25", "25-75", "75+" };
+
+ for (g = 0; g < QFUT_GATE_COUNT; g++)
+ {
+ Trace("QSearch futility gate: %s\n", g_szQFutGateNames[g]);
+ for (s = 0; s < EVAL_POSITIONAL_SOURCE_COUNT; s++)
+ {
+ UINT64 u64TotalTries = 0;
+ for (d = 0; d < QFUT_DIST_BUCKETS; d++)
+ {
+ for (p = 0; p < QFUT_POS_BUCKETS; p++)
+ {
+ u64TotalTries += g_uQFutTries[g][d][p][s];
+ }
+ }
+ if (0 == u64TotalTries)
+ {
+ continue;
+ }
+ Trace(" source=%s (n=%" COMPILER_LONGLONG_UNSIGNED_FORMAT "):\n",
+ g_szQFutSourceNames[s], u64TotalTries);
+ Trace(" by distance short of bar (summed over piPositional buckets):\n");
+ for (d = 0; d < QFUT_DIST_BUCKETS; d++)
+ {
+ UINT64 u64Tries = 0, u64Surprises = 0;
+ for (p = 0; p < QFUT_POS_BUCKETS; p++)
+ {
+ u64Tries += g_uQFutTries[g][d][p][s];
+ u64Surprises += g_uQFutSurprises[g][d][p][s];
+ }
+ if (0 == u64Tries)
+ {
+ continue;
+ }
+ Trace(" dist %8s: %6.2f%% surprise rate (n=%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT ")\n",
+ szDistLabel[d],
+ 100.0 * (double)u64Surprises / (double)u64Tries,
+ u64Tries);
+ }
+ Trace(" by piPositional bucket (summed over distance buckets):\n");
+ for (p = 0; p < QFUT_POS_BUCKETS; p++)
+ {
+ UINT64 u64Tries = 0, u64Surprises = 0;
+ for (d = 0; d < QFUT_DIST_BUCKETS; d++)
+ {
+ u64Tries += g_uQFutTries[g][d][p][s];
+ u64Surprises += g_uQFutSurprises[g][d][p][s];
+ }
+ if (0 == u64Tries)
+ {
+ continue;
+ }
+ Trace(" pos %6s: %6.2f%% surprise rate (n=%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT ")\n",
+ szPosLabel[p],
+ 100.0 * (double)u64Surprises / (double)u64Tries,
+ u64Tries);
+ }
+ // The marginals above can each look flat on their own even
+ // when piPositional genuinely matters (its effect might
+ // only show up at a fixed distance) -- this is the actual
+ // Q2 answer: read a single distance row across columns. If
+ // the percentages don't move across piPositional buckets
+ // at a fixed distance, it isn't predictive; if they fall
+ // as piPositional rises, it is.
+ Trace(" cross-tab, surprise%% (rows=distance, cols=piPositional "
+ "%s/%s/%s/%s):\n",
+ szPosLabel[0], szPosLabel[1], szPosLabel[2], szPosLabel[3]);
+ for (d = 0; d < QFUT_DIST_BUCKETS; d++)
+ {
+ UINT64 u64RowTries = 0;
+ for (p = 0; p < QFUT_POS_BUCKETS; p++)
+ {
+ u64RowTries += g_uQFutTries[g][d][p][s];
+ }
+ if (0 == u64RowTries)
+ {
+ continue;
+ }
+ Trace(" dist %8s:", szDistLabel[d]);
+ for (p = 0; p < QFUT_POS_BUCKETS; p++)
+ {
+ UINT64 u64Tries = g_uQFutTries[g][d][p][s];
+ if (0 == u64Tries)
+ {
+ Trace(" n/a ");
+ }
+ else
+ {
+ Trace(" %5.1f%% (n=%5" COMPILER_LONGLONG_UNSIGNED_FORMAT ")",
+ 100.0 * (double)g_uQFutSurprises[g][d][p][s] /
+ (double)u64Tries,
+ u64Tries);
+ }
+ }
+ Trace("\n");
+ }
+ }
+ }
+}
+#endif // CALIBRATE_QSEARCH_FUTILITY
+
+
+// Flat bonus added to a checking capture/promotion's own (real,
+// winning/even) iMoveValue before comparing against iFutility --
+// replaces the retired VALUE_ROOK position-level cutoff for exactly
+// this population (see _ShouldWeConsiderThisMove's comment at its use
+// site for why). Provisional -- no calibration data yet for this
+// specific split; re-measure with `calibrate qsearchfutility`.
+#define CHECK_BONUS (150)
+
static FLAG INLINE
_ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
IN ULONG uMoveNum,
IN SCORE iFutility,
- IN FLAG fGeneratedChecks)
+ IN FLAG fGeneratedChecks
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ , IN SCORE iAlpha
+ , IN SCORE iBeta
+ , IN SCORE iPositional
+ , IN ULONG uPositionalSource
+#endif
+ )
{
+#ifdef DEBUG
MOVE mvLast = ctx->sPlyInfo[ctx->uPly - 1].mv;
+#endif
MOVE mv = ctx->sMoveStack.mvf[uMoveNum].mv;
ULONG uColor;
- SCORE i;
+ SCORE iMoveValue;
+ // TRUE once iMoveValue holds a real, comparable SEE-derived value
+ // (winning/even captures and promotions) rather than the raw,
+ // not-directly-comparable generation-time score a losing capture
+ // keeps. Used both for real control flow (the checking-move
+ // branch below needs to know which of two populations it's
+ // looking at) and, under CALIBRATE_QSEARCH_FUTILITY, to decide
+ // whether a reject is worth diagnosing.
+ FLAG fHaveMoveValue = FALSE;
ASSERT(!IS_CHECKING_MOVE(mvLast));
ASSERT(!InCheck(&(ctx->sPosition), ctx->sPosition.uToMove));
@@ -1119,12 +1412,13 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
}
}
- i = ctx->sMoveStack.mvf[uMoveNum].iValue;
- if (i >= SORT_THESE_FIRST)
+ iMoveValue = ctx->sMoveStack.mvf[uMoveNum].iValue;
+ if (iMoveValue >= SORT_THESE_FIRST)
{
- i &= STRIP_OFF_FLAGS;
- ASSERT(i >= 0);
- i -= MOVE_SCORE_ORDERING_BIAS(mv);
+ fHaveMoveValue = TRUE;
+ iMoveValue &= STRIP_OFF_FLAGS;
+ ASSERT(iMoveValue >= 0);
+ iMoveValue -= MOVE_SCORE_ORDERING_BIAS(mv);
if (mv.pCaptured)
{
// If there are very few pieces left on the board,
@@ -1151,21 +1445,30 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
}
// Don't trust the SEE alone for alpha pruning decisions.
- i = MAXU(i, PIECE_VALUE(mv.pCaptured));
+ iMoveValue = MAXU(iMoveValue, PIECE_VALUE(mv.pCaptured));
- // Also try hard not to prune recaps, the bad trade
- // penalty can make them look "futile" sometimes.
- if ((PIECE_VALUE(mv.pCaptured) ==
- PIECE_VALUE(mvLast.pCaptured)) &&
- (i + 200 + QSEARCH_CONSIDER_MARGIN > iFutility))
- {
- return(TRUE);
- }
+ // RETIRED 2026-09-08: used to give recaptures (same
+ // captured-piece value as mvLast) a flat +100 bonus
+ // here ("the bad trade penalty can make them look
+ // futile sometimes"). CALIBRATE_QSEARCH_FUTILITY data
+ // showed it wasn't testing real recaptures at all --
+ // this check never compared mv.cTo to mvLast.cTo, so
+ // "recapture-shaped" meant "captured a same-valued
+ // piece anywhere on the board," diluting genuine
+ // recaptures (usually safe) with unrelated captures
+ // (not specially safe) under one bonus. That mismatch
+ // is the more likely explanation for the gate's high,
+ // slowly-decaying surprise rate (13.25% at distance
+ // 0-25, still 3.29% at 400+) than the constant being
+ // merely too small. Removed rather than re-tuned;
+ // reintroduce with a same-square check if a bonus
+ // still looks warranted once the generic gate's own
+ // margin is fixed.
}
// Otherwise, even if a move is even/winning, make sure it
// brings the score up to at least somewhere near alpha.
- if (i + QSEARCH_CONSIDER_MARGIN > iFutility)
+ if (iMoveValue > iFutility)
{
return(TRUE);
}
@@ -1175,10 +1478,73 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
// that checked or a "futile" winning capture/prom that may or
// may not check. Be more willing to play checking captures
// even if they look bad.
+ //
+ // RETIRED VALUE_ROOK 2026-09-08: used to judge both
+ // populations below by a single position-level "iFutility <
+ // VALUE_ROOK" cutoff, discarding iMoveValue entirely even
+ // when a real one existed. CALIBRATE_QSEARCH_FUTILITY data
+ // showed a flat, non-decaying-with-distance surprise rate
+ // (8-16%, no better far past the threshold than right at it)
+ // -- the signature of a position-level test standing in for a
+ // move-level question it can't actually answer. Split into
+ // the two populations that were being conflated: a move with
+ // a real (winning/even) iMoveValue gets the same value-plus-
+ // flat-bonus treatment as any other capture; a move with no
+ // usable value (SEE already called it losing) falls back to
+ // GetCheckSee's real tactical judgment, exactly like the
+ // quiet-check (VALUE_BISHOP) branch already does below.
if (IS_CHECKING_MOVE(mv) && (TRUE == fGeneratedChecks))
{
- return(iFutility < +VALUE_ROOK);
+ if (TRUE == fHaveMoveValue)
+ {
+ FLAG fConsider = (iMoveValue + CHECK_BONUS > iFutility);
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ if (FALSE == fConsider)
+ {
+ _QFutDiagnoseReject(ctx, uMoveNum, iAlpha, iBeta,
+ iPositional, uPositionalSource,
+ QFUT_GATE_CHECK_ROOK,
+ iFutility - (iMoveValue + CHECK_BONUS),
+ fGeneratedChecks);
+ }
+#endif
+ return(fConsider);
+ }
+ {
+ FLAG fConsider = (GetCheckSee(ctx, mv, uMoveNum) >= 0);
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ if (FALSE == fConsider)
+ {
+ // No value-level threshold left for this
+ // population (that's the point) -- iFutility
+ // itself is the only position-level number left
+ // to bucket by, used as-is rather than a
+ // difference from some retired constant.
+ _QFutDiagnoseReject(ctx, uMoveNum, iAlpha, iBeta,
+ iPositional, uPositionalSource,
+ QFUT_GATE_CHECK_ROOK,
+ iFutility,
+ fGeneratedChecks);
+ }
+#endif
+ return(fConsider);
+ }
+ }
+
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ // Falls through to the final reject below -- either not a
+ // checking move, or a checking move we weren't generating
+ // checks for this ply (rare; the generic gate is still what
+ // decided this, so tag it the same way).
+ if (TRUE == fHaveMoveValue)
+ {
+ _QFutDiagnoseReject(ctx, uMoveNum, iAlpha, iBeta,
+ iPositional, uPositionalSource,
+ QFUT_GATE_GENERIC_CAPTURE,
+ iFutility - iMoveValue,
+ fGeneratedChecks);
}
+#endif
}
else
{
@@ -1194,7 +1560,20 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
{
return(TRUE);
}
- return(GetCheckSee(ctx, mv, uMoveNum) >= 0);
+ {
+ FLAG fConsider = (GetCheckSee(ctx, mv, uMoveNum) >= 0);
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ if (FALSE == fConsider)
+ {
+ _QFutDiagnoseReject(ctx, uMoveNum, iAlpha, iBeta,
+ iPositional, uPositionalSource,
+ QFUT_GATE_CHECK_BISHOP,
+ iFutility - (SCORE)VALUE_BISHOP,
+ fGeneratedChecks);
+ }
+#endif
+ return(fConsider);
+ }
}
return(FALSE);
}
@@ -1249,21 +1628,6 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
if ((pf->uQsearchDepth < pf->uQsearchCheckDepth) &&
(pf->uQsearchDepth < g_uIterateDepth / 4) &&
(pf->fCouldStandPat[pos->uToMove] == FALSE) &&
- //
- // Threshold doubled 2026-09-06 (board_representation/
- // EVAL.md section 9): was ">2", tuned against the old
- // CHECK_VECTOR-based CountKingSafetyDefects. The bitboard
- // rewrite (real blocker-aware slider attacks, queen 2x
- // weighting) runs systematically hotter for the same
- // underlying danger, so the un-rescaled old threshold was
- // firing far more liberally than intended -- observed
- // directly as a runaway check-extension cascade (85x+
- // branching for a single ply) on a real position. Not yet
- // independently recalibrated against real data the way
- // iKingSwingP90 was; doubling is a stopgap matching the
- // rough inflation this counter picked up, pending a real
- // measurement.
- //
(CountKingSafetyDefects(pos, pos->uToMove) > 4))
{
if ((uMoveCount == 1) ||
@@ -1344,6 +1708,27 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
}
+// 2026-09-08: was one flat FUTILITY_BASE_MARGIN (150) regardless of
+// which Eval() exit tier produced iEval/rgiPositional this call.
+// CALIBRATE_QSEARCH_FUTILITY data (1500 real-game positions,
+// tests/twic_sample.ep_, sd 6) showed the three tiers need very
+// different margins to reach a similar surprise rate: full-eval
+// source was still failing 1.65-6.4% of diagnosed rejects even
+// hundreds of centipawns short of the bar, regular-lazy was already
+// close to safe (1.26% down to 0.43%), super-lazy was only risky very
+// close to the bar (7.96% at distance 0-25, 0.54% by 400+). Indexed
+// by ctx->uLastPositionalSource (set inside Eval() -- see
+// EVAL_POSITIONAL_SOURCE_* in chess.h). Provisional, derived from one
+// run at sd 6; re-derive with `calibrate qsearchfutility` if search
+// behavior affecting typical qsearch iEval/iFutility gaps changes.
+static const SCORE FUTILITY_BASE_MARGIN_BY_SOURCE[EVAL_POSITIONAL_SOURCE_COUNT] =
+{
+ FUTILITY_BASE_MARGIN_FULL, // EVAL_POSITIONAL_SOURCE_FULL
+ FUTILITY_BASE_MARGIN_LAZY, // EVAL_POSITIONAL_SOURCE_LAZY
+ FUTILITY_BASE_MARGIN_SUPERLAZY, // EVAL_POSITIONAL_SOURCE_SUPERLAZY
+};
+
+
/**
Routine description:
@@ -1380,7 +1765,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
SCORE iScore;
SCORE iEval;
SCORE iFutility;
- SCORE iPositional;
+ SCORE rgiPositional[2];
ULONG x;
#ifdef PERF_COUNTERS
ULONG uLegalMoves;
@@ -1459,7 +1844,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
}
ASSERT(!InCheck(pos, pos->uToMove));
- iEval = iBestScore = Eval(ctx, iAlpha, iBeta, &iPositional);
+ 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
@@ -1480,6 +1865,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
}
else
{
+ ctx->sSearchFlags.fCouldStandPat[pos->uToMove] = TRUE;
if (iBestScore > iAlpha)
{
iAlpha = iBestScore;
@@ -1490,7 +1876,6 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
goto end;
}
}
- ctx->sSearchFlags.fCouldStandPat[pos->uToMove] = TRUE;
}
// He did not choose to stand pat here or we did not allow it. We
@@ -1504,20 +1889,51 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
iFutility = 0;
if (iAlpha < +NMATE)
{
- iFutility = iAlpha - (FUTILITY_BASE_MARGIN + iPositional) - iEval;
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ if (TRUE == ctx->fDiagUnprunedSubtree)
+ {
+ // We're inside a diagnostic "what if this rejected move
+ // had been searched anyway" re-search (see
+ // _QFutDiagnoseReject) -- force every gate in this
+ // function wide open so the diagnostic subtree isn't
+ // contaminated by the same pruning it exists to measure.
+ iFutility = -NMATE;
+ }
+ else
+#endif
+#ifdef DIAG_NO_QSEARCH_FUTILITY
+ // Diagnostic-only (never defined in a normal build): forces
+ // _ShouldWeConsiderThisMove's gates wide open so every
+ // capture/checking-move margin question in this file passes
+ // trivially, to measure the node-count/time cost of qsearch
+ // futility pruning as a whole -- not for shipping, just for
+ // sizing how expensive a "fully unpruned diagnostic subtree"
+ // would be for the qsearch-futility calibration harness.
+ iFutility = -NMATE;
+#else
+ ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT);
+ iFutility = iAlpha -
+ (FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource] +
+ rgiPositional[pos->uToMove]) - iEval;
iFutility = MAX0(iFutility);
+#endif
}
// We know we are not in check. If we are early in the qsearch,
// and the other side has not yet been able to stand pat yet, and
// we have material OR we have hanging pieces, generate checks
// here too. Checks are a "good way" to escape from "trouble".
- fIncludeChecks = ((pf->uQsearchDepth < pf->uQsearchCheckDepth) &&
- (((pf->fCouldStandPat[FLIP(pos->uToMove)] == FALSE) &&
- (pos->uNonPawnMaterial[pos->uToMove] >
- (VALUE_KING + VALUE_BISHOP))) ||
- (FALSE == ctx->sSearchFlags.fCouldStandPat[pos->uToMove])));
-
+ fIncludeChecks = (
+ (pf->uQsearchDepth < pf->uQsearchCheckDepth) &&
+ (
+ (
+ (pf->fCouldStandPat[FLIP(pos->uToMove)] == FALSE) &&
+ (pos->uNonPawnMaterial[pos->uToMove] > (VALUE_KING + VALUE_BISHOP))
+ )
+ ||
+ (FALSE == ctx->sSearchFlags.fCouldStandPat[pos->uToMove])
+ )
+ );
GenerateMoves(ctx, NULLMOVE, _WhatToGen[fIncludeChecks]);
#ifdef PERF_COUNTERS
@@ -1546,7 +1962,14 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
if (FALSE == _ShouldWeConsiderThisMove(ctx,
x,
iFutility,
- fIncludeChecks))
+ fIncludeChecks
+#ifdef CALIBRATE_QSEARCH_FUTILITY
+ , iAlpha
+ , iBeta
+ , rgiPositional[pos->uToMove]
+ , ctx->uLastPositionalSource
+#endif
+ ))
{
continue;
}
@@ -1610,9 +2033,12 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
// Readjust futility margin here; it can be wider now.
if (iAlpha < +NMATE)
{
+ ASSERT(ctx->uLastPositionalSource <
+ EVAL_POSITIONAL_SOURCE_COUNT);
iFutility = (iAlpha -
- (FUTILITY_BASE_MARGIN +
- iPositional) -
+ (FUTILITY_BASE_MARGIN_BY_SOURCE[
+ ctx->uLastPositionalSource] +
+ rgiPositional[pos->uToMove]) -
iEval);
iFutility = MAX0(iFutility);
}