From 7857096f39e16619a42ee85b4aa593abd846b74a Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Wed, 26 Aug 2026 11:50:25 -0700 Subject: Replace ctx->uPositional with a data-calibrated Eval() return value. uPositional was a per-thread EWMA of abs(material - true score) used to size lazy-eval and futility margins. It was history-derived (reflecting whatever recent, unrelated positions looked like) rather than derived from the position actually being margined, and its update/consumption was tangled with EVAL_HASH (now disabled). Eval() now takes an optional SCORE *piPositional out-param and fills it in on every return path: exact (abs(material-delta)) on a full eval, or an estimate from a new EstimatePositionalScore() on a lazy exit. EstimatePositionalScore()'s two terms (king-safety-defect-bucketed, and a flat residual for mobility/passers/everything else) are calibrated from ~1.6M measured full-eval samples (p90 of the actual swing), not guessed -- an initial guessed version measurably regressed ECM solve rate (630 vs a 650 baseline at sn=4M); the recalibrated version is back at parity (649/879). search.c's qsearch futility now reads the value Eval() just computed instead of the stale/shared ctx field. Also removes QSearchInDangerNoStandPat and SideCanStandPat, dead since the danger-hash check that fed them was already commented out (e08387a) -- they depended on the same enprise/ trapped-piece data this conversation is about to move off of g_PositionHash entirely. Co-Authored-By: Claude Sonnet 5 --- src/book.c | 2 +- src/chess.h | 8 +-- src/command.c | 2 +- src/eval.c | 171 ++++++++++++++++++++++-------------------------------- src/evalhash.c | 2 +- src/poshash.c | 22 ------- src/search.c | 175 ++------------------------------------------------------ src/searchsup.c | 2 +- src/testeval.c | 4 +- 9 files changed, 82 insertions(+), 306 deletions(-) diff --git a/src/book.c b/src/book.c index b9cb694..eaa4c48 100755 --- a/src/book.c +++ b/src/book.c @@ -2974,7 +2974,7 @@ Return value: pos = GetRootPosition(); InitializeSearcherContext(pos, ctx); DumpPosition(pos); - (void)Eval(ctx, -INFINITY, +INFINITY); + (void)Eval(ctx, -INFINITY, +INFINITY, NULL); Trace("King safeties: W=%d, B=%d\n", ctx->sPlyInfo[ctx->uPly].iKingScore[WHITE], ctx->sPlyInfo[ctx->uPly].iKingScore[BLACK]); diff --git a/src/chess.h b/src/chess.h index fe5cd2b..d6d4fa4 100755 --- a/src/chess.h +++ b/src/chess.h @@ -2565,9 +2565,6 @@ GetEnpriseCount(POSITION *pos, ULONG uSide); COOR GetTrappedPiece(POSITION *pos, ULONG uSide); -FLAG -SideCanStandPat(POSITION *pos, ULONG uSide); - ULONG ValueOfMaterialInTroubleDespiteMove(POSITION *pos, ULONG uSide); @@ -2593,6 +2590,9 @@ PawnHashLookup(SEARCHER_THREAD_CONTEXT *ctx); // #define LAZY_EVAL #define LAZE_EVAL_BASE_SCORE 10 +#define LAZY_EVAL_BASE_MARGIN (75) // cheap material-only lazy exit margin; + // widened by EstimatePositionalScore + // if this isn't enough on its own extern const int g_iAhead[2]; extern const int g_iBehind[2]; @@ -2614,7 +2614,7 @@ ReadEvalDNA(char *szFilename); SCORE -Eval(SEARCHER_THREAD_CONTEXT *, SCORE, SCORE); +Eval(SEARCHER_THREAD_CONTEXT *, SCORE, SCORE, SCORE *); FLAG EvalPasserRaces(POSITION *, diff --git a/src/command.c b/src/command.c index 4f81b10..091d6cd 100755 --- a/src/command.c +++ b/src/command.c @@ -543,7 +543,7 @@ Return value: { ReInitializeSearcherContext(pos, ctx); } - i = Eval(ctx, -INFINITY, +INFINITY); + i = Eval(ctx, -INFINITY, +INFINITY, NULL); Trace("Static eval: %s\n", ScoreToString(i)); } diff --git a/src/eval.c b/src/eval.c index 92d1edc..876e882 100755 --- a/src/eval.c +++ b/src/eval.c @@ -2227,20 +2227,47 @@ Return value: static void -_QuicklyEstimateKingSafetyTerm(IN POSITION *pos, - IN OUT SCORE *piAlphaMargin, - IN OUT SCORE *piBetaMargin) +EstimatePositionalScore(IN POSITION *pos, + IN UNUSED PAWN_HASH_ENTRY *pHash, + IN OUT SCORE *piAlphaMargin, + IN OUT SCORE *piBetaMargin) /** Routine description: - Before doing an early lazy eval, look at the position and try to - quickly see if there is a large king safety positional component - to the score. + Before doing an early lazy eval, look at the position and widen + the alpha/beta margins to account for the positional terms that + have not been computed yet at this point in Eval() -- king safety + plus a flat residual covering mobility, passers, and everything + else that genuinely requires attack-generation to know exactly. + + Both terms below are calibrated from measured data (CALIBRATE_ + POSITIONAL instrumentation, ~1.6M full-eval samples over an ECM + slice), not guessed: for each value, p90 of the *actual* score + swing between this point in Eval() and full-eval completion, so + the resulting margin is wrong (too narrow) on at most ~10% of + calls, in either bucket. See conversation history for the + percentile tables -- if re-tuning, regenerate them, don't hand- + edit these numbers. + + Note: king safety and "everything else" turned out to be close to + independent of piece count and of each other, so summing their + two p90s (rather than deriving one joint p90) is a deliberately + conservative (wider than strictly necessary) combination. + + O(1) -- no attack bitboard generation -- so it's safe to call + whenever the cheap material-only lazy check (the caller's + first-pass margin) wasn't enough to resolve the cutoff on its own. Parameters: POSITION *pos + PAWN_HASH_ENTRY *pHash : unused now (kept for call-site symmetry); + pHash->iScore is already folded into pos->iScore by this point, + and the passer-specific estimate this used to compute turned + out to be negligible next to the residual term below. + SCORE *piAlphaMargin, *piBetaMargin : widened in place, identically + (no measured basis for an asymmetric alpha/beta split) Return value: @@ -2248,98 +2275,21 @@ Return value: **/ { - // - // IDEA: make this stuff dynamic like ctx->uPositional - // - static const SCORE iScoreByDefectCount[15] = { - 33, 50, 88, 146, 215, 245, 280, 430, 550, 630, 646, 646, 646, 646, 646 + // p90 of |true king-safety swing|, indexed by combined defect + // count (CountKingSafetyDefects(stm) + CountKingSafetyDefects(xsm)), + // clamped above index 10 (sparse data beyond that). + static const SCORE iKingSwingP90[11] = { + 47, 62, 87, 119, 169, 157, 181, 282, 342, 342, 385 }; - SCORE iPenaltyEst[2]; - SCORE iRet; - - ASSERT(CountKingSafetyDefects(pos, pos->uToMove) < 15); - iPenaltyEst[WHITE] = - iScoreByDefectCount[CountKingSafetyDefects(pos, WHITE)]; - ASSERT(iPenaltyEst[WHITE] > 0); - - ASSERT(CountKingSafetyDefects(pos, BLACK) < 15); - iPenaltyEst[BLACK] = - iScoreByDefectCount[CountKingSafetyDefects(pos, BLACK)]; - ASSERT(iPenaltyEst[BLACK] > 0); - - // - // For the beta margin, assume our king safety penalty will stand - // and our opponents will be less severe than we guess... net bonus - // to them / our bonus not as high. - // - iRet = iPenaltyEst[pos->uToMove] - iPenaltyEst[FLIP(pos->uToMove)] / 2; - iRet = MAX0(iRet); - ASSERT(iRet >= 0); - *piBetaMargin += iRet; - - // - // For the alpha margin, assume our king safety penalty will not - // be too bad and our opponents will be as bad as we guess... net - // bonus to us / their offsetting bonus not as high. - // - iRet = iPenaltyEst[FLIP(pos->uToMove)] - iPenaltyEst[pos->uToMove] / 2; - iRet = MAX0(iRet); - ASSERT(iRet >= 0); - *piAlphaMargin += iRet; -} - - -static void -_QuicklyEstimatePasserBonuses(POSITION *pos, - PAWN_HASH_ENTRY *pHash, - SCORE *piAlphaMargin, - SCORE *piBetaMargin) -{ - SCORE iBonusEst[2] = {0, 0}; - SCORE iRet; - ULONG u; - - // - // Guess about the passer bonuses - // - if (pHash->bbPasserLocations[WHITE]) - { - u = pos->uNonPawnMaterial[BLACK] - VALUE_KING; - u /= VALUE_PAWN; - u = MINU(31, u); - ASSERT(PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] >= 0); - iBonusEst[WHITE] = - (CountBits(pHash->bbPasserLocations[WHITE]) * - PASSER_BONUS_AS_MATERIAL_COMES_OFF[u]); - } - if (pHash->bbPasserLocations[BLACK]) - { - u = pos->uNonPawnMaterial[WHITE] - VALUE_KING; - u /= VALUE_PAWN; - u = MINU(31, u); - ASSERT(PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] >= 0); - iBonusEst[BLACK] = - (CountBits(pHash->bbPasserLocations[BLACK]) * - PASSER_BONUS_AS_MATERIAL_COMES_OFF[u]); - } - - // - // For the beta margin, assume our passer bonus gets reduced and - // our opponent's is enhanced... net bonus to them / our bonus - // not as high. - // - iRet = iBonusEst[pos->uToMove] / 2 - iBonusEst[FLIP(pos->uToMove)]; - iRet = MAX0(iRet); - *piBetaMargin += iRet; - - // - // For the alpha bonus, assume our passer bonus gets doubled and - // our opponent's is reduced. Net bonus to us / their bonus not - // as high. - // - iRet = iBonusEst[pos->uToMove] - iBonusEst[FLIP(pos->uToMove)] / 2; - iRet = MAX0(iRet); - *piAlphaMargin += iRet; + // p90 of |mobility + passers + everything else combined|, measured + // directly (no useful correlation found with piece count). + static const SCORE iResidualP90 = 154; + ULONG uDefects = (CountKingSafetyDefects(pos, WHITE) + + CountKingSafetyDefects(pos, BLACK)); + SCORE iKingTerm = iKingSwingP90[MINU(10, uDefects)]; + + *piAlphaMargin += iKingTerm + iResidualP90; + *piBetaMargin += iKingTerm + iResidualP90; } @@ -5533,7 +5483,8 @@ Return value: SCORE Eval(IN SEARCHER_THREAD_CONTEXT *ctx, IN SCORE iAlpha, - IN SCORE iBeta) + IN SCORE iBeta, + OUT SCORE *piPositional) /** Routine description: @@ -5543,6 +5494,11 @@ Parameters: SEARCHER_THREAD_CONTEXT *ctx, SCORE iAlpha, SCORE iBeta, + SCORE *piPositional : if non-NULL, filled in with a magnitude + (always >= 0) estimating the non-material component of the + score -- exact if a full eval ran, a cheap estimate otherwise. + Callers must treat it as an estimate either way; it's only + ever used to size a pruning margin, never as a hard fact. Return value: @@ -5693,7 +5649,7 @@ Return value: // We build two "margins" to account for these components of the // score. // - iAlphaMargin = iBetaMargin = (50 + (SCORE)ctx->uPositional); + iAlphaMargin = iBetaMargin = LAZY_EVAL_BASE_MARGIN; // // If (score + alpha_margin) is already > alpha -OR- @@ -5712,13 +5668,15 @@ Return value: // widen the margin further for king safety issues and passed // pawns. // - _QuicklyEstimateKingSafetyTerm(pos, &iAlphaMargin, &iBetaMargin); - _QuicklyEstimatePasserBonuses(pos, pHash, &iAlphaMargin, &iBetaMargin); + EstimatePositionalScore(pos, pHash, &iAlphaMargin, &iBetaMargin); if (iScoreForSideToMove + iAlphaMargin <= iAlpha) { - ctx->uPositional = MINU(200, ctx->uPositional); INC(ctx->sCounters.tree.u64LazyEvals); + if (NULL != piPositional) + { + *piPositional = iAlphaMargin; + } #ifdef EVAL_HASH // // The true eval can't reach iAlpha; that's a fact about @@ -5733,8 +5691,11 @@ Return value: } else if (iScoreForSideToMove - iBetaMargin >= iBeta) { - ctx->uPositional = MINU(200, ctx->uPositional); INC(ctx->sCounters.tree.u64LazyEvals); + if (NULL != piPositional) + { + *piPositional = iBetaMargin; + } #ifdef EVAL_HASH StoreEvalHash(ctx, iScoreForSideToMove, (SCORE)(iScoreForSideToMove - iBetaMargin), @@ -6101,6 +6062,10 @@ Return value: // Adjust dynamic positional component. // iAlphaMargin = abs(pos->iMaterialBalance[pos->uToMove] - iScoreForSideToMove); + if (NULL != piPositional) + { + *piPositional = iAlphaMargin; + } if ((ULONG)iAlphaMargin > ctx->uPositional) { ctx->uPositional = (ULONG)iAlphaMargin; diff --git a/src/evalhash.c b/src/evalhash.c index bd95b99..fc29b88 100644 --- a/src/evalhash.c +++ b/src/evalhash.c @@ -64,7 +64,7 @@ Return value: if (ctx->uPly <= 4) { - return Eval(ctx, iAlpha, iBeta); + return Eval(ctx, iAlpha, iBeta, NULL); } #ifdef EVAL_HASH else if ((fUseHash) || (ctx->uPly <= (g_uIterateDepth / 2))) diff --git a/src/poshash.c b/src/poshash.c index 19d10ab..d8b4fcd 100644 --- a/src/poshash.c +++ b/src/poshash.c @@ -185,28 +185,6 @@ GetTrappedPiece(POSITION *pos, ULONG uSide) return c; } -FLAG -SideCanStandPat(POSITION *pos, ULONG uSide) -{ - UINT64 u64Sig = PositionToSignatureIgnoringMove(pos); - ULONG uEntry = PositionSigToHashPosition(u64Sig); - POSITION_HASH_ENTRY *pHash = &(g_PositionHash[uEntry]); - FLAG fStand = TRUE; -#ifdef MP - ULONG uLock = HashPositionToLockNumber(uEntry); - LOCK_POSITION_HASH(uLock); -#endif - if (pHash->u64Sig == u64Sig) - { - fStand = ((pHash->cTrapped[uSide] == ILLEGAL_COOR) && - (pHash->uEnpriseCount[uSide] < 2)); - } -#ifdef MP - UNLOCK_POSITION_HASH(uLock); -#endif - return fStand; -} - ULONG ValueOfMaterialInTroubleDespiteMove(POSITION *pos, ULONG uSide) { diff --git a/src/search.c b/src/search.c index 0f814ea..4ea09b0 100755 --- a/src/search.c +++ b/src/search.c @@ -999,164 +999,6 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, } - -/** - -Routine description: - - The QSearch (Quiescence Search) is a selective search called when - there is no remaining depth in Search. Its job is to search only - moves that stabilize the position -- once it is quiescence (quiet) - we will run a static evaluation on it and return the score. - - This branch of the QSearch is called when the side to move is in - danger somehow -- either he has two pieces en prise on the board - or some piece that seems trapped. We do not allow him an - opportunity to stand pat in this position. - -Parameters: - - IN SEARCHER_THREAD_CONTEXT *ctx, - IN SCORE iAlpha, - IN SCORE iBeta - IN SCORE iEval - -Return value: - - SCORE - -**/ -SCORE -QSearchInDangerNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx, - IN SCORE iAlpha, - IN SCORE iBeta) -{ - POSITION *pos = &ctx->sPosition; - CUMULATIVE_SEARCH_FLAGS *pf = &ctx->sSearchFlags; - ULONG x; - FLAG fIncludeChecks; - SCORE iBestScore = iAlpha; - SCORE iScore; - SCORE iFutility; - SCORE iEval = GetRoughEvalScore(ctx, iAlpha, iBeta, TRUE); - MOVE mv; - ULONG uLegalMoves = 0; - static ULONG _WhatToGen[] = - { - GENERATE_CAPTURES_PROMS, - GENERATE_CAPTURES_PROMS_CHECKS - }; - - - // Set futility: - // - // iEval + move_value + margin < alpha - // move_value < alpha - margin - iEval - iFutility = 0; - if (iAlpha < +NMATE) - { - iFutility = (iAlpha - - (FUTILITY_BASE_MARGIN + ctx->uPositional) - - iEval); - iFutility = MAX0(iFutility); - } - - // We suspect that the guy on move is in sad shape if we're - // here... he has more than one piece en prise or he seems to - // have a piece trapped. Allow him to play checks in order to try - // to save the situation. - ASSERT(!InCheck(pos, pos->uToMove)); - fIncludeChecks = ((pf->uQsearchDepth < pf->uQsearchCheckDepth) && - (pf->fCouldStandPat[FLIP(pos->uToMove)] == FALSE)); - GenerateMoves(ctx, NULLMOVE, _WhatToGen[fIncludeChecks]); - - for (x = ctx->sMoveStack.uBegin[ctx->uPly]; - x < ctx->sMoveStack.uEnd[ctx->uPly]; - x++) - { - SelectBestNoHistory(ctx, x); - mv = ctx->sMoveStack.mvf[x].mv; -#ifdef DEBUG - ASSERT(0 == (ctx->sMoveStack.mvf[x].bvFlags & MVF_MOVE_SEARCHED)); - ctx->sMoveStack.mvf[x].bvFlags |= MVF_MOVE_SEARCHED; -#endif - - // Prune except when pruning all moves could cause us to return - // -INFINITY (mated) erroneously. - if (iAlpha > -INFINITY) - { - if (ctx->sMoveStack.mvf[x].iValue <= 0) - { - ASSERT(SanityCheckMoves(ctx, x, VERIFY_BEFORE | VERIFY_AFTER)); - goto end; - } - if (FALSE == _ShouldWeConsiderThisMove(ctx, - x, - iFutility, - TRUE)) - { - continue; - } - } - - if (FALSE == fIncludeChecks) - { - mv.bvFlags |= WouldGiveCheck(ctx, mv); - } - - if (MakeMove(ctx, mv)) - { - uLegalMoves++; - pf->uQsearchNodes++; - pf->uQsearchDepth++; - iScore = -QSearch(ctx, - -iBeta, - -iAlpha); - pf->uQsearchDepth--; - UnmakeMove(ctx, mv); - if (WE_SHOULD_STOP_SEARCHING) goto end; - - if (iScore > iBestScore) - { - iBestScore = iScore; - ctx->sPlyInfo[ctx->uPly].mvBest = mv; - if (iScore > iAlpha) - { - if (iScore >= iBeta) - { - KEEP_TRACK_OF_FIRST_MOVE_FHs(uLegalMoves == 1); - ASSERT(SanityCheckMoves(ctx, x, VERIFY_BEFORE)); - goto end; - } - else - { - UpdatePV(ctx, mv); - StoreExactScore(mv, pos, iScore, 0, FALSE, ctx->uPly); - iAlpha = iScore; - } - } - } - } - } - ASSERT(SanityCheckMoves(ctx, x, VERIFY_BEFORE)); - - end: - // If iAlpha is -INFINITY and side on move has no captures, - // promotes or checks then we must make up a "stand pat" score - // here. We don't want to let him stand pat with iEval because - // the board looks dangerous. But we likewise don't want to say - // "mated" because he's not. - ASSERT((uLegalMoves > 0) || (iBestScore == iAlpha)); - if (iBestScore == -INFINITY) - { - ASSERT(iAlpha == -INFINITY); - iBestScore = iEval - VALUE_QUEEN; - } - ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING); - return(iBestScore); -} - - /** Routine description: @@ -1193,6 +1035,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, SCORE iScore; SCORE iEval; SCORE iFutility; + SCORE iPositional; ULONG x; ULONG uLegalMoves; FLAG fIncludeChecks; @@ -1269,16 +1112,6 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, } ASSERT(!InCheck(pos, pos->uToMove)); - // Even if the side is not in check, do not let him stand pat if - // his position looks dangerous (i.e. more than one piece en prise - // or a piece trapped). Fail low if there's nothing that looks - // good on this line. - //if (SideCanStandPat(pos, pos->uToMove) == FALSE) - //{ - // iBestScore = QSearchInDangerNoStandPat(ctx, iAlpha, iBeta); - // goto end; - //} - // If we get here then side on move is not in check and this // position looks ok enough to allow him the option to stand pat // -or- we missed when we probed the dangerhash. Also remember @@ -1288,7 +1121,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, // discovers a mate, there's no force since a stand pat // opportunity exists right here. uLegalMoves = 0; - iEval = iBestScore = Eval(ctx, iAlpha, iBeta); + iEval = iBestScore = Eval(ctx, iAlpha, iBeta, &iPositional); if (iBestScore > iAlpha) { iAlpha = iBestScore; @@ -1312,7 +1145,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, iFutility = 0; if (iAlpha < +NMATE) { - iFutility = iAlpha - (FUTILITY_BASE_MARGIN + ctx->uPositional) - iEval; + iFutility = iAlpha - (FUTILITY_BASE_MARGIN + iPositional) - iEval; iFutility = MAX0(iFutility); } @@ -1400,7 +1233,7 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, { iFutility = (iAlpha - (FUTILITY_BASE_MARGIN + - ctx->uPositional) - + iPositional) - iEval); iFutility = MAX0(iFutility); } diff --git a/src/searchsup.c b/src/searchsup.c index 9a2cc50..0cf52bf 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -804,7 +804,7 @@ Return value: Trace(" \t"); } - iEval = Eval(&temp, -INFINITY, +INFINITY); + iEval = Eval(&temp, -INFINITY, +INFINITY, NULL); Trace("%5s (%+2d) | ", ScoreToString(iEval), temp.sPosition.iMaterialBalance[temp.sPosition.uToMove] / 100); Trace("%5s (%2u) | ", diff --git a/src/testeval.c b/src/testeval.c index 4010dac..0c4b1cf 100755 --- a/src/testeval.c +++ b/src/testeval.c @@ -265,7 +265,7 @@ Return value: GenerateRandomLegalSymetricPosition(&pos); InitializeSearcherContext(&pos, ctx); - i = Eval(ctx, -INFINITY, INFINITY); + i = Eval(ctx, -INFINITY, INFINITY, NULL); if (i != 0) { DumpPosition(&ctx->sPosition); @@ -430,7 +430,7 @@ Return value: __FILE__, __LINE__); } - i = Eval(&ctx, -INFINITY, +INFINITY); + i = Eval(&ctx, -INFINITY, +INFINITY, NULL); Trace("Position %u: SCORE %d for side to move.\n", u, i); if (ctx.sPosition.uToMove == WHITE) { -- cgit v1.3