diff options
| author | Scott Gasch <[email protected]> | 2026-09-18 09:43:17 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-18 09:43:17 -0700 |
| commit | f698c02de213d0ed8968fb7cca5f70df51fa0763 (patch) | |
| tree | 080d6443fbe62d71de092f9acde61da387dff408 /src/recogn.c | |
| parent | 4a7ec9ac91939957fb3d3e5564fc24a385b422ff (diff) | |
Redesign lazy/super-lazy eval margins with position-specific percentile
tables, and fix four real search/recognizer bugs found while validating it
Main theme: replace the flat, heavily-padded LAZY_EVAL_BASE_MARGIN /
SUPER_LAZY_MARGIN_BY_ARMY constants with per-material-bucket percentile
tables (king safety by attacker army, residual by own army, separate
tables for the lazy vs super-lazy baselines) derived from a 30k-position
calibration pool, targeting a measured ~1-1.5% exceeded-margin rate
instead of an unmeasured "add lots of headroom" floor. QSearch's
FUTILITY_BASE_MARGIN_LAZY/SUPERLAZY were re-derived to match the new,
now-meaningful (p50-based) iPositional this produces. Self-play confirms
the working tree beats both HEAD, a more liberal (~5% miss target)
variant, and a more conservative (near-zero miss) variant of the same
margin mechanism.
Along the way, an initial self-play regression (this margin work bundled
with several changes in one pass, one of which broke things) led to a
day of ZFS-snapshot bisection and then targeted DEBUG-build fuzzing
against a stratified 200k-position corpus (tests/cross_sectional_200k.ep_,
new), which is what actually found and fixed four distinct, real,
pre-existing engine bugs -- none of them related to the margin redesign
itself:
- searchsup.c (CommonSearchInit): three early-return paths (timer
abort, draw, too-deep bailout) left a node's own PV[] row stale
instead of clearing it, letting an ancestor's UpdatePV splice a
leftover move from a completely unrelated node into the displayed
PV -- root cause of a WalkPV/MakeMove assert ("moving a piece to
capture, but nothing was on the target square").
- dynamic.c (RecordEnprisePieceAtPly): the two-slot en-prise dedup
compared (square, piece) instead of square alone, so a same-square
re-record with a different piece (legitimate -- a fail-high
capture's victim is restored by UnmakeMove and can differ across
calls at the same ply) shifted a duplicate square into both slots,
tripping ValueOfMaterialInTroubleDespiteMove's two-distinct-squares
assert.
- recogn.c (_RecognizeKBKP): the wrong-colored-bishop corner-fortress
draw shortcut fired even when the weak side has its own pawn(s),
which can hand the strong side real zugzwang resources the fortress
logic doesn't account for -- produced a false hard draw bound where
EGTB shows a near-forced loss.
- eval.c (_EvalPasserRacesAgainstLoneKings): a rook pawn's race was
credited as winning even when the strong side's own king sits
exactly on its own pawn's queening square (distance 0 specifically --
adjacent is fine, the square is empty) -- a classic stalemate
resource the race heuristic can't see. Only reachable through
_RecognizeKPK's pawn-hash-cache-hit path, which is why
testrecogn.c's TestRecognExhaustiveKPK reported "0 wrong" the whole
time; extended it to cover both color assignments and the full board
(no mirror-shortcut) and documented the remaining cache-hit coverage
gap for a future session to close.
Also: match_play.py gains a local_ebf metric (geometric-mean of
depth-normalized node-growth ratios between consecutive completed
iterations) alongside the existing cumulative nodes**(1/depth) ebf,
which turned out to be misleading when comparing engines with
different lazy-exit aggressiveness -- and probe.c/hash.c pick up two
small, independently-found correctness fixes (TB root-probe promotion
piece missing its color bit; ASSERT(g_pHashTable) crashing on the
legitimate "--hash none" config) noticed along the way.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/recogn.c')
| -rw-r--r-- | src/recogn.c | 148 |
1 files changed, 79 insertions, 69 deletions
diff --git a/src/recogn.c b/src/recogn.c index 07ee0d5..c573f8f 100644 --- a/src/recogn.c +++ b/src/recogn.c @@ -780,72 +780,37 @@ Return value: { // // Neither side has a lone king. This is either KBKP+ or - // KBP+KP+. + // KBP+KP+ -- i.e. the WEAK side has at least one pawn of its + // own, not just a bare king. // - if (FastCountBits(pos->bbPawns[uStrong]) > 0) - { - // - // Strong side can maybe take an adjacent pawn and survive the - // bad bishop. - // - if (uStrong == pos->uToMove) - { - return(UNRECOGNIZED); - } - - // - // Make sure the strong side has the right color bishop - // for his pawns. - // - if (uStrong == WHITE) - { - if (!(bb & ~BBFILE[A]) && - (WhiteSqBishopCount(pos, WHITE) == 0) && - (DISTANCE(cWeakKing, A8) <= 1)) - { - goto at_best_draw_for_strong; - } - - if (!(bb & ~BBFILE[H]) && - (WhiteSqBishopCount(pos, WHITE) == - FastCountBits(pos->bbPieces[WHITE][BISHOP])) && - (DISTANCE(cWeakKing, H8) <= 1)) - { - goto at_best_draw_for_strong; - } - } - else - { - if (!(bb & ~BBFILE[A]) && - (WhiteSqBishopCount(pos, BLACK) == - FastCountBits(pos->bbPieces[BLACK][BISHOP])) && - (DISTANCE(cWeakKing, A1) <= 1)) - { - goto at_best_draw_for_strong; - } - - if (!(bb & ~BBFILE[H]) && - (WhiteSqBishopCount(pos, BLACK) == 0) && - (DISTANCE(cWeakKing, H1) <= 1)) - { - goto at_best_draw_for_strong; - } - } - return(UNRECOGNIZED); - } - else - { - // - // Strong side has no pawns... there's a mate-in-1 here if - // the weak king is stuck in the corner. - // - if ((FastCountBits(pos->bbPieces[uStrong][BISHOP]) > 1) || - (CORNER_DISTANCE(cWeakKing) == 0)) - { - return(UNRECOGNIZED); - } - goto at_best_draw_for_strong; - } + // 2026-09-18: this used to still apply the "wrong-colored- + // bishop rook-pawn corner fortress" shortcut here (same + // bishop-color/pawn-file/king-distance checks as the bare- + // weak-king KBPK branch below, goto at_best_draw_for_strong) + // -- but that shortcut's soundness depends on the weak side + // having nothing but a king to shelter with; it was never + // re-derived for a weak side that has its OWN pawn(s), which + // can hand the strong side real zugzwang/breakthrough + // resources the fortress technique doesn't account for. + // Found via fuzzing tests/cross_sectional_200k.ep_ (DEBUG + // build, RECOGNIZER_SANITY_FAIL): 6k1/6p1/7p/7P/5K2/8/4B3/8 b + // - - 0 0 (White Kf4/Be2/Ph5, Black Kg8/Pg7/Ph6) hit this + // exact shortcut (light-squared bishop, h-file pawn, weak + // king adjacent to h8) and returned a hard RECOGN_LOWER draw + // bound, but EGTB says it's a near-forced loss for Black -- + // the extra g7 pawn (irrelevant to the fortress logic as + // written) apparently gives White exactly this kind of + // resource. Rather than guess a formally-correct boundary + // (e.g. "at most one weak-side pawn is still safe") without + // EGTB-backed proof, conservatively defer this whole case to + // UNRECOGNIZED -- same choice this session's earlier KPK fix + // made for its own unsound shortcut. Slower (falls through to + // real search/EGTB) but never wrong. The bare-weak-king KBPK + // branch below is untouched -- its own shortcuts already + // require TotalNonPawnCount(weak side) == 1, a genuinely bare + // king, which this counterexample doesn't undermine. + // + return(UNRECOGNIZED); } else { @@ -1037,11 +1002,32 @@ Return value: if (TRUE == _EvalPasserRacesAgainstLoneKings(pos, pHash)) { // - // Someone wins. + // Someone wins -- but only trust this as a hard bound when + // the "losing" side has no pawns of its own left. 2026-09-17: + // fuzzing found _EvalPasserRacesAgainstLoneKings only checks + // whether a pawn already flagged "passed" outruns the enemy + // king -- it has no way to see a *connected*-pawn threat + // where neither pawn is individually passed (each still + // contested on an adjacent file) but together they still + // produce a passer and a real race once one of them + // advances. A real case: 8/2K5/6pk/2P2p2/8/6P1/8/8 b (white + // Pc5 "wins the race" against the far-away black king per + // this function) is actually a draw per Syzygy -- black's + // f5/g6 duo (blocked from passed-pawn status by white's own + // g3 pawn) produces enough real counterplay to hold. Crafty's + // EvaluatePassedPawnRaces (evaluate.c) has this identical + // blind spot -- same "only scan already-passed pawns" gate + // -- but it's safe there only because it's a soft eval + // nudge feeding real alpha-beta search, never a hard, + // search-terminating bound the way RECOGN_LOWER/UPPER is + // here. Restricting to "loser has zero pawns" is the one + // case this heuristic can't be undermined by a duo like + // this, since there's no pawn left to produce one. // ASSERT((pos->iScore[BLACK] == 0) || (pos->iScore[WHITE] == 0)); ASSERT((pos->iScore[BLACK] != 0) || (pos->iScore[WHITE] != 0)); - if (pos->iScore[BLACK] > 0) + if ((pos->iScore[BLACK] > 0) && + (0 == FastCountBits(pos->bbPawns[WHITE]))) { ASSERT(pos->iScore[WHITE] == 0); *piScore = (pos->iMaterialBalance[BLACK] + 2 * VALUE_PAWN + @@ -1054,9 +1040,9 @@ Return value: } return(RECOGN_LOWER); } - else + else if ((pos->iScore[WHITE] > 0) && + (0 == FastCountBits(pos->bbPawns[BLACK]))) { - ASSERT(pos->iScore[WHITE] > 0); *piScore = (pos->iMaterialBalance[WHITE] + 2 * VALUE_PAWN + pos->iScore[WHITE]); ASSERT(IS_VALID_SCORE(*piScore)); @@ -1067,6 +1053,11 @@ Return value: } return(RECOGN_LOWER); } + // Else: the "losing" side still has pawns of its own -- + // don't trust the race verdict as a hard bound. Fall + // through to the UNRECOGNIZED/sophisticated-analysis logic + // below, same as if _EvalPasserRacesAgainstLoneKings had + // found no winner at all. } } @@ -1391,6 +1382,25 @@ Return value: { *piScore = iScore; ASSERT((-NMATE < iScore) && (iScore < +NMATE)); +#ifdef DEBUG + if (!_SanityCheckRecognizers(ctx, iScore, uVal)) + { + CHAR *pszDiagFen = PositionToFen(pos); + SCORE iRawEgtb = -12345; + FLAG fEgtbOk = ProbeEGTB(ctx, &iRawEgtb); + Trace("RECOGNIZER_SANITY_FAIL uSigW=%u uSigB=%u uVal=%u " + "iScore=%d egtbOk=%u egtbRawScore=%d (>0=win for " + "side to move, 0=draw, <0=loss for side to move) " + "fen=%s\n", + uSig[WHITE], uSig[BLACK], uVal, (int)iScore, + fEgtbOk, (int)iRawEgtb, + (NULL != pszDiagFen) ? pszDiagFen : "?"); + if (NULL != pszDiagFen) + { + SystemFreeMemory(pszDiagFen); + } + } +#endif ASSERT(_SanityCheckRecognizers(ctx, iScore, uVal)); return(uVal); } |
