summaryrefslogtreecommitdiff
path: root/src/recogn.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-18 09:43:17 -0700
committerScott Gasch <[email protected]>2026-09-18 09:43:17 -0700
commitf698c02de213d0ed8968fb7cca5f70df51fa0763 (patch)
tree080d6443fbe62d71de092f9acde61da387dff408 /src/recogn.c
parent4a7ec9ac91939957fb3d3e5564fc24a385b422ff (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.c148
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);
}