diff options
| author | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-10 23:12:04 -0700 |
| commit | 8a4985116ae6536db3f77b8ca8c81d7e8f73843d (patch) | |
| tree | 7ac75d1ab593b2521045ce9c5ab66d8e1af9ccf3 /src | |
| parent | 9d1e32db698855b939c4bc5df6b6a82509dd381c (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
Diffstat (limited to 'src')
| -rwxr-xr-x | src/chess.h | 51 | ||||
| -rw-r--r-- | src/eval.c | 78 | ||||
| -rwxr-xr-x | src/eval_tune/match_play.py | 165 | ||||
| -rw-r--r-- | src/generate.c | 10 | ||||
| -rw-r--r-- | src/move.c | 2 | ||||
| -rw-r--r-- | src/root.c | 214 | ||||
| -rwxr-xr-x | src/run_tests.sh | 3 | ||||
| -rw-r--r-- | src/search.c | 113 | ||||
| -rw-r--r-- | src/searchsup.c | 37 |
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 @@ -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: @@ -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])); @@ -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; |
