summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
committerScott Gasch <[email protected]>2026-09-10 23:12:04 -0700
commit8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch)
tree7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3
parent9d1e32db698855b939c4bc5df6b6a82509dd381c (diff)
Fix ONE_LEGAL_MOVE hash-move-shrink bug, tighten extension taper, add
a scoped check-evasion qsearch futility prune, and fix a pre-existing PV-corruption crash. - chess.h/generate.c: ONE_LEGAL_MOVE now reads a uTrueEscapeCount snapshot taken before _ScoreAllEscapes excludes an already-tried hash move, instead of the live post-exclusion MOVE_COUNT -- the old version misreported "one legal reply to check" whenever the hash move happened to be one of exactly two legal replies. - root.c: g_uSoftExtendLimit/g_uHardExtendLimit taper onset moved from 2x/4x nominal search depth to 1.5x/3x (uniform 0.75x scaling of the same band shape) -- a forcing chain of check/singular-reply extensions could previously run fully unthrottled out to double the nominal depth before any taper at all kicked in. - search.c: QSearchFromCheckNoStandPat gains a scoped futility prune for check-evasion replies, active only when this side already had a stand-pat opportunity earlier in the line (fCouldStandPat). Only applies to non-checking moves, uses the same calibrated FUTILITY_BASE_MARGIN_BY_SOURCE table normal QSearch already trusts (not a separate, unvalidated margin), computes its Eval() call lazily (only once a non-checking candidate is actually reached, not unconditionally per node), and never fires until at least one legal move has already been searched (uLegalMoves > 0), so it can never discard every reply to a check. - search.c/searchsup.c: split CommonSearchInit's "too deep" bailout into two separately-counted triggers (the rare MAX_PLY_PER_SEARCH-1 array-safety ceiling vs. the much more frequently-hit g_uHardExtendLimit, now reachable sooner given the tightened taper above) and surfaced both counts in root.c's per-search report. - searchsup.c: UpdatePV no longer copies a child ply's stale PV tail forward after writing a sentinel move (HASHMOVE/RECOGNMOVE/ DRAWMOVE/MATEMOVE) into the current ply's slot -- sPlyInfo[] is reused across unrelated nodes at the same ply, so an earlier, unrelated node's leftover sentinel in that slot could reproduce the same sentinel value twice in a row and trip WalkPV's terminator assert (util.c:1093), reproducing under MP via debug_smoke_test.sh. Pre-existing, confirmed on unmodified HEAD; not introduced by any of the above. - eval_tune/match_play.py: EngineStats now tracks running average search depth (already-parsed per-move data, just never surfaced) and prints it alongside nps/ebf in the periodic status line, not just the final summary. Added a ^C handler that dumps the same CANDIDATE_SCORE/CANDIDATE_STATS/HEAD_STATS summary from whatever games completed so far, then exits immediately rather than waiting for the ThreadPoolExecutor's default wait=True teardown to let in-flight games finish naturally. Validated: precommit_check.sh (self-test + DEBUG smoke test) passing throughout; self-play gauntlet (match_play.py --st 1) trending net positive against HEAD through 1000 games. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TBtwX9aPrvwiM3gh4VnnZi
-rwxr-xr-xsrc/chess.h51
-rw-r--r--src/eval.c78
-rwxr-xr-xsrc/eval_tune/match_play.py165
-rw-r--r--src/generate.c10
-rw-r--r--src/move.c2
-rw-r--r--src/root.c214
-rwxr-xr-xsrc/run_tests.sh3
-rw-r--r--src/search.c113
-rw-r--r--src/searchsup.c37
9 files changed, 503 insertions, 170 deletions
diff --git a/src/chess.h b/src/chess.h
index 78c0f94..430c7e5 100755
--- a/src/chess.h
+++ b/src/chess.h
@@ -788,7 +788,17 @@ typedef union _GENERATOR_FLAGS
UCHAR uMoveCount;
UCHAR uKingMoveCount;
UCHAR uCheckingPieces;
- UCHAR uUnused;
+ // Snapshot of MOVE_COUNT taken right after _GenerateEscapes
+ // finishes populating the move list for a check-evasion ply,
+ // before _ScoreAllEscapes swaps the hash move to the end and
+ // shrinks uEnd[ply] to keep the search from re-trying it. The
+ // live uEnd[ply]-uBegin[ply] count that trick leaves behind is
+ // "untried moves remaining," not "legal replies to check" --
+ // using it for ONE_LEGAL_MOVE meant a position with two legal
+ // replies, one of which happened to be the hash move, read as
+ // having only one. This field is captured before that shrink
+ // so it always reflects the true legal-escape count.
+ UCHAR uTrueEscapeCount;
};
ULONG uAllGenFlags;
}
@@ -868,7 +878,7 @@ MOVE_STACK;
#define MOVE_COUNT(ctx, x) \
(((ctx)->sMoveStack.uEnd[(x)]) - (ctx)->sMoveStack.uBegin[(x)])
#define ONE_LEGAL_MOVE(ctx, x) \
- (MOVE_COUNT(ctx, x) == 1)
+ ((ctx)->sMoveStack.sGenFlags[(x)].uTrueEscapeCount == 1)
#define NUM_KING_MOVES(ctx, x) \
((ctx)->sMoveStack.sGenFlags[(x)].uKingMoveCount)
#define NUM_CHECKING_PIECES(ctx, x) \
@@ -921,6 +931,15 @@ typedef struct _COUNTERS
{
UINT64 u64TotalNodeCount;
UINT64 u64QNodeCount;
+ // CommonSearchInit's "too deep" bailout (searchsup.c), split by
+ // which of its two triggers fired -- the MAX_PLY_PER_SEARCH-1
+ // array-safety ceiling (should be a rare, pathological corner
+ // case) vs. g_uHardExtendLimit (a real, tunable limit that a
+ // tighter extension taper makes reachable far more often, in
+ // exactly the long forced-check-chain positions the 2026-09-10/11
+ // extension-taper/qsearch-prune work is about).
+ UINT64 u64TooDeepPlyLimit;
+ UINT64 u64TooDeepQsearchExtendLimit;
UINT64 u64LeafCount;
UINT64 u64TerminalPositionCount;
UINT64 u64BetaCutoffs;
@@ -955,6 +974,20 @@ typedef struct _COUNTERS
// than an array so each term's call sites in eval.c can name
// its own accumulator directly.
//
+ // 2026-09-09: split out of the old "pre-lazy other" bucket --
+ // u64CyclesEvalMaterialSetup (material balance + army-scaler
+ // calc) is paid unconditionally by *every* Eval() call
+ // (super-lazy, lazy, and full alike), while
+ // u64CyclesEvalPasserBadtradeBishop (passer races vs. a bare
+ // king, bad-trade detection, bishop pairs) is gated/rare on
+ // the first two and only unconditional (bishop pairs) inside
+ // calls that get this far. Bundling them together made the
+ // combined bucket's percentage read as "this work is
+ // expensive per call" when it was really "this bucket is
+ // paid by every call, not just full ones."
+ UINT64 u64CyclesEvalMaterialSetup;
+ UINT64 u64CyclesEvalPasserBadtradeBishop;
+
UINT64 u64CyclesEvalPawns;
//
// Split of the counter above by pawn-hash outcome (board_
@@ -1021,6 +1054,12 @@ typedef struct _COUNTERS
//
UINT64 u64CyclesEvalSuperLazy;
+ // 2026-09-09: EvalLookForDrawishSituations runs after
+ // u64CyclesEvalPostLazyMisc's window closes, only on the
+ // full-eval exit -- previously fell into "unaccounted" in
+ // root.c's eval breakdown report.
+ UINT64 u64CyclesEvalDrawish;
+
// Per-exit-path *total call cost*, i.e. the full entry-to-exit
// elapsed time for a call that left via that path -- not to be
// confused with the sub-timers above, which measure a single
@@ -1060,6 +1099,13 @@ typedef struct _COUNTERS
ULONG uEndgame;
ULONG uBotvinnikMarkoff;
ULONG uQExtend;
+ ULONG uQExtendChaseNodes; // qsearch nodes where the side to move
+ // is in check (every QSearch call that
+ // delegates to QSearchFromCheckNoStandPat)
+ // -- compare against
+ // sCounters.tree.u64QNodeCount for the
+ // fraction of all qsearch nodes spent
+ // on forced check evasions
}
extension;
@@ -2450,6 +2496,7 @@ extern BITBOARD BBSQUARE[64];
extern BITBOARD BBROOK_PAWNS;
extern BITBOARD BBPRECEEDING_RANKS[8][2];
extern BITBOARD BBADJACENT_FILES[8];
+extern BITBOARD BBADJACENT_FILES_INCLUSIVE[8];
extern BITBOARD BBADJACENT_RANKS[9];
// Ray-to-edge / knight-attack occupancy tables (data.c) -- built once
diff --git a/src/eval.c b/src/eval.c
index 26b7ace..edb1e0c 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -951,6 +951,39 @@ BITBOARD BBADJACENT_FILES[8] =
};
//
+// Same as BBADJACENT_FILES, but including the file itself -- callers
+// that used to OR in BBFILE[file] separately at every call (only one
+// site, eval.c's _EvalKing) can index this instead and skip the extra
+// load + OR.
+//
+BITBOARD BBADJACENT_FILES_INCLUSIVE[8] =
+{
+ // A = 0
+ BBFILEA | BBFILEB,
+
+ // B = 1
+ BBFILEA | BBFILEB | BBFILEC,
+
+ // C = 2
+ BBFILEB | BBFILEC | BBFILED,
+
+ // D = 3
+ BBFILEC | BBFILED | BBFILEE,
+
+ // E = 4
+ BBFILED | BBFILEE | BBFILEF,
+
+ // F = 5
+ BBFILEE | BBFILEF | BBFILEG,
+
+ // G = 6
+ BBFILEF | BBFILEG | BBFILEH,
+
+ // H = 7
+ BBFILEG | BBFILEH
+};
+
+//
// The ranks preceeding one, indexed by rank/color of pawn (used in
// eval.c)
//
@@ -4231,6 +4264,20 @@ Return value:
//
uQueenNearKing += (((bvAttack | bvXray) & QUEEN_BIT) != 0);
+ //
+ // Tried a branchless rewrite of this attacked/unattacked
+ // split (2026-09-09): compute both sides' contributions
+ // and blend via 0/1 multiply instead of branching. Measured
+ // *worse* under EVAL_TIME (king bucket 17.9% vs 16.0-16.1%
+ // baseline, full-eval cost ~3330 vs ~3270 cyc/call,
+ // consistent across 3 runs each on a fixed sd9 position) --
+ // the fOccupied ? : select that stayed in that version
+ // still compiled to a real CMP/branch (confirmed in
+ // objdump), so the rewrite paid for extra multiplies/masks
+ // on top of a branch that was never actually eliminated.
+ // Reverted; not a win here, don't retry without a way to
+ // force the compare into a cmov/select first.
+ //
if (bvAttack != 0)
{
bvPattern |= (bvAttack | bvXray);
@@ -4307,7 +4354,7 @@ Return value:
#ifdef CALIBRATE_POSITIONAL
// KING_SAFETY_BY_COUNTER is <= 0 throughout (a penalty); the
// calibration histogram bins magnitude, matching iKingSwingP90's
- // own all-positive-magnitude shape.
+ // own all-positive-mOBagnitude shape.
ASSERT(i <= 0);
_RecordPositionalCalibration(ctx, pos, pHash, uColor, (ULONG)(-i));
#endif
@@ -4394,7 +4441,7 @@ Return value:
//
cSquare = FILE(c);
bb = pHash->bbPasserLocations[uColor] &
- (BBADJACENT_FILES[cSquare] | BBFILE[cSquare]);
+ BBADJACENT_FILES_INCLUSIVE[cSquare];
if (bb)
{
while(IS_ON_BOARD(cSquare = CoorFromBitBoardRank8ToRank1(&bb)))
@@ -5257,6 +5304,9 @@ Return value:
ASSERT((pos->iMaterialBalance[WHITE] * -1) ==
pos->iMaterialBalance[BLACK]);
+#ifdef EVAL_TIME
+ UINT64 u64MaterialSetupTimer = SystemReadTimeStampCounter();
+#endif
pos->iScore[uColor] =
(pos->uPawnMaterial[uColor] + pos->uNonPawnMaterial[uColor]);
pos->iScore[xColor] =
@@ -5283,6 +5333,10 @@ Return value:
ASSERT(pos->uArmyScaler[BLACK] <= 31);
ASSERT(pos->uArmyScaler[WHITE] >= 0);
ASSERT(pos->uArmyScaler[WHITE] <= 31);
+#ifdef EVAL_TIME
+ ctx->sCounters.tree.u64CyclesEvalMaterialSetup +=
+ (SystemReadTimeStampCounter() - u64MaterialSetupTimer);
+#endif
// Super-lazy exit point.
#ifdef LAZY_EVAL
@@ -5431,6 +5485,9 @@ Return value:
Trace("After pawns:\n%d\t\t%d\n", pos->iScore[WHITE], pos->iScore[BLACK]);
#endif
+#ifdef EVAL_TIME
+ UINT64 u64PasserBadtradeBishopTimer = SystemReadTimeStampCounter();
+#endif
//
// Look for won passers races. Note: This code cannot trust the
// state of the attack tables yet!
@@ -5467,6 +5524,10 @@ Return value:
Trace("After bishop pair bonus:\n%d\t\t%d\n", pos->iScore[WHITE],
pos->iScore[BLACK]);
#endif
+#ifdef EVAL_TIME
+ ctx->sCounters.tree.u64CyclesEvalPasserBadtradeBishop +=
+ (SystemReadTimeStampCounter() - u64PasserBadtradeBishopTimer);
+#endif
#ifdef LAZY_EVAL
//
@@ -5589,6 +5650,10 @@ Return value:
#ifdef EVAL_TIME
ctx->sCounters.tree.u64CyclesEvalLazyDecision +=
(SystemReadTimeStampCounter() - uLazyDecisionTimer);
+ ctx->sCounters.tree.u64CyclesLazyExit +=
+ (SystemReadTimeStampCounter() - uTimer);
+ ctx->sCounters.tree.u64CyclesInEval +=
+ (SystemReadTimeStampCounter() - uTimer);
#endif
goto end;
#endif
@@ -5614,6 +5679,10 @@ Return value:
#ifdef EVAL_TIME
ctx->sCounters.tree.u64CyclesEvalLazyDecision +=
(SystemReadTimeStampCounter() - uLazyDecisionTimer);
+ ctx->sCounters.tree.u64CyclesLazyExit +=
+ (SystemReadTimeStampCounter() - uTimer);
+ ctx->sCounters.tree.u64CyclesInEval +=
+ (SystemReadTimeStampCounter() - uTimer);
#endif
goto end;
#endif
@@ -5932,7 +6001,8 @@ Return value:
pos->iScore[FLIP(pos->uToMove)]);
// Drive drawish situations towards the draw score.
- EvalLookForDrawishSituations(pos, &iScoreForSideToMove);
+ TIMED_EVAL_CALL(ctx, u64CyclesEvalDrawish,
+ EvalLookForDrawishSituations(pos, &iScoreForSideToMove));
//
// TODO: detect and discourage blocked positions?
@@ -6034,6 +6104,8 @@ Return value:
#endif
#ifdef EVAL_TIME
+ ctx->sCounters.tree.u64CyclesFullEvalExit += (SystemReadTimeStampCounter() -
+ uTimer);
ctx->sCounters.tree.u64CyclesInEval += (SystemReadTimeStampCounter() -
uTimer);
#endif
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py
index b26244a..c4cad6c 100755
--- a/src/eval_tune/match_play.py
+++ b/src/eval_tune/match_play.py
@@ -393,11 +393,16 @@ class EngineStats:
self.ebf_count = 0
self._fmb_weighted_sum = 0.0
self._fmb_weight = 0
+ self.depth_sum = 0
+ self.depth_count = 0
def add_ply(self, nodes, elapsed, depth):
self.total_nodes += nodes
self.total_time += elapsed
self.plies += 1
+ if depth and depth > 0:
+ self.depth_sum += depth
+ self.depth_count += 1
if depth and depth > 0 and nodes > 0:
self.ebf_sum += nodes ** (1.0 / depth)
self.ebf_count += 1
@@ -415,6 +420,8 @@ class EngineStats:
self.ebf_count += other.ebf_count
self._fmb_weighted_sum += other._fmb_weighted_sum
self._fmb_weight += other._fmb_weight
+ self.depth_sum += other.depth_sum
+ self.depth_count += other.depth_count
@property
def nps(self):
@@ -429,8 +436,13 @@ class EngineStats:
return (self._fmb_weighted_sum / self._fmb_weight
if self._fmb_weight else 0.0)
+ @property
+ def avg_depth(self):
+ return self.depth_sum / self.depth_count if self.depth_count else 0.0
+
def summary(self):
return (f"nps={self.nps:.0f} ebf={self.avg_ebf:.3f} "
+ f"depth={self.avg_depth:.2f} "
f"first_move_beta={self.avg_first_move_beta:.1f}% "
f"nodes={self.total_nodes} plies={self.plies}")
@@ -654,69 +666,106 @@ def main():
break
in_flight.add(pool.submit(run, job))
- while in_flight:
- finished, in_flight = wait(in_flight, return_when=FIRST_COMPLETED)
- for fut in finished:
- (i, candidate_result, game, game_elapsed,
- this_candidate_stats, this_head_stats) = fut.result()
- done += 1
- candidate_points += candidate_result
- game_durations.append(game_elapsed)
- candidate_stats.merge(this_candidate_stats)
- head_stats.merge(this_head_stats)
- print(game, file=pgn_out, end="\n\n")
- pgn_out.flush()
- if candidate_result == 1.0:
- wins += 1
- elif candidate_result == 0.0:
- losses += 1
- else:
- draws += 1
- if sprt is not None:
- sprt.update(candidate_result)
- log.write(f"game {i}: candidate_result={candidate_result} "
- f"elapsed={game_elapsed:.1f}s\n")
- log.flush()
+ try:
+ while in_flight:
+ finished, in_flight = wait(in_flight, return_when=FIRST_COMPLETED)
+ for fut in finished:
+ (i, candidate_result, game, game_elapsed,
+ this_candidate_stats, this_head_stats) = fut.result()
+ done += 1
+ candidate_points += candidate_result
+ game_durations.append(game_elapsed)
+ candidate_stats.merge(this_candidate_stats)
+ head_stats.merge(this_head_stats)
+ print(game, file=pgn_out, end="\n\n")
+ pgn_out.flush()
+ if candidate_result == 1.0:
+ wins += 1
+ elif candidate_result == 0.0:
+ losses += 1
+ else:
+ draws += 1
+ if sprt is not None:
+ sprt.update(candidate_result)
+ log.write(f"game {i}: candidate_result={candidate_result} "
+ f"elapsed={game_elapsed:.1f}s\n")
+ log.flush()
- pending[i] = candidate_result
- while next_report_idx in pending:
- reported_points += pending.pop(next_report_idx)
- reported_count += 1
- next_report_idx += 1
+ pending[i] = candidate_result
+ while next_report_idx in pending:
+ reported_points += pending.pop(next_report_idx)
+ reported_count += 1
+ next_report_idx += 1
- if done % 10 == 0:
- avg_game_sec = sum(game_durations) / len(game_durations)
- eta_sec = (avg_game_sec *
- max(len(jobs) - done, 0)) / args.workers
- score = candidate_points / done
- sprt_note = ""
- if sprt is not None:
- sprt_note = (f": llr={sprt.llr():+.2f}, "
- f"H0={sprt.la:+.2f}"
- f"{sprt_bar(sprt.llr(), sprt.la, sprt.lb)}"
- f"H1={sprt.lb:+.2f}")
- print(f"{done} games: -{losses} ={draws} +{wins} "
- f"({avg_game_sec:.1f}s avg, eta={eta_sec/3600:.0f}hrs, "
- f"score={score:.3f}){sprt_note}",
- file=sys.stderr)
+ if done % 10 == 0:
+ avg_game_sec = sum(game_durations) / len(game_durations)
+ eta_sec = (avg_game_sec *
+ max(len(jobs) - done, 0)) / args.workers
+ score = candidate_points / done
+ sprt_note = ""
+ if sprt is not None:
+ sprt_note = (f": llr={sprt.llr():+.2f}, "
+ f"H0={sprt.la:+.2f}"
+ f"{sprt_bar(sprt.llr(), sprt.la, sprt.lb)}"
+ f"H1={sprt.lb:+.2f}")
+ print(f"{done} games: -{losses} ={draws} +{wins} "
+ f"({avg_game_sec:.1f}s avg, eta={eta_sec/3600:.0f}hrs, "
+ f"score={score:.3f}){sprt_note}",
+ file=sys.stderr)
+ print(f" avg depth: candidate={candidate_stats.avg_depth:.2f} "
+ f"head={head_stats.avg_depth:.2f} "
+ f"(nps: candidate={candidate_stats.nps:.0f} "
+ f"head={head_stats.nps:.0f})",
+ file=sys.stderr)
+ print(f" avg ebf: candidate={candidate_stats.avg_ebf:.3f} "
+ f"head={head_stats.avg_ebf:.3f}",
+ file=sys.stderr)
+
+ if sprt is not None and sprt_decision is None:
+ sprt_decision = sprt.decision()
+ if sprt_decision is not None:
+ print(f" SPRT concluded: {sprt_decision} after "
+ f"{sprt.n} games (llr={sprt.llr():+.3f})",
+ file=sys.stderr)
+
+ if sprt_decision is None:
+ while len(in_flight) < args.workers:
+ job = next(job_iter, None)
+ if job is None:
+ break
+ in_flight.add(pool.submit(run, job))
+ # else: stop topping up, just let whatever's already
+ # in_flight finish naturally -- no wasted work beyond games
+ # already launched before the decision landed.
+ except KeyboardInterrupt:
+ print("\n^C caught -- dumping stats for the "
+ f"{done} game(s) completed so far, then exiting "
+ "(not waiting for in-flight games to finish naturally):",
+ file=sys.stderr)
+ print_final_summary(done, candidate_points, wins, draws,
+ losses, candidate_stats, head_stats,
+ sprt, sprt_decision, args)
+ # os._exit, not sys.exit: skips the `with` block's normal
+ # ThreadPoolExecutor teardown (wait=True by default -- would
+ # block until every in-flight game's subprocess-driven game
+ # loop finishes on its own, which is the exact wait a ^C is
+ # trying to avoid) and any atexit/finally cleanup. Log/pgn
+ # writes are already flushed after every completed game
+ # inside the loop, so nothing buffered is lost by skipping
+ # the file objects' own __exit__.
+ os._exit(130)
- if sprt is not None and sprt_decision is None:
- sprt_decision = sprt.decision()
- if sprt_decision is not None:
- print(f" SPRT concluded: {sprt_decision} after "
- f"{sprt.n} games (llr={sprt.llr():+.3f})",
- file=sys.stderr)
+ print_final_summary(done, candidate_points, wins, draws, losses,
+ candidate_stats, head_stats, sprt, sprt_decision,
+ args)
- if sprt_decision is None:
- while len(in_flight) < args.workers:
- job = next(job_iter, None)
- if job is None:
- break
- in_flight.add(pool.submit(run, job))
- # else: stop topping up, just let whatever's already
- # in_flight finish naturally -- no wasted work beyond games
- # already launched before the decision landed.
+def print_final_summary(done, candidate_points, wins, draws, losses,
+ candidate_stats, head_stats, sprt, sprt_decision,
+ args):
+ """Shared by the normal end-of-match path and the ^C handler below --
+ same CANDIDATE_SCORE/CANDIDATE_STATS/HEAD_STATS block either way, just
+ computed from whatever `done` games actually completed."""
n_games = done
score = candidate_points / n_games if n_games else 0.5
elo, err = elo_diff(score, n_games)
diff --git a/src/generate.c b/src/generate.c
index 6f92382..7cc28d0 100644
--- a/src/generate.c
+++ b/src/generate.c
@@ -4847,7 +4847,7 @@ Return value:
pStack->uPly = uPly;
pStack->uEnd[uPly] = pStack->uBegin[uPly];
pStack->sGenFlags[uPly].uAllGenFlags = 0;
-
+
switch(uType)
{
case GENERATE_CAPTURES_PROMS_CHECKS:
@@ -4878,9 +4878,15 @@ Return value:
ASSERT(uReturn);
pStack->sGenFlags[uPly].uKingMoveCount = uReturn >> 16;
pStack->sGenFlags[uPly].uCheckingPieces = uReturn & 0xFF;
+ // Snapshot the true legal-escape count before
+ // _ScoreAllEscapes shrinks uEnd[uPly] to exclude an
+ // already-tried hash move -- see uTrueEscapeCount's
+ // comment in chess.h.
+ pStack->sGenFlags[uPly].uTrueEscapeCount =
+ (UCHAR)MOVE_COUNT(ctx, uPly);
_ScoreAllEscapes(pStack, ctx, mvHash);
break;
-
+
// Note: this is just for plytest / seetest. Just generate
// moves, do not score them.
case GENERATE_DONT_SCORE:
diff --git a/src/move.c b/src/move.c
index 1da5546..f5dab1f 100644
--- a/src/move.c
+++ b/src/move.c
@@ -737,7 +737,7 @@ Return value:
pi->uFifty = pos->uFifty;
pi->fCastled[BLACK] = pos->fCastled[BLACK];
pi->fCastled[WHITE] = pos->fCastled[WHITE];
- pi->uTotalNonPawns = (pos->uNonPawnCount[BLACK][0] +
+ pi->uTotalNonPawns = (pos->uNonPawnCount[BLACK][0] +
pos->uNonPawnCount[WHITE][0]);
ASSERT(IS_VALID_FLAG(pos->fCastled[WHITE]) &&
IS_VALID_FLAG(pos->fCastled[BLACK]));
diff --git a/src/root.c b/src/root.c
index 5b51009..d5780e4 100644
--- a/src/root.c
+++ b/src/root.c
@@ -460,19 +460,9 @@ Return value:
ASSERT(d);
n = (double)(ctx->sCounters.pawnhash.u64Hits);
Trace("Pawn hash hitrate: %5.3f percent.\n", (n/d) * 100.0);
- // EXPERIMENTAL, measurement-only -- see eval.c's Eval() /
- // rgKingSafetyHashProbe.
- d = (double)(ctx->sCounters.kingsafetyhash.u64Probes) + 1;
- ASSERT(d);
- n = (double)(ctx->sCounters.kingsafetyhash.u64Hits);
- Trace("[EXPERIMENTAL] King safety hash: %5.3f%% hitrate, "
- "avg %.1f cyc hit / %.1f cyc miss (%" COMPILER_LONGLONG_UNSIGNED_FORMAT
- " probes)\n",
- (n/d) * 100.0,
- (n > 0 ? (double)ctx->sCounters.kingsafetyhash.u64CyclesHit / n : 0.0),
- ((d - 1 - n) > 0 ?
- (double)ctx->sCounters.kingsafetyhash.u64CyclesMiss / (d - 1 - n) : 0.0),
- ctx->sCounters.kingsafetyhash.u64Probes);
+ // [EXPERIMENTAL] King safety hash hit/miss cycle detail is printed
+ // below, next to the pawn hash cycle breakdown (EVAL_TIME section),
+ // rather than here.
n = (double)(ctx->sCounters.tree.u64NullMoveSuccess);
d = (double)(ctx->sCounters.tree.u64NullMoves) + 1;
ASSERT(d);
@@ -564,35 +554,47 @@ Return value:
double dFull = (double)ctx->sCounters.tree.u64FullEvals;
double dRealTotal = dSuperLazy + dLazy + dFull;
d = dRealTotal + 1;
- Trace("Eval exit breakdown: (%5.2f%% super lazy, %5.2f%% lazy, "
- "%5.2f%% full)\n",
- (dSuperLazy / d) * 100.0,
- (dLazy / d) * 100.0,
- (dFull / d) * 100.0);
#ifdef EVAL_TIME
{
UINT64 u64SLCyc = ctx->sCounters.tree.u64CyclesSuperLazyExit;
UINT64 u64LCyc = ctx->sCounters.tree.u64CyclesLazyExit;
UINT64 u64FCyc = ctx->sCounters.tree.u64CyclesFullEvalExit;
UINT64 u64AllCyc = ctx->sCounters.tree.u64CyclesInEval;
+ // Eval exit breakdown (call-count share) folded in here
+ // alongside the cycle-cost share, so both facets of "how
+ // much does each exit path matter" live in one place
+ // instead of two separate, easy-to-miss Trace blocks.
Trace("Avg. cpu cycles in eval, by exit path:\n"
- " super lazy: %8.1f (%5.1f%% of total eval cycles)\n"
- " lazy: %8.1f (%5.1f%% of total eval cycles)\n"
- " full: %8.1f (%5.1f%% of total eval cycles)\n"
- " overall: %8.1f\n",
+ " super lazy: %8.1f cyc/call (%5.2f%% of calls, %5.1f%% of total eval cycles)\n"
+ " lazy: %8.1f cyc/call (%5.2f%% of calls, %5.1f%% of total eval cycles)\n"
+ " full: %8.1f cyc/call (%5.2f%% of calls, %5.1f%% of total eval cycles)\n"
+ " overall: %8.1f cyc/call\n",
(dSuperLazy ? (double)u64SLCyc / dSuperLazy : 0.0),
+ (dSuperLazy / d) * 100.0,
(u64AllCyc ? 100.0 * (double)u64SLCyc / (double)u64AllCyc : 0.0),
(dLazy ? (double)u64LCyc / dLazy : 0.0),
+ (dLazy / d) * 100.0,
(u64AllCyc ? 100.0 * (double)u64LCyc / (double)u64AllCyc : 0.0),
(dFull ? (double)u64FCyc / dFull : 0.0),
+ (dFull / d) * 100.0,
(u64AllCyc ? 100.0 * (double)u64FCyc / (double)u64AllCyc : 0.0),
(dRealTotal ? (double)u64AllCyc / dRealTotal : 0.0));
}
+#else
+ Trace("Eval exit breakdown: (%5.2f%% super lazy, %5.2f%% lazy, "
+ "%5.2f%% full)\n",
+ (dSuperLazy / d) * 100.0,
+ (dLazy / d) * 100.0,
+ (dFull / d) * 100.0);
#endif
}
#endif
Trace("Extensions: (%u +, %u q+, %u 1mv, %u !kmvs, %u mult+, %u pawn\n"
- " %u threat, %u zug, %u sing, %u endg, %u bm, %u recap)\n",
+ " %u threat, %u zug, %u sing, %u endg, %u bm, %u recap)\n"
+ " %u / %llu qsearch nodes (%.1f%%) were in-check "
+ "evasion nodes\n"
+ " too-deep bailout: %llu ply-limit, %llu "
+ "qsearch-hard-extend-limit\n",
ctx->sCounters.extension.uCheck,
ctx->sCounters.extension.uQExtend,
ctx->sCounters.extension.uOneLegalMove,
@@ -604,7 +606,14 @@ Return value:
ctx->sCounters.extension.uSingularReplyToCheck,
ctx->sCounters.extension.uEndgame,
ctx->sCounters.extension.uBotvinnikMarkoff,
- ctx->sCounters.extension.uRecapture);
+ ctx->sCounters.extension.uRecapture,
+ ctx->sCounters.extension.uQExtendChaseNodes,
+ ctx->sCounters.tree.u64QNodeCount,
+ (ctx->sCounters.tree.u64QNodeCount ?
+ (100.0 * ctx->sCounters.extension.uQExtendChaseNodes /
+ (double)ctx->sCounters.tree.u64QNodeCount) : 0.0),
+ ctx->sCounters.tree.u64TooDeepPlyLimit,
+ ctx->sCounters.tree.u64TooDeepQsearchExtendLimit);
#ifdef EVAL_TIME
{
//
@@ -626,64 +635,102 @@ Return value:
//
// u64PreLazy already contains u64Pawns as a subset (pawns
// runs nested inside the pre-lazy segment, not after it) --
- // split it out here rather than double-counting both.
+ // it's used below only for u64Named's total, not displayed
+ // directly (its sub-segments are each measured and displayed
+ // on their own: super-lazy, material setup, passer/badtrade/
+ // bishop, pawns, lazy decision).
//
UINT64 u64PreLazy = ctx->sCounters.tree.u64CyclesEvalPreLazy;
- UINT64 u64PreLazyOther = (u64PreLazy >= u64Pawns) ?
- (u64PreLazy - u64Pawns) : 0;
UINT64 u64PostMisc = ctx->sCounters.tree.u64CyclesEvalPostLazyMisc;
UINT64 u64AttackPop = ctx->sCounters.tree.u64CyclesEvalAttackTablePop;
+ UINT64 u64Drawish = ctx->sCounters.tree.u64CyclesEvalDrawish;
UINT64 u64Named = (u64PreLazy + u64Knight + u64Bishop +
u64Rook + u64Queen + u64King + u64PostMisc +
- u64AttackPop);
+ u64AttackPop + u64Drawish);
UINT64 u64Total = ctx->sCounters.tree.u64CyclesInEval;
UINT64 u64Unaccounted = (u64Total >= u64Named) ?
(u64Total - u64Named) : 0;
+ UINT64 u64LazyDecision = ctx->sCounters.tree.u64CyclesEvalLazyDecision;
+ UINT64 u64CKSD = ctx->sCounters.tree.u64CyclesEvalCountKingSafetyDefects;
+ UINT64 u64SuperLazy = ctx->sCounters.tree.u64CyclesEvalSuperLazy;
+ UINT64 u64MaterialSetup = ctx->sCounters.tree.u64CyclesEvalMaterialSetup;
+ UINT64 u64PasserBadtradeBishop =
+ ctx->sCounters.tree.u64CyclesEvalPasserBadtradeBishop;
- Trace(" eval breakdown (%% of total cycles in eval):\n");
- Trace(" pre-lazy (always paid): %5.1f%% "
- "(of which pawns: %5.1f%%, other pre-lazy: %5.1f%%)\n",
- (u64Total ? (100.0 * (double)u64PreLazy / (double)u64Total) : 0.0),
- (u64Total ? (100.0 * (double)u64Pawns / (double)u64Total) : 0.0),
- (u64Total ? (100.0 * (double)u64PreLazyOther / (double)u64Total) : 0.0));
+ //
+ // Reorganized (2026-09-09) into one major section per exit
+ // path, each listing its own segments in the chronological
+ // order Eval() actually executes them, since a flat dump of
+ // every segment side by side no longer maps to which exit
+ // path pays for what -- a super-lazy exit call never reaches
+ // pawns/pieces at all, and a lazy exit call never reaches the
+ // per-piece/attack-table segments. Percentages are still each
+ // segment's share of u64Total (total cycles across every
+ // Eval() call of any exit path), not renormalized per
+ // section, since these segments' own counters are accumulated
+ // across all calls that reach them (not split out per exit
+ // path) -- e.g. "pawns" below is paid by both lazy-exit and
+ // full-eval calls combined.
+ //
+ Trace("eval breakdown (%% of total cycles in eval), "
+ "by exit-path phase, chronological within each:\n");
+ Trace(" [super-lazy] (paid by every call)\n");
+ Trace(" material/army-scaler setup: %5.1f%%\n",
+ (u64Total ? (100.0 * (double)u64MaterialSetup / (double)u64Total) : 0.0));
+ Trace(" super-lazy material check: %5.1f%%\n",
+ (u64Total ? (100.0 * (double)u64SuperLazy / (double)u64Total) : 0.0));
+ Trace(" [lazy+] (paid by lazy-exit and full-eval calls)\n");
+ Trace(" pawns: %5.1f%%\n",
+ (u64Total ? (100.0 * (double)u64Pawns / (double)u64Total) : 0.0));
+ Trace(" passer-races/bad-trades/bishop-pairs: %5.1f%%\n",
+ (u64Total ? (100.0 * (double)u64PasserBadtradeBishop / (double)u64Total) : 0.0));
+ Trace(" lazy gate + EstimatePositionalScore: %5.1f%% "
+ "(of which CountKingSafetyDefects: %5.1f%%)\n",
+ (u64Total ? (100.0 * (double)u64LazyDecision / (double)u64Total) : 0.0),
+ (u64Total ? (100.0 * (double)u64CKSD / (double)u64Total) : 0.0));
+ Trace(" [full-only] (paid only by full-eval calls)\n");
+ Trace(" attack-table population: %5.1f%%\n",
+ (u64Total ? (100.0 * (double)u64AttackPop / (double)u64Total) : 0.0));
Trace(" knight: %5.1f%% bishop: %5.1f%% rook: %5.1f%%\n",
(u64Total ? (100.0 * (double)u64Knight / (double)u64Total) : 0.0),
(u64Total ? (100.0 * (double)u64Bishop / (double)u64Total) : 0.0),
(u64Total ? (100.0 * (double)u64Rook / (double)u64Total) : 0.0));
- Trace(" queen: %5.1f%% king: %5.1f%% post-lazy misc: %5.1f%%\n",
+ Trace(" queen: %5.1f%% king: %5.1f%%\n",
(u64Total ? (100.0 * (double)u64Queen / (double)u64Total) : 0.0),
- (u64Total ? (100.0 * (double)u64King / (double)u64Total) : 0.0),
+ (u64Total ? (100.0 * (double)u64King / (double)u64Total) : 0.0));
+ Trace(" post-lazy misc (passers/danger/trapped/scalers): %5.1f%%\n",
(u64Total ? (100.0 * (double)u64PostMisc / (double)u64Total) : 0.0));
- Trace(" attack-table pop: %5.1f%% unaccounted: %5.1f%%\n",
- (u64Total ? (100.0 * (double)u64AttackPop / (double)u64Total) : 0.0),
+ Trace(" drawish-situation scaling: %5.1f%%\n",
+ (u64Total ? (100.0 * (double)u64Drawish / (double)u64Total) : 0.0));
+ // "Unaccounted" here is mostly EVAL_TIME's own instrumentation
+ // overhead, not missing eval work: each per-piece
+ // TIMED_EVAL_CALL (knight/bishop/rook/queen/king) reads the
+ // cycle counter twice, and on a full board that's 2 reads x
+ // up to ~16 pieces per Eval() call. A one-off diagnostic
+ // (2026-09-09, since reverted) wrapped the whole piece-eval
+ // section in a single outer timer and compared it against the
+ // sum of the per-piece buckets -- the difference (i.e. the
+ // extra reads' own cost, not algorithmic eval time) accounted
+ // for the large majority of this percentage on a full board;
+ // true untimed code (bit-scan loop control, small setup/tail
+ // arithmetic) was a small remainder. Re-run that diagnostic
+ // (wrap the piece-eval loops end to end in one blanket timer,
+ // diff against the named per-piece sum) before assuming a
+ // change to this number reflects a real difference in eval
+ // cost.
+ Trace(" unaccounted: %5.1f%%\n",
(u64Total ? (100.0 * (double)u64Unaccounted / (double)u64Total) : 0.0));
{
- UINT64 u64LazyDecision = ctx->sCounters.tree.u64CyclesEvalLazyDecision;
- UINT64 u64CKSD = ctx->sCounters.tree.u64CyclesEvalCountKingSafetyDefects;
- UINT64 u64Storm = ctx->sCounters.tree.u64CyclesEvalFileStormDefects;
- UINT64 u64SuperLazy = ctx->sCounters.tree.u64CyclesEvalSuperLazy;
- UINT64 u64PreLazyRest = (u64PreLazyOther >= u64LazyDecision) ?
- (u64PreLazyOther - u64LazyDecision) : 0;
- u64PreLazyRest = (u64PreLazyRest >= u64SuperLazy) ?
- (u64PreLazyRest - u64SuperLazy) : 0;
- Trace(" -- of which, pre-lazy breakdown --\n");
- Trace(" super lazy check: %5.1f%%\n",
- (u64Total ? (100.0 * (double)u64SuperLazy / (double)u64Total) : 0.0));
- Trace(" material/passers/badtrades/bishoppairs: %5.1f%%\n",
- (u64Total ? (100.0 * (double)u64PreLazyRest / (double)u64Total) : 0.0));
- Trace(" lazy gate + EstimatePositionalScore: %5.1f%% "
- "(of which CountKingSafetyDefects: %5.1f%%, "
- "_GetFileStormDefects: %5.1f%%)\n",
- (u64Total ? (100.0 * (double)u64LazyDecision / (double)u64Total) : 0.0),
- (u64Total ? (100.0 * (double)u64CKSD / (double)u64Total) : 0.0),
- (u64Total ? (100.0 * (double)u64Storm / (double)u64Total) : 0.0));
- }
- {
UINT64 u64PHits = ctx->sCounters.tree.u64CountEvalPawnsHit;
UINT64 u64PMisses = ctx->sCounters.tree.u64CountEvalPawnsMiss;
UINT64 u64PHitCycles = ctx->sCounters.tree.u64CyclesEvalPawnsHit;
UINT64 u64PMissCycles = ctx->sCounters.tree.u64CyclesEvalPawnsMiss;
- Trace(" pawn hash: %" COMPILER_LONGLONG_UNSIGNED_FORMAT
+ UINT64 u64KSProbes = ctx->sCounters.kingsafetyhash.u64Probes;
+ UINT64 u64KSHits = ctx->sCounters.kingsafetyhash.u64Hits;
+ UINT64 u64KSMisses = (u64KSProbes >= u64KSHits) ?
+ (u64KSProbes - u64KSHits) : 0;
+ Trace("hash probes (avg cycles per probe):\n");
+ Trace(" pawn hash: %" COMPILER_LONGLONG_UNSIGNED_FORMAT
" hits (avg %.1f cyc), %" COMPILER_LONGLONG_UNSIGNED_FORMAT
" misses (avg %.1f cyc), hit rate %.2f%%\n",
u64PHits,
@@ -693,6 +740,22 @@ Return value:
((u64PHits + u64PMisses) ?
(100.0 * (double)u64PHits / (double)(u64PHits + u64PMisses)) :
0.0));
+ // Keeping this counter around: see eval.c's Eval() /
+ // rgKingSafetyHashProbe.
+ Trace(" king safety hash: %"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT
+ " hits (avg %.1f cyc), %"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT
+ " misses (avg %.1f cyc), hit rate %.2f%%\n",
+ u64KSHits,
+ (u64KSHits ? (double)ctx->sCounters.kingsafetyhash.u64CyclesHit /
+ (double)u64KSHits : 0.0),
+ u64KSMisses,
+ (u64KSMisses ? (double)ctx->sCounters.kingsafetyhash.u64CyclesMiss /
+ (double)u64KSMisses : 0.0),
+ (u64KSProbes ?
+ (100.0 * (double)u64KSHits / (double)u64KSProbes) :
+ 0.0));
}
}
#endif
@@ -1083,14 +1146,23 @@ _IterateSetSearchGlobals(ULONG uDepth)
// For use in scaling back extensions that are very far from
// the root.
//
- // Iterate g_Soft g_Hard
- // | | | | | | | | |
- // 0 1 2 2.5 3 3.5 4 (x Iterate)
- // |-------------------|----|----|---------|----...
- // |full full full full|-1/4|-1/2|-3/4 -3/4|none...
+ // 2026-09-10: onset moved in from 2x/4x iterate depth to 1.5x/3x
+ // (a uniform 0.75x scaling of the old 2x..4x span) -- a forcing
+ // chain of check/singular-reply extensions could previously run
+ // completely unthrottled out to double the nominal search depth
+ // before any taper at all kicked in, which is exactly the shape
+ // of runaway seen on check-heavy positions (see CLAUDE.md's
+ // 2026-09 extension-tuning notes). Same taper shape, just starts
+ // sooner.
+ //
+ // Iterate g_Soft g_Hard
+ // | | | | | | | |
+ // 0 1 1.5 1.875 2.25 3 (x Iterate)
+ // |----------------|-----|-----|-------|----...
+ // |full full full |-1/4 |-1/2 |-3/4 |none...
//
- g_uSoftExtendLimit = g_uIterateDepth * 2;
- g_uHardExtendLimit = g_uIterateDepth * 4;
+ g_uSoftExtendLimit = g_uIterateDepth * 3 / 2;
+ g_uHardExtendLimit = g_uIterateDepth * 3;
// g_uExtensionReduction[]'s own bands, below, must stay reachable
// within [0, MAX_PLY_PER_SEARCH) regardless of how deep g_uIterateDepth
@@ -1106,19 +1178,19 @@ _IterateSetSearchGlobals(ULONG uDepth)
// it would be an unrelated, unintended behavior change.
{
ULONG uEffIterateDepth = MIN(g_uIterateDepth, (MAX_PLY_PER_SEARCH - 4) / 4);
- ULONG uEffSoftLimit = uEffIterateDepth * 2;
- ULONG uEffHardLimit = uEffIterateDepth * 4;
+ ULONG uEffSoftLimit = uEffIterateDepth * 3 / 2;
+ ULONG uEffHardLimit = uEffIterateDepth * 3;
for (u = 0; u < MAX_PLY_PER_SEARCH; u++)
{
if (u < uEffSoftLimit)
{
g_uExtensionReduction[u] = 0;
}
- else if (u < (uEffSoftLimit + uEffIterateDepth / 2))
+ else if (u < (uEffSoftLimit + (uEffIterateDepth * 3 / 8)))
{
g_uExtensionReduction[u] = QUARTER_PLY;
}
- else if (u < (uEffSoftLimit + uEffIterateDepth))
+ else if (u < (uEffSoftLimit + (uEffIterateDepth * 3 / 4)))
{
g_uExtensionReduction[u] = HALF_PLY;
}
diff --git a/src/run_tests.sh b/src/run_tests.sh
index 7e3a053..28e8ffe 100755
--- a/src/run_tests.sh
+++ b/src/run_tests.sh
@@ -20,6 +20,7 @@ fi
# positions solve within a fixed cost. Override via SD/SN env vars.
SD="${SD:-10}"
SN="${SN:-5000000}"
+SN_LABEL="${SN_LABEL:-5m}"
/bin/rm -rf "${LOGDIR}"
mkdir -p "${LOGDIR}"
@@ -92,7 +93,7 @@ for suite in "${SUITES[@]}"; do
echo
echo "----[ $suite sn=${SN} ]------------------------------------------------------------------"
print_stats_table \
- <(sed -n '/correct solutions/,/script time/p' "$HEAD_LOGDIR/sn${SN}_${suite}.out") \
+ <(sed -n '/correct solutions/,/script time/p' "$HEAD_LOGDIR/sn${SN_LABEL}_${suite}.out") \
<(sed -n '/correct solutions/,/script time/p' "$LOGDIR/sn${SN}_${suite}.out")
echo
done
diff --git a/src/search.c b/src/search.c
index fae2567..55d2006 100644
--- a/src/search.c
+++ b/src/search.c
@@ -1563,6 +1563,32 @@ _ShouldWeConsiderThisMove(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.
+//
+// 2026-09-11: also reused (unmodified) by QSearchFromCheckNoStandPat's
+// own stand-pat-safety-net prune below -- moved up here so both
+// functions can share one definition instead of duplicating/guessing
+// a second margin.
+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:
@@ -1596,9 +1622,7 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
SCORE iBestScore = MATED_SCORE(ctx->uPly);
SCORE iScore;
MOVE mv;
-#if defined(DEBUG) || defined(PERF_COUNTERS)
ULONG uLegalMoves = 0;
-#endif
ULONG uQsearchCheckExtension = 0;
ASSERT(InCheck(pos, pos->uToMove));
@@ -1614,9 +1638,9 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
(pf->fCouldStandPat[pos->uToMove] == FALSE) &&
(CountKingSafetyDefects(pos, pos->uToMove) > 4))
{
- if ((uMoveCount == 1) ||
- (NUM_KING_MOVES(ctx, ctx->uPly) == 0) ||
- (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1))
+ if (ONE_LEGAL_MOVE(ctx, ctx->uPly) ||
+ (NUM_KING_MOVES(ctx, ctx->uPly) == 0) ||
+ (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1))
{
uQsearchCheckExtension = 2;
INC(ctx->sCounters.extension.uQExtend);
@@ -1625,6 +1649,24 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
}
}
+ // If we could have stood pat at an ancestor node, though, then any
+ // work we do here to determine "how bad" things really are is very
+ // likely to be wasted effort (since, at the ancestor node, side to
+ // move can stand pat instead of take this line's _bad_ score).
+ //
+ // iEval itself is computed lazily below, on the first quiet
+ // (non-checking) move actually reached, rather than unconditionally
+ // here -- Eval() is a real cost (full/lazy/super-lazy tiers, not
+ // free), and _ScoreAllEscapes tiers captures/checks above quiet
+ // moves, so a beta cutoff on an earlier capture/check is common and
+ // means this never needs to run at all. Self-play measurement
+ // (avg depth/nps regression vs HEAD under equal time) showed this
+ // call's own cost was eating into the node savings this whole
+ // mechanism exists to redirect elsewhere.
+ SCORE rgiPositional[2];
+ SCORE iEval = 0;
+ FLAG fEvalComputed = FALSE;
+
for (x = ctx->sMoveStack.uBegin[ctx->uPly];
x < ctx->sMoveStack.uEnd[ctx->uPly];
x++)
@@ -1637,13 +1679,44 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
ctx->sMoveStack.mvf[x].bvFlags |= MVF_MOVE_SEARCHED;
#endif
- // Note: no selectivity at in-check nodes; search every reply.
- // IDEA: prune if the side in check could have stood pat before.
+ // See above... this is an effort to avoid wasting time
+ // figuring out exactly "how bad" things get here if side to
+ // move already has a "get out of jail free" card in their
+ // pocket (in the form of a stand pat at some ancestor
+ // position).
+ if (TRUE == pf->fCouldStandPat[pos->uToMove] &&
+ uLegalMoves > 0 &&
+ !IS_CHECKING_MOVE(mv))
+ {
+ if (FALSE == fEvalComputed)
+ {
+ iEval = Eval(ctx, iAlpha, iBeta, &rgiPositional);
+ ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT);
+ fEvalComputed = TRUE;
+ }
+
+ // Same shape (and same calibrated per-tier margin) as
+ // normal QSearch's own futility gate below -- see
+ // FUTILITY_BASE_MARGIN_BY_SOURCE's derivation comment.
+ // That margin was calibrated against *ordinary* quiet
+ // positions; resolving a check can swing the eval more
+ // than an ordinary quiet move can (a king escaping to a
+ // genuinely safe square vs. one that only looks escaped),
+ // so this reuses the same margin rather than inventing a
+ // separate, unvalidated one -- better to be at least as
+ // conservative as the calibrated case than to guess looser.
+ if (iEval < iAlpha &&
+ ((iEval + PIECE_VALUE(mv.pCaptured)) + rgiPositional[pos->uToMove] +
+ FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource]) < iAlpha)
+ {
+ continue;
+ }
+ }
+
if (MakeMove(ctx, mv))
{
-#if defined(DEBUG) || defined(PERF_COUNTERS)
uLegalMoves++;
-#endif
+
pf->uQsearchNodes++;
pf->uQsearchDepth++;
ASSERT(uQsearchCheckExtension < 3);
@@ -1692,27 +1765,6 @@ 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:
@@ -1823,6 +1875,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
if (IS_CHECKING_MOVE(mvLast))
{
ASSERT(InCheck(pos, pos->uToMove));
+ INC(ctx->sCounters.extension.uQExtendChaseNodes);
iBestScore = QSearchFromCheckNoStandPat(ctx, iAlpha, iBeta);
goto end;
}
diff --git a/src/searchsup.c b/src/searchsup.c
index 89ad729..b4b9d1f 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -50,6 +50,32 @@ Return value:
ASSERT(u < MAX_PLY_PER_SEARCH);
this->PV[u] = mv;
u++;
+
+ // HASHMOVE/RECOGNMOVE/DRAWMOVE/MATEMOVE are sentinels, not real
+ // moves -- WalkPV (util.c) treats any of them as a hard PV
+ // truncation point and asserts the slot right after one is
+ // always a zero terminator. Copying the child ply's PV tail
+ // forward below would instead leave whatever move happens to
+ // already be sitting in that reused sPlyInfo slot (sPlyInfo[] is
+ // shared across every node that ever occupies this ply, never
+ // zeroed between unrelated nodes) -- if some earlier, unrelated
+ // node at this same ply also once wrote the same sentinel there
+ // and nothing since overwrote it, that stale leftover reproduces
+ // the sentinel value a second time in a row and trips the assert
+ // (util.c:1093, found via debug_smoke_test.sh under MP). Hard-
+ // terminate immediately instead of inheriting the child's tail.
+ if ((mv.uMove == HASHMOVE.uMove) ||
+ (mv.uMove == RECOGNMOVE.uMove) ||
+ (mv.uMove == DRAWMOVE.uMove) ||
+ (mv.uMove == MATEMOVE.uMove))
+ {
+ if (u < MAX_PLY_PER_SEARCH)
+ {
+ this->PV[u].uMove = 0;
+ }
+ return;
+ }
+
while(u < MAX_PLY_PER_SEARCH)
{
this->PV[u] = next->PV[u];
@@ -987,9 +1013,16 @@ Return value:
// the past they will call MakeMove this must cut off one ply
// before the hard limit.
//
- if ((ctx->uPly >= (MAX_PLY_PER_SEARCH - 1)) ||
- (ctx->sSearchFlags.uQsearchDepth >= g_uHardExtendLimit))
+ if (ctx->uPly >= (MAX_PLY_PER_SEARCH - 1))
+ {
+ INC(ctx->sCounters.tree.u64TooDeepPlyLimit);
+ *piScore = *piBeta;
+ DTTRACE("<toodeep/>");
+ goto end;
+ }
+ if (ctx->sSearchFlags.uQsearchDepth >= g_uHardExtendLimit)
{
+ INC(ctx->sCounters.tree.u64TooDeepQsearchExtendLimit);
*piScore = *piBeta;
DTTRACE("<toodeep/>");
goto end;