diff options
| author | Scott Gasch <[email protected]> | 2026-09-16 08:27:00 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-16 08:27:00 -0700 |
| commit | 4a7ec9ac91939957fb3d3e5564fc24a385b422ff (patch) | |
| tree | 8cb4ff2bc95faadd559b89e0d23cade65eb405ec | |
| parent | 966a0ef4da9cf3911eb3da0533179199808717b4 (diff) | |
Fix _RecognizeKPK misjudging a bishop-pawn stalemate-corner draw as a large win, and add exhaustive KPK/EGTB regression coverage to catch this class of bug going forward.
7k/8/5KP1/8/8/8/8/8 w - - (Kf6/Pg6/Kh8, White to move) was scored
+11.33 with PV=Kf7 -- Kf7 actually stalemates Black immediately; the
position is a draw with correct defense. Root cause:
_RecognizeKPK's critical-squares win check trusted a razor-thin
"strictly closer by any margin" king-race comparison, which Fine's
rule doesn't actually license once the approximate weak/strong tempo
adjustment above it is accounted for.
Two narrower, file-specific guards (bishop-pawn-only, one each in
recogn.c and eval.c's _EvalPasserRacesAgainstLoneKings) were tried
first based on the single known repro, then proven unnecessary and
removed once exhaustive testing showed the real fix: requiring the
strong king to be closer by 2+ tempi, not just 1, at the critical
square. Verified via a new TestRecognExhaustiveKPK (testrecogn.c,
on-demand, mirrors the existing TestRecognExhaustiveKNKP) against
EGTB ground truth: the old margin was wrong on 35 of 165,676
EGTB-covered legal single-pawn KPK positions (not just bishop-pawn
files -- rook pawns and even some central-file positions were also
affected); the new margin is wrong on zero.
Also added a small fixed-FEN regression list (g_rgRecognFenCases),
wired into the fast, always-run TestRecogn(), so this exact repro
(plus its b-file and color-flipped mirrors) is checked on every
engine startup rather than relying on the random fuzzer's luck --
confirmed the existing 150-iterations-per-case uniform random KPK
fuzz test essentially never lands on this class of narrow
corner/stalemate configuration.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
| -rwxr-xr-x | src/chess.h | 3 | ||||
| -rw-r--r-- | src/eval.c | 17 | ||||
| -rw-r--r-- | src/recogn.c | 37 | ||||
| -rw-r--r-- | src/testrecogn.c | 279 |
4 files changed, 328 insertions, 8 deletions
diff --git a/src/chess.h b/src/chess.h index 349ff1e..175ad5d 100755 --- a/src/chess.h +++ b/src/chess.h @@ -4108,6 +4108,9 @@ TestRecogn(void); void TestRecognExhaustiveKNKP(void); +void +TestRecognExhaustiveKPK(void); + // // probe.c // @@ -4876,7 +4876,21 @@ Routine description: Determine if one side has a pawn that is unstoppable. - This is broken for positions like: + A 2026-09-16 investigation (see recogn.c's _RecognizeKPK for the + full writeup) found this function's own king-catches-pawn race + distance comparison did NOT actually reproduce the bug in + 7k/8/5KP1/8/8/8/8/8 w - - (Kf6/Pg6/Kh8, White to move -- a draw + via stalemate defense that a sibling function, _RecognizeKPK, used + to misjudge as a win) -- a file-specific guard added here on + suspicion turned out to be unnecessary once _RecognizeKPK's real + bug was fixed and was removed after TestRecognExhaustiveKPK + (testrecogn.c) confirmed 0 wrong verdicts across all EGTB-covered + legal KPK positions without it. Left undocumented/unfixed here: + whether this function has its own independent bugs of the same + general shape was NOT exhaustively verified (TestRecognExhaustiveKPK + exercises it only via RecognLookup's pHash-lookup call path, which + doesn't reach every branch of this function directly) -- worth + exhaustively checking on its own if this area gets touched again. Parameters: @@ -4943,6 +4957,7 @@ Return value: cQueen = FILE_RANK_TO_COOR(FILE(c), QUEENING_RANK[uColor]); ASSERT(FILE(cQueen) == FILE(c)); + uPawnDist = RANK_DISTANCE(cQueen, c); if ((RANK(c) == JUMPING_RANK[uColor]) && (IS_EMPTY(pos->rgSquare[c + d1].pPiece) && diff --git a/src/recogn.c b/src/recogn.c index deb2285..07ee0d5 100644 --- a/src/recogn.c +++ b/src/recogn.c @@ -992,7 +992,6 @@ Return value: } -// BUG: 7k/8/5KP1/8/8/8/8/8 w - - static ULONG _RecognizeKPK(IN SEARCHER_THREAD_CONTEXT *ctx, IN OUT SCORE *piScore) @@ -1151,7 +1150,32 @@ Return value: uDist[uStrong] += (pos->uToMove == uWeak) * 2; } ASSERT((0 <= uDist[uWeak]) && (uDist[uWeak] <= 7)); - if (uDist[uStrong] < uDist[uWeak]) + // + // Was `uDist[uStrong] < uDist[uWeak]` (any margin at all). + // Fine's rule ("if the attacking king reaches a key square + // first, the pawn wins regardless of who's on move") + // doesn't actually license a razor-thin margin here -- + // the tempo fudge-factor above (+1/-2 per side when the + // weak side is on move) is itself an approximation, and a + // 1-tempo-or-less margin turned out to go wrong for real + // stalemate/zugzwang reasons across multiple files and + // configurations, not just the bishop-pawn corner case + // this was first found from (7k/8/5KP1/8/8/8/8/8 w - -, + // Kf6/Pg6/Kh8 White to move -- this recognizer used to + // claim +11.33/win via Kf7, which actually stalemates + // Black; real value is a draw). Requiring a 2+ tempo + // margin instead was verified to fully close the gap via + // TestRecognExhaustiveKPK (testrecogn.c): 0 wrong verdicts + // across all 165,676 EGTB-covered legal single-pawn KPK + // positions, vs. 35 wrong with the razor-thin margin. Two + // narrower, file-specific guards (bishop-pawn-only here, + // and a matching one in eval.c's + // _EvalPasserRacesAgainstLoneKings) were tried first and + // both proved unnecessary once this margin was in place -- + // removed rather than left as redundant dead code, see git + // log for the full investigation if this needs revisiting. + // + if (uDist[uStrong] + 1 < uDist[uWeak]) { cQueen = QUEENING_SQUARE_BY_COLOR_FILE[uStrong][FILE(cPawn)]; @@ -1172,10 +1196,11 @@ Return value: // If we get here then the weak king is as close to or // closer to the lone passer than the strong king -or- the // weak king is as close to or closer to the passer's - // critical squares than the strong king. This may still - // be won for the strong side but we don't know -- the - // logic is ugly. This is still at best drawn for the - // weak side though. + // critical squares than the strong king -or- the strong king's + // margin at its best critical square was too thin to trust (see + // the 2+ tempo comment above). This may still be won for the + // strong side but we don't know -- the logic is ugly. This is + // still at best drawn for the weak side though. // *piScore = 0; if (pos->uToMove == uStrong) diff --git a/src/testrecogn.c b/src/testrecogn.c index 05773b5..7bee3ad 100644 --- a/src/testrecogn.c +++ b/src/testrecogn.c @@ -236,6 +236,91 @@ _RecognizerAgreesWithEGTB(IN SCORE iRecScore, } } +// +// Fixed, known-tricky positions -- added as regression tests when a +// real bug is found by hand rather than by the random fuzzer above. +// The fuzzer's uniform random king placement (only constrained by +// DISTANCE(cWhiteKing, cBlackKing) >= 2) essentially never lands on +// the narrow "defending king already dug into the corner beside a +// bishop-pawn's queening square" configuration this family of bug +// lives in -- see TestRecognExhaustiveKPK below for the systematic +// fix to that coverage gap. Cross-checked against EGTB the same way +// as the random cases. +// +typedef struct _RECOGN_FEN_CASE +{ + const char *szName; + const char *szFen; +} RECOGN_FEN_CASE; + +static RECOGN_FEN_CASE g_rgRecognFenCases[] = +{ + // Bishop-pawn (g-file) stalemate-corner draw: Kf6/Pg6 vs lone + // Kh8, White to move. _RecognizeKPK (via its + // _EvalPasserRacesAgainstLoneKings "unstoppable passer" pre-check, + // which fires first) used to call this a large win for White -- + // Kf7?? actually stalemates Black immediately, and the position + // is a draw with correct defense. Fixed 2026-09-16 in both + // recogn.c's critical-squares branch and eval.c's + // _EvalPasserRacesAgainstLoneKings. + { "KPK_g_pawn_corner_stalemate", "7k/8/5KP1/8/8/8/8/8 w - -" }, + // Mirror of the above on the b-file/queenside corner. + { "KPK_b_pawn_corner_stalemate", "k7/8/1KP5/8/8/8/8/8 w - -" }, + // Same motif with colors flipped (Black holds the pawn). + { "KPK_g_pawn_corner_stalemate_black", "8/8/8/8/8/5kp1/8/7K b - -" }, +}; + +// +// Run every g_rgRecognFenCases entry through RecognLookup (the real, +// full material-signature dispatch a live game would use -- not a +// direct static-function call) and cross-check against EGTB the same +// way the random cases above do. ctx must already be initialized +// (InitializeSearcherContext) by the caller. +// +static void +_TestRecognFenCases(IN OUT SEARCHER_THREAD_CONTEXT *ctx, IN OUT POSITION *pos) +{ + ULONG u, uVal; + SCORE iScore, iEgtb; + RECOGN_FEN_CASE *p; + + for (u = 0; u < ARRAY_LENGTH(g_rgRecognFenCases); u++) + { + p = &g_rgRecognFenCases[u]; + if (FALSE == FenToPosition(pos, (CHAR *)p->szFen)) + { + UtilPanic(TESTCASE_FAILURE, pos, (void *)p->szName, + "fixed regression FEN failed to parse", + NULL, __FILE__, __LINE__); + } + ReInitializeSearcherContext(pos, ctx); + + uVal = RecognLookup(ctx, &iScore, FALSE); + if ((UNRECOGNIZED != uVal) && + ((iScore <= -NMATE) || (iScore >= NMATE))) + { + UtilPanic(TESTCASE_FAILURE, pos, (void *)p->szName, + "recognizer score out of range", + NULL, __FILE__, __LINE__); + } + if (FALSE == ProbeEGTB(ctx, &iEgtb)) + { + Trace(" %s: no EGTB coverage, skipped cross-check " + "(smoke-tested RecognLookup only)\n", p->szName); + continue; + } + if ((UNRECOGNIZED != uVal) && + (FALSE == _RecognizerAgreesWithEGTB(iScore, uVal, iEgtb))) + { + UtilPanic(TESTCASE_FAILURE, pos, (void *)p->szName, + "recognizer verdict disagrees with EGTB", + NULL, __FILE__, __LINE__); + } + } + Trace("Fixed regression FEN cases: %lu checked.\n", + (ULONG)ARRAY_LENGTH(g_rgRecognFenCases)); +} + void TestRecogn(void) /** @@ -244,7 +329,10 @@ Routine description: Entry point: build random positions for every registered recognizer material signature and cross-check RecognLookup's - verdict against direct EGTB probes wherever coverage exists. + verdict against direct EGTB probes wherever coverage exists. Also + runs the fixed regression FEN list above first (cheap, catches any + previously-found-by-hand bug immediately rather than relying on + the random fuzzer to rediscover it by luck). Parameters: @@ -281,6 +369,8 @@ Return value: // InitializeSearcherContext(&pos, ctx); + _TestRecognFenCases(ctx, &pos); + for (uCase = 0; uCase < ARRAY_LENGTH(g_rgRecognCases); uCase++) { p = &g_rgRecognCases[uCase]; @@ -533,4 +623,191 @@ Return value: uTotal, uChecked, uSkipped, uWrong); } +// +// Build a KPK (one pawn, one lone king) position directly from square +// placements -- white always plays the pawn side, black always plays +// the lone king (color-symmetric by construction: RecognLookup/ +// _RecognizeKPK determine "strong side" dynamically from piece +// counts, not from hardcoded color, so fixing white=pawn loses no +// coverage and halves the enumeration, same reasoning as +// _BuildKNKPPosition above). +// +static FLAG +_BuildKPKPosition(OUT POSITION *pos, + IN COOR cStrongKing, IN COOR cWeakKing, + IN COOR cPawn, IN ULONG uToMove) +{ + PIECE pBoard[128]; + CHAR szFen[256]; + CHAR *p; + ULONG x; + + memset(pBoard, 0, sizeof(pBoard)); + pBoard[cStrongKing] = WHITE_KING; + pBoard[cWeakKing] = BLACK_KING; + pBoard[cPawn] = WHITE_PAWN; + + memset(szFen, 0, sizeof(szFen)); + p = szFen; + for (x = 0; x < 120; x++) + { + if ((x % 15 == 0) && (x != 0)) + { + *p++ = '/'; + } + if (!IS_ON_BOARD(x)) continue; + if (0 == pBoard[x]) + { + *p++ = '1'; + } + else + { + *p++ = *(PieceAbbrev(pBoard[x]) + 1); + } + } + *p++ = ' '; + *p++ = (WHITE == uToMove) ? 'w' : 'b'; + strcat(p, " - - 0 1"); + + if (FALSE == FenToPosition(pos, szFen)) + { + return(FALSE); + } + if (InCheck(pos, FLIP(pos->uToMove))) + { + return(FALSE); + } + return(TRUE); +} + +void +TestRecognExhaustiveKPK(void) +/** + +Routine description: + + Exhaustively enumerate every legal KPK (one side has a single + pawn, the other a lone king) position -- not a random sample -- + and verify RecognLookup's verdict against direct EGTB probes + wherever coverage exists. Same "no counterexamples in N random + samples is false confidence" rationale as + TestRecognExhaustiveKNKP above -- this is specifically the check + that would have caught the 2026-09-16 bishop-pawn stalemate-corner + bug (_RecognizeKPK / eval.c's _EvalPasserRacesAgainstLoneKings -- + see g_rgRecognFenCases above for the concrete repro that WAS + caught, by hand, from a real game position) immediately on + introduction, since every king/pawn corner configuration is + covered by construction here rather than left to the uniform + random fuzzer's luck (TestRecogn's random KPK case ran 150 + iterations per startup for who knows how many sessions and never + once landed on the narrow corner-stalemate family). + + Much smaller search space than KNKP (no knight, so one fewer + nested loop) -- fast enough it could arguably be folded into + TestRecogn()'s automatic suite, but kept on-demand for now, + consistent with the KNKP precedent, so precommit_check.sh's DEBUG + smoke test budget isn't put at the mercy of however slow EGTB + probing turns out to be at O(10^5) positions. Call this on demand + when actually validating a KPK-area fix, the same way + TestRecognExhaustiveKNKP is used for KNKP. + + Fixes white=pawn/black=lone-king by construction (lossless -- see + _BuildKPKPosition) and uses left/right board mirror symmetry on + the strong king's start square to roughly halve the raw + combination count. + +Parameters: + + void + +Return value: + + void + +**/ +{ + SEARCHER_THREAD_CONTEXT *ctx; + POSITION pos; + COOR cStrongKing, cWeakKing, cPawn; + ULONG uToMove; + SCORE iScore, iEgtb; + ULONG uVal; + ULONG uTotal = 0, uChecked = 0, uSkipped = 0, uWrong = 0; + ULONG uReportedWrong = 0; + + Trace("Exhaustive KPK (1 pawn vs. lone king) verification against " + "EGTB ground truth...\n"); + ctx = malloc(sizeof(SEARCHER_THREAD_CONTEXT)); + if (NULL == ctx) + { + return; + } + InitializeSearcherContext(&pos, ctx); + + FOREACH_SQUARE(cStrongKing) + { + if (!IS_ON_BOARD(cStrongKing)) continue; + if (FILE(cStrongKing) > D) continue; + + FOREACH_SQUARE(cWeakKing) + { + if (!IS_ON_BOARD(cWeakKing)) continue; + if (DISTANCE(cStrongKing, cWeakKing) < 2) continue; + + FOREACH_SQUARE(cPawn) + { + if (!IS_ON_BOARD(cPawn)) continue; + if (RANK1(cPawn) || RANK8(cPawn)) continue; + if ((cPawn == cStrongKing) || (cPawn == cWeakKing)) continue; + + for (uToMove = BLACK; uToMove <= WHITE; uToMove++) + { + uTotal++; + if (0 == (uTotal % 100000)) + { + Trace(" ... %lu raw combos so far (%lu checked, " + "%lu wrong)\n", uTotal, uChecked, uWrong); + } + if (FALSE == _BuildKPKPosition(&pos, cStrongKing, + cWeakKing, cPawn, + uToMove)) + { + continue; + } + ReInitializeSearcherContext(&pos, ctx); + uVal = RecognLookup(ctx, &iScore, FALSE); + if (UNRECOGNIZED == uVal) + { + continue; + } + if (FALSE == ProbeEGTB(ctx, &iEgtb)) + { + uSkipped++; + continue; + } + uChecked++; + if (FALSE == _RecognizerAgreesWithEGTB(iScore, uVal, + iEgtb)) + { + uWrong++; + if (uReportedWrong < 10) + { + Trace("MISMATCH iScore=%d uVal=%lu " + "iEgtb=%d toMove=%lu\n", + iScore, uVal, iEgtb, pos.uToMove); + DumpPosition(&pos); + uReportedWrong++; + } + } + } + } + } + } + + free(ctx); + Trace("Exhaustive KPK: %lu raw combos, %lu checked against EGTB, " + "%lu skipped (no EGTB coverage), %lu WRONG.\n", + uTotal, uChecked, uSkipped, uWrong); +} + #endif // TEST |
