diff options
| author | Scott Gasch <[email protected]> | 2026-09-13 15:03:09 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-13 15:03:09 -0700 |
| commit | 78d1889fe38db1fd45b58509c8b3bd535ce691b7 (patch) | |
| tree | 994a213816c19c6654faf89eca74880f0fa79f47 /src/chess.h | |
| parent | d2699c47d6d0abf0f018e5ba37d4f1bfe05ecd33 (diff) | |
Fix several MP/qsearch correctness bugs found by auditing ctx->uPly
indexing and per-thread state across MakeMove/recursion boundaries;
add a position-signature stamp so full-eval-only data can be validated
cheaply instead of approximated.
- chess.h/searchsup.c/search.c/root.c: add NODE_TYPE (PV/CUT/ALL)
classification, set centrally in CommonSearchInit, replacing the old
binary fPvNode flag. Fixes split.c's node-type check reading stale
data on helper threads (they never call CommonSearchInit for the
split-point ply) and removes a redundant, always-true parent-type
check in search.c's own LMR gate.
- split.c: StartParallelSearch now copies the split point's eNodeType
into SPLIT_INFO for helper threads to read (mirroring the existing
iEval/iImprovement pattern); a split-node fail-high is now detected
explicitly in search.c's move loop instead of falling through to the
generic no_more_moves classifier, which misread it as a fail-low
(skipping killer/history updates and storing an inverted-sense upper
bound instead of a lower bound); ThreadUnderTerminatingSplit now
checks all nested split pointers instead of just the first, so a
thread notices when an inner split terminates while it's still
helping an outer one; MAX_SPLITS bumped 20->64 for higher thread
counts.
- chess.h/searchsup.c/search.c: convert
SEARCHER_THREAD_CONTEXT.uLastPositionalSource (a single per-thread
scalar) to PLY_INFO.uLastPositionalSource (per-ply). The scalar
version was clobbered whenever QSearch's own move loop recursed
before a later iteration read it back, corrupting which qsearch
futility margin got applied; also fixes the same hazard in the
newly-added check-evasion futility prune.
- chess.h/generate.c/searchsup.c: generalize move-scoring interpretation
via a new MOVE_SCORING_SCHEME tag (per-ply, set by whichever
generate.c routine last scored that ply's moves) and a single shared
DecodeMoveValueEstimate, replacing scheme-blind decode logic in
ComputeMoveScore and _ShouldWeConsiderThisMove.
- chess.h/eval.c/dynamic.c: stamp PLY_INFO.u64FullEvalSig with the
position signature whenever a node completes a full (non-lazy) Eval()
call. cEnprise/pEnprise/cTrapped/pTrapped/iKingScore/the eval-scoped
bb*Attacks bitboards are only valid when this matches the position's
live signature; _EnpriseSlotValid/_TrappedSlotValid now compare sigs
directly instead of re-checking piece identity on the live board.
- searchsup.c: CommonSearchInit clears pi->mvBest on both its IsDraw and
too-deep early-exit paths to prevent a later sibling node from reading
stale mvBest data left by an unrelated earlier visit to the same ply.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C9r36BydPnfpyYLFhkTo6d
Diffstat (limited to 'src/chess.h')
| -rwxr-xr-x | src/chess.h | 179 |
1 files changed, 159 insertions, 20 deletions
diff --git a/src/chess.h b/src/chess.h index 5f9f10c..2681701 100755 --- a/src/chess.h +++ b/src/chess.h @@ -688,6 +688,19 @@ typedef struct _POSITION // Bishop's x-ray chains arbitrarily deep, same as rook's/queen's, // not the single-hop approximation it originally shipped with (see // EVAL.md's progress log for why that was revisited). + // + // REQUIRES SIG MATCH before any reader outside eval.c trusts these: + // compare ctx->sPlyInfo[ctx->uPly].u64FullEvalSig (PLY_INFO, chess.h) + // against this position's own live (u64NonPawnSig ^ u64PawnSig). + // On a lazy/super-lazy Eval() exit these are left completely + // unpopulated -- not merely stale, genuinely never cleared or + // written that call (see Eval()'s own pawn-eval-deferred comment) -- + // and even on a full eval they describe whatever position is + // current in ctx->sPosition at the moment Eval() returns, which + // stops being true the instant the next MakeMove runs. Currently + // only read from inside the same Eval() call that wrote them + // (_EvalKing, _WhoControlsSquareFast); any future reader elsewhere + // (search.c, see.c, generate.c) must sig-check first. BITBOARD bbPawnAttacks[2]; BITBOARD bbMinorAttacks[2]; BITBOARD bbMinorXrayAttacks[2]; @@ -941,6 +954,19 @@ typedef struct _COUNTERS UINT64 u64TooDeepPlyLimit; UINT64 u64TooDeepQsearchExtendLimit; UINT64 u64LeafCount; + // How often a node's actual outcome contradicts its assumed + // NODE_TYPE (chess.h) -- a real, if imperfect, sanity check on + // the classic PVS node typing. Counted at every Search()/ + // QSearch() exit, using the window it was actually asked to + // search (iAlpha/iBeta at that point, not whatever the initial + // caller-supplied window was, since mate-distance pruning can + // narrow it before this node's own outcome is known). Some + // "surprises" (e.g. an immediate hash-table cutoff) are + // legitimate and expected, not actual instability, so these + // counts are a rough signal, not a bug indicator by themselves. + UINT64 u64PVNodeSurprised; // PV node failed high or low + UINT64 u64AllNodeFailedHigh; // ALL node (expected fail-low) failed high + UINT64 u64CutNodeFailedLow; // CUT node (expected fail-high) failed low UINT64 u64TerminalPositionCount; UINT64 u64BetaCutoffs; UINT64 u64BetaCutoffsOnFirstMove; @@ -1138,6 +1164,26 @@ typedef struct _CUMULATIVE_SEARCH_FLAGS } CUMULATIVE_SEARCH_FLAGS; +// Classic PVS node typing. PV: this node's own window was wide +// (iBeta != iAlpha + 1), computed for free from the window it was +// called with. CUT/ALL only matter when NOT PV, and can't be derived +// from window width alone (both are null-window scouts) -- set +// centrally in CommonSearchInit (searchsup.c, covers both Search() and +// QSearch() from one place) per the standard alternation: the first +// child of a PV node is PV; every other child of a PV node is CUT +// (expected to fail high); the first child of a CUT node is ALL +// (expected to fail low, must search everything); children of an ALL +// node are CUT again. A narrow window's type therefore depends only on +// the PARENT's own type, not on which move number got us here -- no +// need to thread "was this the first move" through the caller. +typedef enum +{ + NODE_PV, + NODE_CUT, + NODE_ALL +} +NODE_TYPE; + // ---------------------------------------------------------------------- // // Searcher thread record @@ -1167,6 +1213,18 @@ typedef struct _SPLIT_INFO SCORE iBeta; // beta at split SCORE iEval; // static eval at split SCORE iImprovement; // eval improvement since ply-2 (may be negative) + NODE_TYPE eNodeType; // the split point's own node type -- + // copied here rather than read off a + // helper thread's own ctx->sPlyInfo, + // since a helper thread never actually + // calls Search()/CommonSearchInit for + // the split-point ply itself (it just + // replays MakeMove along mvPathToHere + // to reconstruct board state), so that + // slot in ITS OWN persistent context is + // always stale leftover data from + // whatever unrelated node that thread + // last helped with CUMULATIVE_SEARCH_FLAGS sSearchFlags; // flags at split time // output from the split node @@ -1182,6 +1240,37 @@ typedef struct _SPLIT_INFO } SPLIT_INFO; +// Which of several distinct move-scoring conventions currently +// populates this ply's sMoveStack[].iValue -- generate.c's four +// schemes each pack tier-flag bits (SORT_THESE_FIRST, FIRST_KILLER, +// etc.) the same way, so a raw tier/leftover classification +// (iValue < GOOD_MOVE) already works correctly regardless of which of +// those four is active. What does NOT generalize is treating iValue +// as a material/eval-axis estimate: each scheme bakes in different +// ordering-only bias terms on top of the real SEE/material value (see +// ComputeMoveScore's DecodeMoveValueEstimate, searchsup.c), so any +// caller that needs an actual value (not just a tier) must know which +// scheme it's decoding. MOVE_SCORING_ROOT and MOVE_SCORING_IID_RESCORED +// aren't tier-flag-encoded at all -- both are already real quantities +// (a node-count/score composite, or a genuine searched eval-axis +// score respectively), never meaningful as a generate.c-style tier. +typedef enum +{ + MOVE_SCORING_NORMAL, // _ScoreAllMoves + MOVE_SCORING_ESCAPE, // _ScoreAllEscapes + MOVE_SCORING_QSEARCH_CHECKS, // _ScoreQSearchMovesInclChecks + MOVE_SCORING_QSEARCH_NO_CHECKS, // _ScoreQSearchMovesNoChecks + MOVE_SCORING_IID_RESCORED, // RescoreMovesViaSearch -- a real + // eval-axis score already, trust + // iValue as-is + MOVE_SCORING_ROOT // root.c's own next-iteration + // move-ordering composite + // ((subtree node count >> 9, + // capped) + iScore) -- only ever + // populates ply 0's move stack +} +MOVE_SCORING_SCHEME; + typedef struct _PLY_INFO { #ifdef DEBUG @@ -1193,17 +1282,55 @@ typedef struct _PLY_INFO INT iExtensionAmount; FLAG fInCheck; FLAG fInQsearch; - FLAG fPvNode; // this node's own window was wide - FLAG fMovesRescoredByIID; // sMoveStack[uPly].iValue holds - // real search scores from - // RescoreMovesViaSearch, not - // generate.c's ordering encoding + NODE_TYPE eNodeType; // classic PVS node typing -- + // see the enum's own comment + MOVE_SCORING_SCHEME eMoveScoringScheme; // which convention scored + // sMoveStack[uPly] -- see + // the enum's own comment MOVE mv; MOVE mvBest; MOVE PV[MAX_PLY_PER_SEARCH]; - SCORE iKingScore[2]; + // Stamped with (pos->u64NonPawnSig ^ pos->u64PawnSig) the instant a + // full (not lazy/super-lazy) Eval() call finishes for the position + // currently sitting at this ply -- see Eval()'s own comment near + // where it's written. A handful of fields below are only ever + // populated on that same full-eval path (iKingScore) or only + // trustworthy when read back against the exact position that set + // them (ctx->cEnprise/pEnprise/cTrapped/pTrapped, indexed by ply + // alongside this struct -- see chess.h's SEARCHER_THREAD_CONTEXT). + // Every such field is marked "REQUIRES SIG MATCH" below; before + // trusting one, compare this stamp against the CURRENT position's + // live signature (NOT against u64Sig just below -- that field holds + // a different thing, the position last recorded as this ply was + // being LEFT via SaveCurrentPositionHistoryInContext, not the + // position currently occupying it while Eval() is mid-flight). + // Nonzero-but-wrong is exactly as unsafe as zero: a plain FALSE + // default at struct-zero-init time (sig 0) already reads as "no + // match" against any real position, so no separate valid/invalid + // flag is needed. + UINT64 u64FullEvalSig; + // Which Eval() exit tier supplied the piPositional value most + // recently returned for THIS ply -- set inside Eval() itself (all + // three exit points), read back in QSearch/QSearchFromCheckNoStandPat + // to index FUTILITY_BASE_MARGIN_BY_SOURCE. Per-ply, not a single + // per-thread scalar, precisely because it used to be one + // (SEARCHER_THREAD_CONTEXT.uLastPositionalSource) and that was a + // real, live bug: QSearch's own move loop recurses into QSearch() + // again for earlier moves before reading this back for later ones, + // and that recursive call's own nested Eval() at ctx->uPly+1 would + // clobber a single shared scalar -- indexing the margin table with + // whatever tier the deepest just-completed call happened to use, + // not the tier that actually produced this ply's own iEval/ + // rgiPositional. Per-ply storage makes that impossible by + // construction: a deeper node's Eval() call writes to a different + // array slot entirely. + ULONG uLastPositionalSource; + SCORE iKingScore[2]; // REQUIRES SIG MATCH + // (u64FullEvalSig) -- + // only written on + // Eval()'s full path UINT64 u64NonPawnSig; UINT64 u64PawnSig; UINT64 u64Sig; @@ -1265,6 +1392,18 @@ typedef struct _SEARCHER_THREAD_CONTEXT MOVE mvNullmoveQuietRefutations[MAX_PLY_PER_SEARCH]; MOVE mvCounter[COUNTER_MOVE_TABLE_SIZE][2]; // counter-move table UCHAR uCounterDepth[COUNTER_MOVE_TABLE_SIZE]; // ply depth slot 0 was set at + // REQUIRES SIG MATCH (sPlyInfo[uPly].u64FullEvalSig, chess.h's + // PLY_INFO) before trusting any of these four -- only written on + // Eval()'s full path (eval.c's _EvalLookForDanger/ + // _EvalTrappedPieces), by whichever node last ran a full eval at + // this ply (possibly this node, possibly an earlier sibling that + // visited the same ply slot for a different position -- see + // ValueOfMaterialInTroubleDespiteMove's own comment on that). Was + // previously guarded by _EnpriseSlotValid/_TrappedSlotValid + // re-checking piece identity on the live board instead; that's a + // decent but imprecise proxy (a coincidentally-matching piece on + // the same square in an unrelated position at this ply reads as + // "fresh") -- a direct sig compare is both cheaper and exact. COOR cEnprise[MAX_PLY_PER_SEARCH][2]; // en prise piece hints PIECE pEnprise[MAX_PLY_PER_SEARCH][2]; COOR cTrapped[MAX_PLY_PER_SEARCH]; // trapped piece hint @@ -1301,14 +1440,6 @@ typedef struct _SEARCHER_THREAD_CONTEXT // called on. FLAG fCalibrateCandidate; #endif - // Which Eval() exit tier supplied the piPositional value most - // recently returned to this thread -- set inside Eval() itself - // (all three exit points), read right after the Eval() call in - // QSearch. Was CALIBRATE_QSEARCH_FUTILITY-only; promoted to - // unconditional 2026-09-08 since QSearch's real futility margin - // now indexes FUTILITY_BASE_MARGIN_BY_SOURCE with it, not just - // the calibration harness. See EVAL_POSITIONAL_SOURCE_* below. - ULONG uLastPositionalSource; #ifdef CALIBRATE_QSEARCH_FUTILITY // TRUE for the duration of a diagnostic "what if we hadn't pruned // this move" re-search -- search.c's qsearch futility gate checks @@ -1996,8 +2127,11 @@ extern PIECE_DATA g_PieceData[8]; // winning/even captures'/promotions' iValue -- harmless for sorting, but // wrong once the value is used on the eval axis (compared against // material-scale margins). Back it out to recover the pure SEE/ -// material-diff value. ComputeMoveScore() (searchsup.c) already does this; -// _ShouldWeConsiderThisMove() (search.c) reads iValue raw and needs it too. +// material-diff value. Used only inside DecodeMoveValueEstimate +// (searchsup.c) -- that function is the single place that knows which of +// generate.c's several distinct scoring conventions (MOVE_SCORING_SCHEME) +// a given iValue was packed under and decodes accordingly; don't apply +// this macro to a raw iValue anywhere else. #define MOVE_SCORE_ORDERING_BIAS(mv) \ (PIECE_VALUE_OVER_100((mv).pCaptured) + 120 + \ PIECE_VALUE_OVER_100((mv).pPromoted) - \ @@ -2901,10 +3035,10 @@ IsDraw(SEARCHER_THREAD_CONTEXT *ctx); #define QPLIES_OF_NON_CAPTURE_CHECKS (2) // Which Eval() exit tier supplied a given piPositional value -- -// SEARCHER_THREAD_CONTEXT.uLastPositionalSource above. Was diagnostic- -// only (CALIBRATE_QSEARCH_FUTILITY); promoted to unconditional -// 2026-09-08 once FUTILITY_BASE_MARGIN_BY_SOURCE (search.c) made it -// load-bearing for real search behavior, not just measurement. +// PLY_INFO.uLastPositionalSource above. Was diagnostic-only +// (CALIBRATE_QSEARCH_FUTILITY); promoted to unconditional 2026-09-08 +// once FUTILITY_BASE_MARGIN_BY_SOURCE (search.c) made it load-bearing +// for real search behavior, not just measurement. #define EVAL_POSITIONAL_SOURCE_FULL (0) #define EVAL_POSITIONAL_SOURCE_LAZY (1) #define EVAL_POSITIONAL_SOURCE_SUPERLAZY (2) @@ -2955,6 +3089,11 @@ ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx, IN MOVE mv, IN ULONG uMoveNum); +SCORE +DecodeMoveValueEstimate(IN MOVE mv, + IN SCORE iRawValue, + IN MOVE_SCORING_SCHEME eScheme); + FLAG ThreadUnderTerminatingSplit(SEARCHER_THREAD_CONTEXT *); |
