summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-16 08:27:00 -0700
committerScott Gasch <[email protected]>2026-09-16 08:27:00 -0700
commit4a7ec9ac91939957fb3d3e5564fc24a385b422ff (patch)
tree8cb4ff2bc95faadd559b89e0d23cade65eb405ec /src
parent966a0ef4da9cf3911eb3da0533179199808717b4 (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]>
Diffstat (limited to 'src')
-rwxr-xr-xsrc/chess.h3
-rw-r--r--src/eval.c17
-rw-r--r--src/recogn.c37
-rw-r--r--src/testrecogn.c279
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
//
diff --git a/src/eval.c b/src/eval.c
index 6764349..730365e 100644
--- a/src/eval.c
+++ b/src/eval.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