summaryrefslogtreecommitdiff
path: root/src/eval.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-08 20:18:20 -0700
committerScott Gasch <[email protected]>2026-09-08 20:18:20 -0700
commit88a0787a7d19a5b4e19e540816f1d500e02dbfeb (patch)
treed4a4c44df716aed5559d64ed132a31bf07791da7 /src/eval.c
parent379a03bbd993247c8de9c1f1b163fcfab2fa1d69 (diff)
Fix BOOC opposite-bishop check, extract drawish-scaling, trim lazy margins
Three changes landed together, verified via the usual pipeline (release + DEBUG build, DEBUG smoke test, 40-game st1 match vs clean 434fa04, score 0.600, llr +0.24): 1. New BOOC (bishops of opposite color) endgame drawish-scaling term had a real bug in its opposite-color check: (pos->uWhiteSqBishopCount[WHITE] && !pos->uWhiteSqBishopCount[BLACK]) only detects one of the two possible opposite-color configurations and silently misses the mirror case (White dark-squared / Black light-squared). Since each side's bishop-square-color flag is 0 or 1 whenever uNonPawnCount[side][BISHOP] == 1 (already checked above), "opposite colors" is exactly the XOR of the two flags: (pos->uWhiteSqBishopCount[WHITE] != pos->uWhiteSqBishopCount[BLACK]) 2. Extracted the winning-chances/BOOC/fifty-move drawish scaling out of Eval() into its own EvalLookForDrawishSituations(pos, &iScoreForSideToMove) helper -- same semantics, cleaner separation. Fixed two small issues in the extraction: missing `static` (every other file-local eval.c helper is static; this had accidental external linkage with no prototype anywhere) and a typo in a new EVAL_DUMP trace string ("At of all pieces" -> "After all pieces"). Also reordered Eval()'s two king evaluations to go side-to-move first / enemy second (via the already-cached uColor/xColor) instead of always BLACK-then-WHITE -- confirmed safe, no dependency between the two _EvalKing calls (each only reads attack-bitboard data already populated by earlier phases). 3. Re-calibrated and trimmed SUPER_LAZY_MARGIN_BY_ARMY and iSwingFloorByArmy. Both were originally derived by measuring symmetric |real - lazy| swing, which conflates a swing *toward* the alpha/beta boundary (the only direction that can make an exit unsound) with a swing *away* from it (harmless). Re-ran CALIBRATE_MARGIN_SAFETY against the same 1500-position tests/twic_sample.ep_ (sd 8) with the harness fixed to measure only the dangerous-direction swing: true max ran 15-50% below the old symmetric measurement in most material buckets, several averaged in the single digits, and every bucket showed exceeded=0 even before adding any headroom back. iSwingFloorByArmy: {1069,1069,1069,974,821,876,796,754} -> {297,297,297,286,461,453,582,600} SUPER_LAZY_MARGIN_BY_ARMY: {2000,1800,1800,1750,1000,850,850,850} -> {300,1635,1800,1070,946,850,734,698} (buckets 2 and 5 unchanged -- already tighter than a fresh 15% headroom over the new directional max would give) Motivation: the board-representation-migration branch exists to close a measured 5x nps gap vs Crafty on the same CPU (profiled: typhoon spends more of its search time in Eval() than Crafty does in evaluate()); every lazy/super-lazy exit that fires is Eval()'s fast path, so trimming unnecessary margin headroom directly increases how often the cheap path is taken instead of a full evaluation. Not yet done: splitting alpha-margin and beta-margin into independent per-bucket values (currently symmetric per bucket, no principled reason they need to be) -- would need another calibration pass tracking the two separately. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ka9o3S2eKqh4jNfmxVZ6fH
Diffstat (limited to 'src/eval.c')
-rwxr-xr-xsrc/eval.c203
1 files changed, 123 insertions, 80 deletions
diff --git a/src/eval.c b/src/eval.c
index 867c487..5522d2d 100755
--- a/src/eval.c
+++ b/src/eval.c
@@ -3137,9 +3137,20 @@ Return value:
// re-derive with `calibrate marginsafety` if this margin's shape
// changes again.
{
+ // 2026-09-08 re-calibration: original values above measured
+ // |real - lazy| swing symmetrically, conflating "swung toward
+ // the boundary" (the only direction that can make an exit
+ // unsound) with "swung further away" (harmless). Re-measured
+ // with a directional split (CALIBRATE_MARGIN_SAFETY, same
+ // 1500-position tests/twic_sample.ep_, sd 8): the true
+ // dangerous-direction max is 30-80% smaller in every bucket
+ // than the old symmetric max, and every bucket showed
+ // exceeded=0 even before adding headroom. New values are
+ // directional-max * ~1.15-1.3 (more headroom on the smaller
+ // buckets, where sample size is thinner).
static const SCORE iSwingFloorByArmy[8] =
{
- 1069, 1069, 1069, 974, 821, 876, 796, 754,
+ 297, 297, 297, 286, 461, 453, 582, 600,
};
ULONG uMatBucket = MINU(
7, (pos->uArmyScaler[WHITE] + pos->uArmyScaler[BLACK]) / 8);
@@ -5139,6 +5150,65 @@ InitEval(void)
}
+static void
+EvalLookForDrawishSituations(POSITION *pos,
+ SCORE *piScoreForSideToMove)
+{
+ ULONG uColor = pos->uToMove;
+
+ // If the side who's ahead by raw material/positional score can't
+ // actually force a win with what it has left on the board (see
+ // _SideHasWinningChances), squash the score hard towards draw --
+ // same idiom as the 50-move dampening just below (and the same
+ // signed-arithmetic care: g_iDrawScore is currently always 0, but
+ // write this relative to it rather than assuming that, matching
+ // the 50-move code's own convention). /16 (not a full collapse to
+ // drawscore) deliberately mirrors Crafty's EvaluateDraws -- e.g.
+ // KRB vs KR is still theoretically losable by the run-of-the-mill
+ // defender with the checks _SideHasWinningChances gates on, so
+ // some residual signal survives.
+ if ((*piScoreForSideToMove > g_iDrawScore[uColor]) &&
+ (FALSE == _SideHasWinningChances(pos, uColor)))
+ {
+ *piScoreForSideToMove = g_iDrawScore[uColor] +
+ ((*piScoreForSideToMove - g_iDrawScore[uColor]) / 16);
+ }
+ else if ((*piScoreForSideToMove < g_iDrawScore[uColor]) &&
+ (FALSE == _SideHasWinningChances(pos, FLIP(uColor))))
+ {
+ *piScoreForSideToMove = g_iDrawScore[uColor] +
+ ((*piScoreForSideToMove - g_iDrawScore[uColor]) / 16);
+ }
+
+ // If this is a BOOC endgame, push towards draw score too.
+ if ((pos->uNonPawnCount[WHITE][0] == 2) &&
+ (pos->uNonPawnCount[BLACK][0] == 2) &&
+ (pos->uNonPawnCount[WHITE][BISHOP] == 1) &&
+ (pos->uNonPawnCount[BLACK][BISHOP] == 1) &&
+ (pos->uWhiteSqBishopCount[WHITE] != pos->uWhiteSqBishopCount[BLACK])) {
+ *piScoreForSideToMove = g_iDrawScore[uColor] +
+ ((*piScoreForSideToMove - g_iDrawScore[uColor]) / 4);
+ }
+
+ // Drive the score towards draw as we approach a 50 move w/o
+ // progress draw.
+ if (pos->uFifty > 84)
+ {
+ ULONG uDrawDist = 101 - pos->uFifty;
+ ASSERT(uDrawDist > 0);
+ // uDrawDist is ULONG -- multiplying a negative SCORE by it
+ // directly promotes the SCORE to unsigned first (usual
+ // arithmetic conversions, same rank), wrapping a negative
+ // *piScoreForSideToMove into a huge positive garbage value
+ // instead of scaling it down. Cast uDrawDist to SCORE so the
+ // multiply happens in signed arithmetic; its range (1-16) is
+ // always representable.
+ *piScoreForSideToMove = g_iDrawScore[uColor] +
+ (*piScoreForSideToMove * (SCORE)uDrawDist / 16);
+ }
+}
+
+
SCORE
Eval(IN SEARCHER_THREAD_CONTEXT *ctx,
IN SCORE iAlpha,
@@ -5236,24 +5306,26 @@ Return value:
// Super-lazy exit point.
#ifdef LAZY_EVAL
- // 2026-09-08: was a single flat 625 for every material level.
- // CALIBRATE_MARGIN_SAFETY data (100 real-game positions, sd 8,
- // tests/twic_sample.ep_) showed that's badly unsound at low
- // material -- up to 9.3% of super-lazy exits in bare-king-plus-a-
- // little-material positions (combined army scaler 0-7) had a real
- // swing exceeding 625, max observed 1710 -- while richer positions
- // (combined scaler 32+) never came close (max 893, well under
- // 625... actually under 900, still comfortably bounded). Table
- // indexed the same way _MaterialBucket buckets the calibration
- // data (combined army scaler / 8, 8 buckets), so it can be
- // re-derived directly from a "calibrate marginsafety" run. Values
- // here are the observed max per bucket rounded up with headroom
- // (~15-20%), not a hard theoretical bound -- provisional pending a
- // larger/deeper calibration run; re-check before trusting this at
- // sd well beyond 8.
+ // 2026-09-08: was a single flat 625 for every material level, then
+ // split by material bucket using a 100-position/sd8 calibration
+ // (symmetric |swing|, ~15-20% headroom). Re-calibrated against a
+ // larger 1500-position sample with a directional split (only a
+ // swing *toward* the alpha/beta boundary can make an exit unsound;
+ // a swing away is harmless, and the original symmetric measurement
+ // conflated the two) -- true dangerous-direction max ran 15-50%
+ // below the old symmetric max in most buckets, and every bucket
+ // showed exceeded=0 even before headroom. Values below are
+ // directional-max * ~1.15-1.3 (more headroom on buckets with
+ // thinner samples, e.g. bucket 0's n=512); buckets 2 and 5 kept at
+ // their prior value since it was already tighter than a fresh 15%
+ // headroom would give. Table indexed the same way _MaterialBucket
+ // buckets the calibration data (combined army scaler / 8, 8
+ // buckets), so it can be re-derived directly from a "calibrate
+ // marginsafety" run. Not a hard theoretical bound -- re-check
+ // before trusting this at sd well beyond 8.
static const SCORE SUPER_LAZY_MARGIN_BY_ARMY[8] =
{
- 2000, 1800, 1800, 1750, 1000, 850, 850, 850,
+ 300, 1635, 1800, 1070, 946, 850, 734, 698,
};
ULONG uSuperLazyMatBucket = MINU(
7, (pos->uArmyScaler[WHITE] + pos->uArmyScaler[BLACK]) / 8);
@@ -5789,44 +5861,42 @@ Return value:
#endif
}
- //
// Evaluate the two kings last.
- //
- c = pos->cNonPawns[BLACK][0];
+ c = pos->cNonPawns[uColor][0];
#ifdef DEBUG
ASSERT(IS_ON_BOARD(c));
{
PIECE pDebugKing = pos->rgSquare[c].pPiece;
ASSERT(IS_VALID_PIECE(pDebugKing));
- ASSERT(GET_COLOR(pDebugKing) == BLACK);
+ ASSERT(GET_COLOR(pDebugKing) == uColor);
ASSERT(IS_KING(pDebugKing));
}
#endif
TIMED_EVAL_CALL(ctx, u64CyclesEvalKing, _EvalKing(pos, c, pHash, ctx));
#ifdef EVAL_DUMP
- Trace("After *k:\n%d\t\t%d\n", pos->iScore[WHITE], pos->iScore[BLACK]);
+ Trace("After our K at %s:\n%d\t\t%d\n",
+ CoorToString(c), pos->iScore[WHITE], pos->iScore[BLACK]);
#endif
- c = pos->cNonPawns[WHITE][0];
+ c = pos->cNonPawns[xColor][0];
#ifdef DEBUG
ASSERT(IS_ON_BOARD(c));
{
PIECE pDebugKing = pos->rgSquare[c].pPiece;
ASSERT(IS_VALID_PIECE(pDebugKing));
- ASSERT(GET_COLOR(pDebugKing) == WHITE);
+ ASSERT(GET_COLOR(pDebugKing) == xColor);
ASSERT(IS_KING(pDebugKing));
}
#endif
TIMED_EVAL_CALL(ctx, u64CyclesEvalKing, _EvalKing(pos, c, pHash, ctx));
#ifdef EVAL_DUMP
- Trace("After .k:\n%d\t\t%d\n", pos->iScore[WHITE], pos->iScore[BLACK]);
+ Trace("After enemy K at %s:\n%d\t\t%d\n",
+ CoorToString(c), pos->iScore[WHITE], pos->iScore[BLACK]);
#endif
- //
// Now that we have the whole attack table generated, think about
// passed pawns identified by the pawn eval routine again. Also
// see if the side not on move has a trapped piece.
- //
#ifdef EVAL_TIME
UINT64 u64PostLazyMiscTimer = SystemReadTimeStampCounter();
#endif
@@ -5856,9 +5926,7 @@ Return value:
// TODO: endgame-specific knowledge? e.g. B over N in an endgame
// with 2 pawn wings?
- //
- // Roll in the reduced material down scaler terms.
- //
+ // Roll in the reduced material down scaler terms (aggregate).
ASSERT(pos->iReducedMaterialDownScaler[BLACK] > -200);
ASSERT(pos->iReducedMaterialDownScaler[BLACK] < +200);
iAlphaMargin = (pos->iReducedMaterialDownScaler[BLACK] *
@@ -5874,62 +5942,22 @@ Return value:
ctx->sCounters.tree.u64CyclesEvalPostLazyMisc +=
(SystemReadTimeStampCounter() - u64PostLazyMiscTimer);
#endif
+#ifdef EVAL_DUMP
+ Trace("After all pieces:\n%d\t\t%d\n",
+ pos->iScore[WHITE], pos->iScore[BLACK]);
+#endif
- //
// Almost done
- //
iScoreForSideToMove = (pos->iScore[pos->uToMove] -
pos->iScore[FLIP(pos->uToMove)]);
-#ifdef EVAL_DUMP
- Trace("At the end:\n%d\t\t%d\n", pos->iScore[WHITE],
- pos->iScore[BLACK]);
-#endif
+
+ // Drive drawish situations towards the draw score.
+ EvalLookForDrawishSituations(pos, &iScoreForSideToMove);
//
// TODO: detect and discourage blocked positions?
//
- // If the side who's ahead by raw material/positional score can't
- // actually force a win with what it has left on the board (see
- // _SideHasWinningChances), squash the score hard towards draw --
- // same idiom as the 50-move dampening just below (and the same
- // signed-arithmetic care: g_iDrawScore is currently always 0, but
- // write this relative to it rather than assuming that, matching
- // the 50-move code's own convention). /16 (not a full collapse to
- // drawscore) deliberately mirrors Crafty's EvaluateDraws -- e.g.
- // KRB vs KR is still theoretically losable by the run-of-the-mill
- // defender with the checks _SideHasWinningChances gates on, so
- // some residual signal survives.
- if ((iScoreForSideToMove > g_iDrawScore[pos->uToMove]) &&
- (FALSE == _SideHasWinningChances(pos, pos->uToMove)))
- {
- iScoreForSideToMove = g_iDrawScore[pos->uToMove] +
- ((iScoreForSideToMove - g_iDrawScore[pos->uToMove]) / 16);
- }
- else if ((iScoreForSideToMove < g_iDrawScore[pos->uToMove]) &&
- (FALSE == _SideHasWinningChances(pos, FLIP(pos->uToMove))))
- {
- iScoreForSideToMove = g_iDrawScore[pos->uToMove] +
- ((iScoreForSideToMove - g_iDrawScore[pos->uToMove]) / 16);
- }
-
- // Drive the score towards draw as we approach a 50 move w/o
- // progress draw.
- if (pos->uFifty > 84)
- {
- ULONG uDrawDist = 101 - pos->uFifty;
- ASSERT(uDrawDist > 0);
- // uDrawDist is ULONG -- multiplying a negative SCORE by it
- // directly promotes the SCORE to unsigned first (usual
- // arithmetic conversions, same rank), wrapping a negative
- // iScoreForSideToMove into a huge positive garbage value
- // instead of scaling it down. Cast uDrawDist to SCORE so the
- // multiply happens in signed arithmetic; its range (1-16) is
- // always representable.
- iScoreForSideToMove = g_iDrawScore[pos->uToMove] +
- (iScoreForSideToMove * (SCORE)uDrawDist / 16);
- }
-
// Adjust dynamic positional component. Unlike the lazy-exit
// margins above (always >= 0 by construction), this can go
// negative -- a side whose positional terms (king safety, pawn
@@ -5968,8 +5996,18 @@ Return value:
// restore this one, not the regular-lazy save in the other
// branch.
//
+ // Direction matters: for an alpha-side exit (saved score below
+ // alpha), only an *upward* real swing is dangerous -- it's the
+ // one that could pull the score back over alpha. A downward
+ // swing just makes the verdict even more clearly correct. Same
+ // in reverse for a beta-side exit. abs() was conflating both
+ // directions, so "exceeded" was really an upper bound on real
+ // unsoundness, not a measurement of it.
RecordSuperLazyMarginSafetySwing(
- pos, abs(iScoreForSideToMove - iSavedSuperLazyScore),
+ pos,
+ (iSavedSuperLazyScore < iAlpha)
+ ? MAX0(iScoreForSideToMove - iSavedSuperLazyScore)
+ : MAX0(iSavedSuperLazyScore - iScoreForSideToMove),
iSuperLazyMargin);
iScoreForSideToMove = iSavedSuperLazyScore;
if (NULL != piPositional)
@@ -5994,8 +6032,13 @@ Return value:
// large would LAZY_EVAL_BASE_MARGIN have had to be before an
// exit here would have been unsound."
//
- RecordMarginSafetySwing(pos, abs(iScoreForSideToMove - iSavedLazyScore),
- iSavedLazyPositional);
+ // Same directional fix as the super-lazy branch above.
+ RecordMarginSafetySwing(
+ pos,
+ (iSavedLazyScore < iAlpha)
+ ? MAX0(iScoreForSideToMove - iSavedLazyScore)
+ : MAX0(iSavedLazyScore - iScoreForSideToMove),
+ iSavedLazyPositional);
//
// Restore exactly what a normal build would have returned --
// this measurement must not change real search behavior.