summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-08-28 13:19:05 -0700
committerScott Gasch <[email protected]>2026-08-28 13:19:05 -0700
commit7e762b293d19b9c438a287f9d123e77ee3a5056d (patch)
tree6b3c8ab3d8109a6ef023796d1639b080fd2e2edd /src
parent5dd4ef29dde8872bb4280328113e6f8b9254f5e8 (diff)
Replace SEARCH_SORT_LIMIT's pure move-count cutoff with a quality-based
one: always fully select "high performer" moves regardless of count, only apply the per-ply budget to leftover ordinary moves. The old gate (uLegalMoves < SEARCH_SORT_LIMIT(ply)) stopped selecting carefully after a fixed count, counting the hash move too -- so at ply 6+ (limit 5), a position with a hash move already used one of only 5 total slots before the cutoff hit. It had no way to tell "a handful of mediocre quiet moves" from "a hash move plus three winning captures and two killers" -- in the latter case, a real high-performer beyond the 4th/5th slot would get treated identically to a random leftover quiet move, even though generate.c had already tagged it as excellent. Checked what three real engines do here: Crafty always fully sorts the hash move, then MVV/LVA-ordered captures, then up to 4 killers -- its own cheap fallback (a move-count cutoff, gated by remaining depth) only ever applies to what's left after all of that, i.e. plain untested quiet moves. Stockfish uses a value threshold, not a position/count threshold, so a good move is never orphaned by where it happens to sit in the list, only by its own assessed quality. Berserk never gates at all -- full selection sort unconditionally, every node. New design: keep fully selecting for as long as every move found so far is >= GOOD_MOVE (a generate.c ordering-encoding constant that already sits, by construction, below every killer tier and SORT_THESE_FIRST's winning/even-capture range, and above ordinary quiet moves and losing captures -- a real quality floor already baked into the existing encoding, not a new one). The first selection that reveals a move below that floor marks the transition to "the rest of the team"; from there, SEARCH_SORT_LIMIT's existing table is reused (as an explicitly untuned starting point -- its old numbers were calibrated, if at all, against a different question: total selection budget from move 1, not a leftover-only budget) to decide how many more full selections are worth the cost before taking the remainder in place. On an IID-rescored ply, GOOD_MOVE is meaningless (iValue is a real eval-axis score there, not generate.c's encoding), so that ply type keeps its existing unconditional full-select behavior unchanged. Measured (ecm_ringers.ep_/ecm_confident_quick.ep_/ecm_hard_quick.ep_, sn=5M) against the prior baseline (10/88/9): 11/87/12, net +3 solves. EBF: unchanged on ringers, worse on confident_quick (the one suite that also lost a solve -- consistent single-suite regression, not a systemic pattern), better on hard_quick (paired with its solve gain). Not yet a fully validated result -- SEARCH_SORT_LIMIT's numbers (17/12/9/7/6/5) now need their own recalibration pass under this new "leftover budget" meaning, since whatever they were tuned against before doesn't apply to this role. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
Diffstat (limited to 'src')
-rwxr-xr-xsrc/search.c76
-rw-r--r--src/searchsup.c4
2 files changed, 52 insertions, 28 deletions
diff --git a/src/search.c b/src/search.c
index 6e596ab..fbef312 100755
--- a/src/search.c
+++ b/src/search.c
@@ -146,6 +146,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
INT iExtend;
ULONG uNextDepth;
ULONG uLegalMoves = 0;
+ FLAG fInLeftovers = FALSE;
+ ULONG uLeftoverPicks = 0;
HASH_ENTRY *pHash;
FLAG fThreat;
FLAG fSkipNull;
@@ -421,7 +423,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// we generated at this ply using a shallower
// search. "Internal Iterative Deepening" or
// something like it.
- if ((iAlpha + 1 != iBeta) &&
+ if ((TRUE == pi->fPvNode) &&
(mvHash.uMove == 0) &&
(ctx->sMoveStack.mvf[x].iValue < SORT_THESE_FIRST) &&
(0 == (ctx->sMoveStack.mvf[x].iValue &
@@ -462,36 +464,58 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
if (x < ctx->sMoveStack.uEnd[ctx->uPly])
{
ASSERT(x >= ctx->sMoveStack.uBegin[ctx->uPly]);
- if (uLegalMoves < SEARCH_SORT_LIMIT(ctx->uPly))
+ // EXPERIMENT (replaces the old pure move-count
+ // SEARCH_SORT_LIMIT gate -- see lmr_testing/RESULTS.md):
+ // a fixed count is blind to whether this move list is a
+ // "strong team" (lots of winning captures/killers) or a
+ // "weak team" (nothing but ordinary quiet moves) --
+ // stopping at count N throws away real signal when N
+ // good moves is an arbitrary cutoff partway through a
+ // list full of good moves, modeled on Crafty (hash,
+ // then MVV/LVA captures, then killers -- all always
+ // fully ordered -- only *then* does its cheap fallback
+ // kick in) and Stockfish (a value threshold, not a
+ // position threshold, decides what's worth sorting).
+ //
+ // On an IID-rescored ply, iValue is a real eval-axis
+ // score (see RescoreMovesViaSearch/ComputeMoveScore) --
+ // GOOD_MOVE is a generate.c ordering-encoding constant,
+ // meaningless on that axis, so always fully select
+ // there with no bailout point at all (unchanged from
+ // before this experiment).
+ //
+ // Otherwise: keep fully selecting for as long as every
+ // move found so far is >= GOOD_MOVE (a "high performer"
+ // -- this constant already sits, by construction, below
+ // every killer tier and SORT_THESE_FIRST's
+ // winning/even-capture range, and above ordinary quiet
+ // moves and losing captures, so it's a real quality
+ // floor already baked into generate.c's own encoding,
+ // not a new one). The first time a selection reveals a
+ // move below that floor, we've hit "the rest of the
+ // team" -- from then on, apply a budget (borrowing
+ // SEARCH_SORT_LIMIT's existing table as an untuned
+ // starting point for this new meaning) on how many more
+ // full selections are worth the cost before just taking
+ // the remainder in place.
+ if (TRUE == pi->fMovesRescoredByIID)
{
- // On an IID-rescored ply, mvf[].iValue holds a
- // real, honest eval-axis score from an actual
- // shallow search (see RescoreMovesViaSearch) --
- // trust it outright, same principle as
- // ComputeMoveScore's IID-trust branch. Two
- // alternatives were measured and rejected (see
- // RESULTS.md): blending history into the real
- // score (scaled or capped) invents a new,
- // leak-prone move-scoring axis on top of an
- // already-crowded set (generate.c's ordering
- // encoding, RescoreMovesViaSearch's real scores,
- // root.c's own scheme) and measures no better
- // than trusting the score outright; a pure
- // tiebreak-on-exact-ties compromise still cost
- // solves relative to full mixing, so it wasn't
- // buying its complexity either. The branch lives
- // here (once per selection call), not inside
- // SelectBestWithHistory (once per candidate
- // move in a hot per-node loop), to keep the
- // overwhelmingly common non-rescored path at
- // zero added cost.
- if (TRUE == pi->fMovesRescoredByIID)
+ SelectBestNoHistory(ctx, x);
+ }
+ else if ((FALSE == fInLeftovers) ||
+ (uLeftoverPicks < SEARCH_SORT_LIMIT(ctx->uPly)))
+ {
+ SelectBestWithHistory(ctx, x);
+ if (FALSE == fInLeftovers)
{
- SelectBestNoHistory(ctx, x);
+ if (ctx->sMoveStack.mvf[x].iValue < GOOD_MOVE)
+ {
+ fInLeftovers = TRUE;
+ }
}
else
{
- SelectBestWithHistory(ctx, x);
+ uLeftoverPicks++;
}
}
mv = ctx->sMoveStack.mvf[x].mv;
diff --git a/src/searchsup.c b/src/searchsup.c
index 6f0d5f9..b532d9a 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -216,7 +216,7 @@ Return value:
ASSERT(mv.uMove);
ASSERT((uMoveNum > 0) || (uLegalMoves == 0));
if ((uRemainingDepth >= TWO_PLY) &&
- (iBeta == (iAlpha + 1)) &&
+ (FALSE == ctx->sPlyInfo[ctx->uPly - 1].fPvNode) &&
(uLegalMoves > 5) &&
(0 == iExtend) &&
(!IS_ESCAPING_CHECK(mv)) &&
@@ -1113,7 +1113,7 @@ WeShouldTryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx,
(pos->uNonPawnCount[pos->uToMove][0] > 2) &&
(FALSE == pi->fInCheck) &&
(iBeta != +INFINITY) &&
- (iBeta == iAlpha + 1)) // <--- TODO: test this one please...
+ (FALSE == pi->fPvNode)) // <--- TODO: test this one please...
{
if (uNullDepth <= 6 * ONE_PLY)
{