summaryrefslogtreecommitdiff
path: root/src/chess.h
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-13 15:03:09 -0700
committerScott Gasch <[email protected]>2026-09-13 15:03:09 -0700
commit78d1889fe38db1fd45b58509c8b3bd535ce691b7 (patch)
tree994a213816c19c6654faf89eca74880f0fa79f47 /src/chess.h
parentd2699c47d6d0abf0f018e5ba37d4f1bfe05ecd33 (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-xsrc/chess.h179
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 *);