diff options
| author | Scott Gasch <[email protected]> | 2026-08-28 13:19:05 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-08-28 13:19:05 -0700 |
| commit | 7e762b293d19b9c438a287f9d123e77ee3a5056d (patch) | |
| tree | 6b3c8ab3d8109a6ef023796d1639b080fd2e2edd /src | |
| parent | 5dd4ef29dde8872bb4280328113e6f8b9254f5e8 (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-x | src/search.c | 76 | ||||
| -rw-r--r-- | src/searchsup.c | 4 |
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) { |
