summaryrefslogtreecommitdiff
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
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
-rwxr-xr-xsrc/chess.h179
-rw-r--r--src/dynamic.c18
-rw-r--r--src/eval.c32
-rw-r--r--src/generate.c8
-rw-r--r--src/root.c14
-rw-r--r--src/search.c189
-rw-r--r--src/searchsup.c265
-rw-r--r--src/split.c24
8 files changed, 604 insertions, 125 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 *);
diff --git a/src/dynamic.c b/src/dynamic.c
index 992b842..9c68993 100644
--- a/src/dynamic.c
+++ b/src/dynamic.c
@@ -538,8 +538,16 @@ _EnpriseSlotValid(SEARCHER_THREAD_CONTEXT *ctx, ULONG uSlot, ULONG uSide)
ULONG uPly = ctx->uPly;
COOR c = ctx->cEnprise[uPly][uSlot];
- return(IS_ON_BOARD(c) &&
- (ctx->sPosition.rgSquare[c].pPiece == ctx->pEnprise[uPly][uSlot]) &&
+ // Was a live-board piece-identity re-check (same piece still on
+ // that square); replaced with a direct signature compare against
+ // the full eval that populated this slot (see PLY_INFO's
+ // u64FullEvalSig comment, chess.h) -- cheaper, and exact rather
+ // than an approximation that could false-positive on an unrelated
+ // position coincidentally holding the same piece on the same
+ // square at this ply.
+ return((ctx->sPlyInfo[uPly].u64FullEvalSig ==
+ (ctx->sPosition.u64NonPawnSig ^ ctx->sPosition.u64PawnSig)) &&
+ IS_ON_BOARD(c) &&
(GET_COLOR(ctx->pEnprise[uPly][uSlot]) == uSide));
}
@@ -550,8 +558,10 @@ _TrappedSlotValid(SEARCHER_THREAD_CONTEXT *ctx, ULONG uSide)
ULONG uPly = ctx->uPly;
COOR c = ctx->cTrapped[uPly];
- return(IS_ON_BOARD(c) &&
- (ctx->sPosition.rgSquare[c].pPiece == ctx->pTrapped[uPly]) &&
+ // See _EnpriseSlotValid's comment -- same replacement.
+ return((ctx->sPlyInfo[uPly].u64FullEvalSig ==
+ (ctx->sPosition.u64NonPawnSig ^ ctx->sPosition.u64PawnSig)) &&
+ IS_ON_BOARD(c) &&
(GET_COLOR(ctx->pTrapped[uPly]) == uSide));
}
diff --git a/src/eval.c b/src/eval.c
index edb1e0c..0a8f71e 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -5385,7 +5385,7 @@ Return value:
// govern from here.
(*piPositional)[WHITE] = 0;
(*piPositional)[BLACK] = 0;
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY;
}
#ifdef EVAL_TIME
ctx->sCounters.tree.u64CyclesEvalSuperLazy +=
@@ -5419,7 +5419,7 @@ Return value:
{
(*piPositional)[WHITE] = 0;
(*piPositional)[BLACK] = 0;
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY;
}
#ifdef EVAL_TIME
ctx->sCounters.tree.u64CyclesEvalSuperLazy +=
@@ -5632,7 +5632,7 @@ Return value:
{
(*piPositional)[WHITE] = iAlphaMargin;
(*piPositional)[BLACK] = iAlphaMargin;
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY;
}
#ifdef EVAL_TIME
ctx->sCounters.tree.u64CyclesEvalPreLazy +=
@@ -5665,7 +5665,7 @@ Return value:
{
(*piPositional)[WHITE] = iBetaMargin;
(*piPositional)[BLACK] = iBetaMargin;
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY;
}
#ifdef EVAL_TIME
ctx->sCounters.tree.u64CyclesEvalPreLazy +=
@@ -5963,15 +5963,27 @@ Return value:
// _EvalLookForDanger/_EvalTrappedPieces only ever *add* a hint
// when they find one; a full eval that finds nothing this time
// would otherwise leave a stale, unrelated hint from an earlier
- // visit to this ply sitting here (still piece-identity-valid by
- // coincidence). Clear this ply's self-danger slots first so a
- // clean full eval reliably means a clean slate.
+ // visit to this ply sitting here. Clear this ply's self-danger
+ // slots first so a clean full eval reliably means a clean slate.
//
ctx->cEnprise[ctx->uPly][0] = ctx->cEnprise[ctx->uPly][1] = ILLEGAL_COOR;
ctx->cTrapped[ctx->uPly] = ILLEGAL_COOR;
_EvalLookForDanger(ctx);
_EvalTrappedPieces(ctx);
+ // We are now committed to the full-eval path (no more lazy/
+ // super-lazy exits below this point) -- stamp this ply's
+ // PLY_INFO with the position we just fully evaluated so every
+ // "REQUIRES SIG MATCH" field this call populates (iKingScore
+ // below; cEnprise/cTrapped above; the eval-scoped bb*Attacks
+ // tables) can be validated by a downstream reader with one cheap
+ // compare against this position's live signature, instead of the
+ // old per-field piece-identity re-check. See PLY_INFO's own
+ // comment in chess.h for why this, not u64Sig, is the field to
+ // compare against.
+ ctx->sPlyInfo[ctx->uPly].u64FullEvalSig =
+ (pos->u64NonPawnSig ^ pos->u64PawnSig);
+
// TODO: endgame-specific knowledge? e.g. B over N in an endgame
// with 2 pawn wings?
@@ -6031,7 +6043,7 @@ Return value:
MAX(pos->iScore[BLACK] -
(SCORE)(pos->uPawnMaterial[BLACK] + pos->uNonPawnMaterial[BLACK]),
-(FUTILITY_BASE_MARGIN_FULL / 2));
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_FULL;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_FULL;
}
g_Options.iLastEvalScore = iScoreForSideToMove;
@@ -6069,7 +6081,7 @@ Return value:
// purposes, not what a normal build actually returns here
// -- fix it back up to match the restored (super-lazy)
// values.
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_SUPERLAZY;
}
}
else if (TRUE == fWouldHaveExited)
@@ -6098,7 +6110,7 @@ Return value:
{
(*piPositional)[WHITE] = iSavedLazyPositional;
(*piPositional)[BLACK] = iSavedLazyPositional;
- ctx->uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY;
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource = EVAL_POSITIONAL_SOURCE_LAZY;
}
}
#endif
diff --git a/src/generate.c b/src/generate.c
index 7cc28d0..a04b4c6 100644
--- a/src/generate.c
+++ b/src/generate.c
@@ -4063,6 +4063,8 @@ Return value:
ULONG uCounterMoveContKey = 0;
FLAG fHaveCounterMove = FALSE;
+ ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_NORMAL;
+
//
// We have generated all moves here. We also know that we are not
// escaping from check. There can be a hash move -- if there is
@@ -4337,6 +4339,8 @@ Return value:
// and we should not generate it again.
//
+ ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_ESCAPE;
+
ASSERT(MOVE_COUNT(ctx, uPly) <= MAX_MOVES_PER_PLY);
for (u = pStack->uBegin[uPly];
u < pStack->uEnd[uPly];
@@ -4554,6 +4558,8 @@ Return value:
ASSERT(MOVE_COUNT(ctx, uPly) <= MAX_MOVES_PER_PLY);
ASSERT(uPly >= 1);
+ ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_QSEARCH_CHECKS;
+
for (u = pStack->uBegin[uPly];
u < pStack->uEnd[uPly];
u++)
@@ -4703,6 +4709,8 @@ Return value:
//
ASSERT(MOVE_COUNT(ctx, uPly) <= MAX_MOVES_PER_PLY);
+ ctx->sPlyInfo[uPly].eMoveScoringScheme = MOVE_SCORING_QSEARCH_NO_CHECKS;
+
#ifdef DEBUG
uColor = pos->uToMove;
#endif
diff --git a/src/root.c b/src/root.c
index 702dd47..46a7a80 100644
--- a/src/root.c
+++ b/src/root.c
@@ -468,6 +468,13 @@ Return value:
ASSERT(d);
Trace("Null move cutoff rate: %5.3f percent.\n",
((n / d) * 100.0));
+#ifdef PERF_COUNTERS
+ Trace("Node-type surprises: PV failed high/low=%llu, ALL failed high=%llu, "
+ "CUT failed low=%llu.\n",
+ ctx->sCounters.tree.u64PVNodeSurprised,
+ ctx->sCounters.tree.u64AllNodeFailedHigh,
+ ctx->sCounters.tree.u64CutNodeFailedLow);
+#endif
if (ctx->sCounters.egtb.uProbes > 0)
{
n = (double)(ctx->sCounters.egtb.uHits);
@@ -806,6 +813,13 @@ Return value:
UINT64 u64StartingNodeCount;
ULONG uNumLegalMoves;
+ // This function's own move loop overwrites sMoveStack[ctx->uPly]'s
+ // iValue with a node-count/score composite for next-iteration move
+ // ordering -- not a generate.c tier encoding, and not meaningful to
+ // ComputeMoveScore's decoder (see MOVE_SCORING_SCHEME's own comment,
+ // chess.h).
+ pi->eMoveScoringScheme = MOVE_SCORING_ROOT;
+
#ifdef DEBUG
POSITION *pos = &ctx->sPosition;
ASSERT(IS_VALID_SCORE(iAlpha));
diff --git a/src/search.c b/src/search.c
index 8ee2d3b..4e89a2c 100644
--- a/src/search.c
+++ b/src/search.c
@@ -235,8 +235,13 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
}
DTEnterNode(ctx, uDepth, FALSE, iAlpha, iBeta);
iInitialAlpha = iAlpha;
- pi->fPvNode = (iBeta != iAlpha + 1);
- pi->fMovesRescoredByIID = FALSE;
+ // pi->eNodeType is set centrally by CommonSearchInit (see its own
+ // comment) -- covers both Search() and QSearch() from one place.
+ // Safe default until the real move-generation/scoring call below
+ // sets this to whatever scheme actually ran (generate.c's four
+ // _Score* functions and RescoreMovesViaSearch are the authoritative
+ // setters -- see MOVE_SCORING_SCHEME's own comment in chess.h).
+ pi->eMoveScoringScheme = MOVE_SCORING_NORMAL;
ASSERT((IS_CHECKING_MOVE(mvLast) && (TRUE == pi->fInCheck)) ||
(!IS_CHECKING_MOVE(mvLast) && (FALSE == pi->fInCheck)));
@@ -472,7 +477,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// we generated at this ply using a shallower
// search. "Internal Iterative Deepening" or
// something like it.
- if ((TRUE == pi->fPvNode) &&
+ if ((NODE_PV == pi->eNodeType) &&
(mvHash.uMove == 0) &&
(ctx->sMoveStack.mvf[x].iValue < SORT_THESE_FIRST) &&
(0 == (ctx->sMoveStack.mvf[x].iValue &
@@ -530,7 +535,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// this closes a real gap rather than relying on it not
// mattering in practice.
ASSERT(!uFutilityMargin);
- if ((FALSE == pi->fPvNode) &&
+ if ((NODE_PV != pi->eNodeType) &&
(iOrigExtend == 0) &&
(ctx->uPly >= 2) &&
(ctx->sPlyInfo[ctx->uPly - 2].iExtensionAmount <= 0))
@@ -590,7 +595,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// never classifies an IID-rescored move as a
// leftover (matches pre-retirement behavior).
fIsLeftoverMove = FALSE;
- if (TRUE == pi->fMovesRescoredByIID)
+ if (MOVE_SCORING_IID_RESCORED == pi->eMoveScoringScheme)
{
SelectBestNoHistory(ctx, x);
}
@@ -650,7 +655,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// g_fCanSplit[] indexing ever evaluates it.
if (((uLegalMoves >= 2) && fIsLeftoverMove) &&
(0 != g_uNumHelpersAvailable) &&
- (FALSE == pi->fMovesRescoredByIID) &&
+ (MOVE_SCORING_IID_RESCORED != pi->eMoveScoringScheme) &&
(0 == uFutilityMargin) &&
(uDepth >= ONE_PLY) &&
(TRUE == g_fCanSplit[uDepth / ONE_PLY - 1]) &&
@@ -681,6 +686,52 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
if (IS_VALID_SCORE(iScore))
{
pi->mvBest = mvBest;
+ if (iBestScore >= iBeta)
+ {
+ //
+ // The split failed high. StartParallelSearch leaves
+ // iAlpha untouched on this path (see its own
+ // comment), so falling through to no_more_moves'
+ // generic iAlpha-moved classifier would misread
+ // this as a fail-low/exact-score case -- skipping
+ // UpdateDynamicMoveOrdering entirely and storing
+ // the fail-high score as an (inverted-sense,
+ // unsound) upper bound instead of a lower bound.
+ // Handle it explicitly here instead, matching the
+ // non-split fail-high branch above -- except for
+ // uCurrent: mvBest may have been found by a helper
+ // thread with its own separate ctx->sMoveStack, not
+ // this (originating) thread's, so passing this
+ // thread's own move index (the non-split call's
+ // convention) doesn't identify mvBest's position in
+ // any coherent move array. Pass 0 to skip the
+ // "decrement history for moves tried earlier at
+ // this node" loop entirely -- there's no single
+ // thread-local move sequence to decrement across a
+ // split, so it's dropped rather than fed a bogus
+ // index (which previously tripped this exact
+ // assert in dynamic.c: mvf[uCurrent] wasn't
+ // mvBest). The killer/counter-move/history-increment
+ // side of the update below is unaffected.
+ //
+ if (!IS_CAPTURE_OR_PROMOTION(mvBest))
+ {
+ UpdateDynamicMoveOrdering(ctx,
+ uDepth,
+ mvBest,
+ iBestScore,
+ 0);
+ }
+ StoreLowerBound(mvBest, pos, iBestScore, uDepth, fThreat);
+ if (mvBest.pCaptured && !IS_PAWN(mvBest.pCaptured) &&
+ (iBeta < +NMATE))
+ {
+ ASSERT(ctx->uPly > 0);
+ RecordEnprisePieceAtPly(ctx, ctx->uPly - 1,
+ mvBest.cTo);
+ }
+ goto end;
+ }
goto no_more_moves;
}
else
@@ -735,8 +786,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// Decide how much (if any) to reduce this move's depth --
// graded LMR.
if ((uDepth > TWO_PLY) &&
- !pi->fPvNode &&
- !(ctx->sPlyInfo[ctx->uPly - 1].fPvNode) &&
+ (NODE_PV != pi->eNodeType) &&
(uLegalMoves > 5) &&
(0 == iExtend) &&
(!IS_ESCAPING_CHECK(mv)) &&
@@ -757,7 +807,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
{
ASSERT(iExtend == 0);
iExtend = iLMR;
- pi->iExtensionAmount = iLMR;
}
}
@@ -828,6 +877,16 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
INC(ctx->sCounters.tree.u64CMEvidenceTries[uCMEvidenceBucket]);
}
#endif
+ // Record the extension/reduction actually applied to
+ // THIS move, unconditionally -- not just when it's
+ // negative. Leaving this stale from whatever earlier
+ // move at this ply last happened to set it let a child
+ // node checking "was my parent's move reduced" via
+ // (pi-1)->iExtensionAmount < 0 read a leftover value
+ // from a completely different sibling move, not the
+ // one actually played.
+ pi->iExtensionAmount = iExtend;
+
// Compute the next search depth for this move/subtree.
uNextDepth = uDepth - ONE_PLY + iExtend;
if (uNextDepth >= MAX_DEPTH_PER_SEARCH) uNextDepth = 0;
@@ -1046,6 +1105,34 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
ASSERT(IS_VALID_SCORE(iAlpha));
ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING);
ASSERT(PositionsAreEquivalent(pos, &pi->sPosition));
+#ifdef PERF_COUNTERS
+ if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore))
+ {
+ FLAG fFailedLow = (iBestScore <= iAlpha);
+ FLAG fFailedHigh = (iBestScore >= iBeta);
+ switch (pi->eNodeType)
+ {
+ case NODE_PV:
+ if (fFailedLow || fFailedHigh)
+ {
+ INC(ctx->sCounters.tree.u64PVNodeSurprised);
+ }
+ break;
+ case NODE_ALL:
+ if (fFailedHigh)
+ {
+ INC(ctx->sCounters.tree.u64AllNodeFailedHigh);
+ }
+ break;
+ case NODE_CUT:
+ if (fFailedLow)
+ {
+ INC(ctx->sCounters.tree.u64CutNodeFailedLow);
+ }
+ break;
+ }
+ }
+#endif
DTLeaveNode(ctx, FALSE, iBestScore, mvBest);
return(iBestScore);
}
@@ -1400,9 +1487,17 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
if (iMoveValue >= SORT_THESE_FIRST)
{
fHaveMoveValue = TRUE;
- iMoveValue &= STRIP_OFF_FLAGS;
- ASSERT(iMoveValue >= 0);
- iMoveValue -= MOVE_SCORE_ORDERING_BIAS(mv);
+ // This function is only ever called outside of check (see
+ // the ASSERTs above), so uMoveNum always indexes one of
+ // QSearch's two schemes -- never MOVE_SCORING_ESCAPE. Which
+ // of the two matters here: InclChecks bakes in a +4
+ // checking-move nudge NoChecks doesn't, so a raw
+ // MOVE_SCORE_ORDERING_BIAS subtraction alone (correct for
+ // NoChecks) silently over-values a checking capture scored
+ // under InclChecks by 4. DecodeMoveValueEstimate (searchsup.c)
+ // is the one place that knows the difference.
+ iMoveValue = DecodeMoveValueEstimate(
+ mv, iMoveValue, ctx->sPlyInfo[ctx->uPly].eMoveScoringScheme);
if (mv.pCaptured)
{
// If there are very few pieces left on the board,
@@ -1572,7 +1667,7 @@ _ShouldWeConsiderThisMove(IN SEARCHER_THREAD_CONTEXT *ctx,
// 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
+// by PLY_INFO.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.
@@ -1691,7 +1786,8 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
if (FALSE == fEvalComputed)
{
iEval = Eval(ctx, iAlpha, iBeta, &rgiPositional);
- ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT);
+ ASSERT(ctx->sPlyInfo[ctx->uPly].uLastPositionalSource <
+ EVAL_POSITIONAL_SOURCE_COUNT);
fEvalComputed = TRUE;
}
@@ -1705,9 +1801,16 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
// 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.
+ // uLastPositionalSource is read off this ply's own
+ // PLY_INFO slot, not a per-thread scalar, so a recursive
+ // QSearch() call for an earlier move in this same loop
+ // (which writes ctx->sPlyInfo[ctx->uPly+1], a different
+ // slot) can't clobber it before a later iteration reads it
+ // back.
if (iEval < iAlpha &&
((iEval + PIECE_VALUE(mv.pCaptured)) + rgiPositional[pos->uToMove] +
- FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource]) < iAlpha)
+ FUTILITY_BASE_MARGIN_BY_SOURCE[
+ ctx->sPlyInfo[ctx->uPly].uLastPositionalSource]) < iAlpha)
{
continue;
}
@@ -1761,6 +1864,13 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
end:
ASSERT((uLegalMoves > 0) || (iBestScore <= -NMATE));
ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING);
+ // No node-type-surprise counting here (unlike Search()/QSearch()'s
+ // own "end:" labels) -- this function has exactly one call site
+ // (QSearch(), which does `goto end` immediately after calling this
+ // at the SAME ply, no MakeMove in between) and QSearch()'s own
+ // "end:" block already counts that identical (iBestScore, ctx->uPly,
+ // eNodeType) outcome; counting here too would double-count every
+ // in-check qsearch node.
return(iBestScore);
}
@@ -1882,7 +1992,6 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
ASSERT(!InCheck(pos, pos->uToMove));
pi->iEval = iEval = iBestScore = Eval(ctx, iAlpha, iBeta, &rgiPositional);
-
// If that Eval (above) was full (i.e. not lazy) it may have set
// en prise and trapped piece indicators. Likewise, other nodes
// at this depth may have set en prise piece hints. If these are
@@ -1948,9 +2057,9 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
// would be for the qsearch-futility calibration harness.
iFutility = -NMATE;
#else
- ASSERT(ctx->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT);
+ ASSERT(pi->uLastPositionalSource < EVAL_POSITIONAL_SOURCE_COUNT);
iFutility = iAlpha -
- (FUTILITY_BASE_MARGIN_BY_SOURCE[ctx->uLastPositionalSource] +
+ (FUTILITY_BASE_MARGIN_BY_SOURCE[pi->uLastPositionalSource] +
rgiPositional[pos->uToMove]) - iEval;
iFutility = MAX0(iFutility);
#endif
@@ -2004,7 +2113,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
, iAlpha
, iBeta
, rgiPositional[pos->uToMove]
- , ctx->uLastPositionalSource
+ , pi->uLastPositionalSource
#endif
))
{
@@ -2070,11 +2179,20 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
// Readjust futility margin here; it can be wider now.
if (iAlpha < +NMATE)
{
- ASSERT(ctx->uLastPositionalSource <
+ // pi->uLastPositionalSource is this ply's
+ // own PLY_INFO slot -- the recursive
+ // QSearch() call just above (for the move
+ // that raised alpha) wrote its own nested
+ // Eval() results to ctx->uPly+1, a
+ // different slot, so this one is
+ // untouched and still describes the same
+ // Eval() call that produced iEval/
+ // rgiPositional above.
+ ASSERT(pi->uLastPositionalSource <
EVAL_POSITIONAL_SOURCE_COUNT);
iFutility = (iAlpha -
(FUTILITY_BASE_MARGIN_BY_SOURCE[
- ctx->uLastPositionalSource] +
+ pi->uLastPositionalSource] +
rgiPositional[pos->uToMove]) -
iEval);
iFutility = MAX0(iFutility);
@@ -2091,6 +2209,35 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
ASSERT(PositionsAreEquivalent(pos, &pi->sPosition));
ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING);
DTLeaveNode(ctx, TRUE, iBestScore, pi->mvBest);
+#ifdef PERF_COUNTERS
+ // See Search()'s own "end:" label for the full rationale.
+ if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore))
+ {
+ FLAG fFailedLow = (iBestScore <= iAlpha);
+ FLAG fFailedHigh = (iBestScore >= iBeta);
+ switch (pi->eNodeType)
+ {
+ case NODE_PV:
+ if (fFailedLow || fFailedHigh)
+ {
+ INC(ctx->sCounters.tree.u64PVNodeSurprised);
+ }
+ break;
+ case NODE_ALL:
+ if (fFailedHigh)
+ {
+ INC(ctx->sCounters.tree.u64AllNodeFailedHigh);
+ }
+ break;
+ case NODE_CUT:
+ if (fFailedLow)
+ {
+ INC(ctx->sCounters.tree.u64CutNodeFailedLow);
+ }
+ break;
+ }
+ }
+#endif
// Note: iBestScore can be +INFINITY or -INFINITY here even in the
// absence of a legitimate mate detected if we disallowed stand
diff --git a/src/searchsup.c b/src/searchsup.c
index 41415dc..32e1be8 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -201,12 +201,22 @@ ThreadUnderTerminatingSplit(IN SEARCHER_THREAD_CONTEXT *ctx)
{
ULONG u;
+ //
+ // A thread can be nested in more than one split at once (a helper
+ // thread can itself call StartParallelSearch while inside
+ // HelpSearch for an outer split), so pSplitInfo[] is a stack, not
+ // a single slot -- slot 0 is the outermost split this thread is
+ // part of, later slots are progressively more nested ones. Any of
+ // them terminating means this thread's current work is moot, so
+ // all populated slots must be checked, not just the first one
+ // found.
+ //
for (u = 0; u < NUM_SPLIT_PTRS_IN_CONTEXT; u++)
{
- if (ctx->pSplitInfo[u] != NULL)
+ if ((ctx->pSplitInfo[u] != NULL) &&
+ (ctx->pSplitInfo[u]->fTerminate == TRUE))
{
- if (ctx->pSplitInfo[u]->fTerminate == TRUE) return(TRUE);
- return(FALSE);
+ return(TRUE);
}
}
return(FALSE);
@@ -260,63 +270,66 @@ Return value:
SCORE
-ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx,
- IN MOVE mv,
- IN ULONG uMoveNum)
+DecodeMoveValueEstimate(IN MOVE mv,
+ IN SCORE iRawValue,
+ IN MOVE_SCORING_SCHEME eScheme)
+/**
+
+Routine description:
+
+ generate.c has (at least) four distinct conventions for packing
+ sMoveStack[].iValue (see MOVE_SCORING_SCHEME's own comment, chess.h),
+ plus two more that aren't generate.c tier encodings at all
+ (RescoreMovesViaSearch's real eval-axis score, and root.c's node-
+ count/score composite). This function is the one place that knows
+ how to turn a raw iValue back into an eval-axis (100=1-pawn)
+ material estimate, given which scheme produced it -- every caller
+ that needs an actual value rather than just a tier/leftover
+ classification should go through here rather than inlining its own
+ guess at the encoding.
+
+Parameters:
+
+ IN MOVE mv,
+ IN SCORE iRawValue,
+ IN MOVE_SCORING_SCHEME eScheme
+
+Return value:
+
+ SCORE
+
+**/
{
- SCORE iMoveScore = (PIECE_VALUE(mv.pCaptured) +
- PIECE_VALUE(mv.pPromoted));
- if (uMoveNum != (ULONG)-1)
+ switch (eScheme)
{
- ASSERT(uMoveNum < MAX_MOVE_STACK);
- ASSERT(IS_SAME_MOVE(mv, ctx->sMoveStack.mvf[uMoveNum].mv));
- ASSERT(ctx->uPly > 0);
- iMoveScore = ctx->sMoveStack.mvf[uMoveNum].iValue;
- // Every current caller (search.c's EFP check, and
- // ComputeMoveExtension's two ComputeMoveScore() call sites) only
- // reaches here after MakeMove(ctx, mv) has already succeeded, so
- // ctx->uPly is always the *child's* ply here -- uMoveNum indexes
- // the parent's move list, i.e. ctx->uPly - 1, not ctx->uPly. That's
- // the ply RescoreMovesViaSearch (if it ran) would have rescored,
- // so that's the flag to check.
- if (TRUE == ctx->sPlyInfo[ctx->uPly - 1].fMovesRescoredByIID)
- {
- // RescoreMovesViaSearch already put a real, searched eval-axis
- // score here -- better than SEE/MVV-LVA, since it reflects an
- // entire subtree, not just the immediate exchange. Trust it
- // exactly as-is; don't run it through the capture-bias
- // subtraction or collapse it via MIN0, both of which assume
- // generate.c's ordering-encoded format, which this isn't.
- }
- else if ((iMoveScore >= SORT_THESE_FIRST) && IS_CAPTURE_OR_PROMOTION(mv))
- {
- ASSERT(iMoveScore > 0);
- iMoveScore &= STRIP_OFF_FLAGS;
+ case MOVE_SCORING_IID_RESCORED:
+ // Already a real, searched eval-axis score (reflects an
+ // entire subtree, not just the immediate exchange) --
+ // trust it exactly as-is.
+ return(iRawValue);
- // _ScoreAllMoves (generate.c) bakes a flat +120 move-ordering
- // bias plus small MVV-LVA tie-break nudges
- // (PIECE_VALUE_OVER_100 terms) on top of the real SEE/
- // material-diff value for winning/even captures and
- // promotions -- harmless for sorting (every capture gets
- // the same treatment), but this function's callers use the
- // result as an eval-axis quantity (compared against
- // material-scale margins in futility/extension code), so
- // back the ordering-only bias back out to recover the pure
- // SEE/material-diff value, same axis as the raw
- // PIECE_VALUE() fallback below.
- iMoveScore -= (PIECE_VALUE_OVER_100(mv.pCaptured) + 120 +
- PIECE_VALUE_OVER_100(mv.pPromoted) -
- PIECE_VALUE_OVER_100(mv.pMoved));
- }
- else
- {
- // All quiet moves (including killer/killer-mate quiet moves,
- // which can reach SORT_THESE_FIRST or the killer-tier bits via
- // generate.c's ordering bonuses with no relation to a move's
- // real eval-axis value) collapse to 0 here -- correct, since
- // this function's contract is "estimate the move's value on
- // the 100=1-pawn axis," and a quiet move's ordering bonus
- // carries no such estimate.
+ case MOVE_SCORING_NORMAL:
+ case MOVE_SCORING_QSEARCH_NO_CHECKS:
+ // Identical +120 flat move-ordering bias plus small
+ // MVV-LVA tie-break nudges for winning/even captures and
+ // promotions -- harmless for sorting, but wrong on the
+ // eval axis until backed out.
+ if ((iRawValue >= SORT_THESE_FIRST) && IS_CAPTURE_OR_PROMOTION(mv))
+ {
+ ASSERT(iRawValue > 0);
+ iRawValue &= STRIP_OFF_FLAGS;
+ iRawValue -= MOVE_SCORE_ORDERING_BIAS(mv);
+ return(iRawValue);
+ }
+ // All quiet moves (including killer/killer-mate quiet
+ // moves, which can reach SORT_THESE_FIRST or the killer-
+ // tier bits via generate.c's ordering bonuses with no
+ // relation to a move's real eval-axis value) collapse to
+ // 0 here -- correct, since a quiet move's ordering bonus
+ // carries no such estimate. A genuinely negative raw value
+ // (a losing capture, scored without any tier flag) passes
+ // through MIN0 unchanged, preserving its real (negative)
+ // material estimate.
//
// FUTURE WORK: 0 is uniform across every quiet move, which
// throws away signal we plausibly have. Two candidate
@@ -334,8 +347,90 @@ ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx,
// moves -- eval.c's real value is far more than per-square
// tables (mobility, king safety, pawn structure), so this
// would only ever be directionally suggestive, not exact.
- iMoveScore = MIN0(iMoveScore);
- }
+ return(MIN0(iRawValue));
+
+ case MOVE_SCORING_QSEARCH_CHECKS:
+ // Same +120 convention, plus two QSearch-only ordering
+ // nudges baked into the same value by
+ // _ScoreQSearchMovesInclChecks: +4 for a checking move,
+ // +64 for a same-square recapture. The checking-move term
+ // is backed out here; the same-square-recapture term isn't
+ // (this function doesn't receive mvLast) -- a known,
+ // small (<=64, well under a minor piece) residual
+ // imprecision for that specific case, not silently wrong
+ // by an unbounded amount the way the pre-fix code was for
+ // escapes.
+ if ((iRawValue >= SORT_THESE_FIRST) && IS_CAPTURE_OR_PROMOTION(mv))
+ {
+ ASSERT(iRawValue > 0);
+ iRawValue &= STRIP_OFF_FLAGS;
+ iRawValue -= MOVE_SCORE_ORDERING_BIAS(mv);
+ if (IS_CHECKING_MOVE(mv))
+ {
+ iRawValue -= 4;
+ }
+ return(iRawValue);
+ }
+ return(MIN0(iRawValue));
+
+ case MOVE_SCORING_ESCAPE:
+ // _ScoreAllEscapes' winning-capture encoding uses a
+ // DIFFERENT bias than _ScoreAllMoves (no +120) plus an
+ // IS_KING(mv.pMoved) right-shift that's lossy -- can't be
+ // inverted exactly. Even captures are tagged FIRST_KILLER,
+ // not SORT_THESE_FIRST, so they would not even reach a
+ // capture-decode branch keyed on that bit. Rather than try
+ // to invert either packed encoding, just recompute the
+ // cheap raw material estimate directly from the move's own
+ // fields -- exactly what _ScoreAllEscapes itself computes
+ // as ITS OWN first-pass estimate before ever calling SEE
+ // (deliberately not re-invoking SEE here -- same axis,
+ // just cheaper and not dependent on reversing a lossy
+ // encoding).
+ if (IS_CAPTURE_OR_PROMOTION(mv))
+ {
+ return((SCORE)(PIECE_VALUE(mv.pCaptured) -
+ PIECE_VALUE(mv.pMoved)));
+ }
+ return(0);
+
+ case MOVE_SCORING_ROOT:
+ default:
+ // Not a generate.c tier encoding at all -- a node-count/
+ // score composite (root.c), only ever populates ply 0's
+ // move stack. No current caller asks to decode a root-ply
+ // move here; fail loudly rather than silently return a
+ // number that looks plausible but isn't.
+ ASSERT(FALSE);
+ return(0);
+ }
+}
+
+
+SCORE
+ComputeMoveScore(IN SEARCHER_THREAD_CONTEXT *ctx,
+ IN MOVE mv,
+ IN ULONG uMoveNum)
+{
+ SCORE iMoveScore = (PIECE_VALUE(mv.pCaptured) +
+ PIECE_VALUE(mv.pPromoted));
+ if (uMoveNum != (ULONG)-1)
+ {
+ ASSERT(uMoveNum < MAX_MOVE_STACK);
+ ASSERT(IS_SAME_MOVE(mv, ctx->sMoveStack.mvf[uMoveNum].mv));
+ ASSERT(ctx->uPly > 0);
+ // Every current caller (search.c's EFP check, and
+ // ComputeMoveExtension's two ComputeMoveScore() call sites) only
+ // reaches here after MakeMove(ctx, mv) has already succeeded, so
+ // ctx->uPly is always the *child's* ply here -- uMoveNum indexes
+ // the parent's move list, i.e. ctx->uPly - 1, not ctx->uPly. That's
+ // the ply whatever scoring pass ran (generate.c's scorers,
+ // RescoreMovesViaSearch) actually scored, so that's the ply
+ // whose eMoveScoringScheme is authoritative here.
+ iMoveScore = DecodeMoveValueEstimate(
+ mv,
+ ctx->sMoveStack.mvf[uMoveNum].iValue,
+ ctx->sPlyInfo[ctx->uPly - 1].eMoveScoringScheme);
}
return(iMoveScore);
}
@@ -735,7 +830,7 @@ Return value:
{
ctx->sMoveStack.mvf[x].iValue = rgScores[x - uBegin];
}
- ctx->sPlyInfo[ctx->uPly].fMovesRescoredByIID = TRUE;
+ ctx->sPlyInfo[ctx->uPly].eMoveScoringScheme = MOVE_SCORING_IID_RESCORED;
}
else
{
@@ -750,11 +845,11 @@ Return value:
// ordering value completely untouched and just bump uBest into
// killer-tier territory so the normal (non-rescored) selection
// path -- SelectBestWithHistory/NoHistory -- picks it first, the
- // same trick generate.c uses for a real killer move. Do NOT set
- // fMovesRescoredByIID: this ply's iValue is back to being
- // generate.c's ordering encoding, not real scores, for every
- // move including uBest (whose bump is itself just an ordering
- // flag, not part of its own eval-axis value).
+ // same trick generate.c uses for a real killer move. Do NOT
+ // change eMoveScoringScheme: this ply's iValue is back to being
+ // whatever generate.c scheme originally scored it, not real
+ // scores, for every move including uBest (whose bump is itself
+ // just an ordering flag, not part of its own eval-axis value).
ctx->sMoveStack.mvf[uBest].iValue |= FIRST_KILLER;
}
@@ -1028,6 +1123,15 @@ Return value:
//
if (TRUE == IsDraw(ctx))
{
+ // Every early exit from this function must clear pi->mvBest
+ // itself, since the real reset further down is never reached
+ // from here. Found via a DEBUG-build assertion failure:
+ // TryNullmovePruning reading (pi+1)->mvBest as if it reflected
+ // the null-move probe's own search, when it was actually stale
+ // data left by a completely unrelated earlier node that once
+ // occupied the same ply slot and bailed out via this exact path
+ // without ever being cleared.
+ pi->mvBest.uMove = 0;
INC(ctx->sCounters.tree.u64LeafCount);
*piScore = g_iDrawScore[ctx->sPosition.uToMove];
if ((*piAlpha < *piScore) && (*piScore < *piBeta))
@@ -1084,6 +1188,8 @@ Return value:
*piScore = Eval(ctx, *piAlpha, *piBeta, NULL);
pi->iEval = *piScore;
}
+ // See the IsDraw branch above -- same staleness hazard, same fix.
+ pi->mvBest.uMove = 0;
DTTRACE("<toodeep/>");
goto end;
}
@@ -1131,6 +1237,33 @@ Return value:
pi->iAlpha = *piAlpha;
pi->iBeta = *piBeta;
#endif
+ // Classic PVS node typing (see NODE_TYPE's definition in chess.h).
+ // Centralized here rather than in each caller's move loop
+ // (search.c, root.c, split.c) since CommonSearchInit is the one
+ // place both Search() and QSearch() funnel through -- a wide
+ // window is unambiguously PV regardless of move number (this
+ // covers "first move of a PV node" for free, no need to track
+ // which move number got us here); a narrow window's type depends
+ // only on the PARENT's own type, not on move number either: a CUT
+ // parent's children are ALL (expected to fail low, must search
+ // everything to prove it), and both a PV parent's non-first
+ // children AND an ALL parent's children are CUT (expected to fail
+ // high). The ctx->uPly==0 guard is defensive only -- root is
+ // always called with a wide window in practice, so the width check
+ // above already handles it.
+ if (*piBeta != *piAlpha + 1)
+ {
+ pi->eNodeType = NODE_PV;
+ }
+ else if (ctx->uPly == 0)
+ {
+ pi->eNodeType = NODE_CUT;
+ }
+ else
+ {
+ NODE_TYPE eParentType = ctx->sPlyInfo[ctx->uPly - 1].eNodeType;
+ pi->eNodeType = (NODE_CUT == eParentType) ? NODE_ALL : NODE_CUT;
+ }
fRet = FALSE;
end:
@@ -1182,7 +1315,7 @@ FLAG WeShouldTryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx,
(pos->uNonPawnCount[pos->uToMove][0] > 2) &&
(FALSE == pi->fInCheck) &&
(iBeta != +INFINITY) &&
- (FALSE == pi->fPvNode))
+ (NODE_PV != pi->eNodeType))
{
if (uNullDepth <= 6 * ONE_PLY)
{
diff --git a/src/split.c b/src/split.c
index f8da7ed..3a69ab4 100644
--- a/src/split.c
+++ b/src/split.c
@@ -81,7 +81,13 @@ Revision History:
extern ULONG g_uIterateDepth;
ULONG g_uNumHelperThreads = 0;
-#define MAX_SPLITS (20)
+// Sized for up to 64 threads (--cpus' documented max, main.c) each
+// holding a handful of nested split pointers (NUM_SPLIT_PTRS_IN_CONTEXT,
+// chess.h) at once -- exhausting this pool just degrades gracefully
+// (StartParallelSearch falls back to serial search), but a ceiling
+// tuned for a handful of threads bites harder the more of them are
+// actually configured, and each SPLIT_INFO is only a few KB.
+#define MAX_SPLITS (64)
#define IDLE ((ULONG)-1)
void
@@ -570,6 +576,7 @@ Return value:
sizeof(POSITION));
g_SplitInfo[u].iEval = ctx->sPlyInfo[ctx->uPly].iEval;
g_SplitInfo[u].iImprovement = iImprovement;
+ g_SplitInfo[u].eNodeType = ctx->sPlyInfo[ctx->uPly].eNodeType;
#if DEBUG
g_SplitInfo[u].uSplitPly = ctx->uPly;
memcpy(&(g_SplitInfo[u].sSplitPosition),
@@ -886,7 +893,17 @@ Return value:
if (iScore >= g_SplitInfo[u].iBeta)
{
//
- // We failed high so we want to update the split node
+ // We failed high so we want to update the split node.
+ // Do NOT touch g_SplitInfo[u].iAlpha here -- the caller
+ // (StartParallelSearch's caller in search.c) asserts
+ // iAlpha stays < iBeta after a split returns, and
+ // detects fail-high purely via iBestScore/iScore, not
+ // via alpha having moved. (See search.c's handling of
+ // this return for the other half of this fix: it must
+ // special-case iBestScore >= iBeta itself rather than
+ // falling into the generic no_more_moves classifier,
+ // which infers "exact score" vs. "fail-high" purely
+ // from whether alpha moved.)
//
g_SplitInfo[u].fTerminate = TRUE;
g_SplitInfo[u].PV[0] = NULLMOVE;
@@ -1152,8 +1169,7 @@ Return value:
// the deterministic missing-gate bug it actually was.
//
if ((uDepth > TWO_PLY) &&
- !(ctx->sPlyInfo[ctx->uPly].fPvNode) &&
- !(ctx->sPlyInfo[ctx->uPly - 1].fPvNode) &&
+ (NODE_PV != g_SplitInfo[u].eNodeType) &&
((g_SplitInfo[u].uAlreadyDone + uMoveNum + 1) > 5) &&
(0 == iExtend) &&
(!IS_ESCAPING_CHECK(mv)) &&