summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-23 15:12:10 -0700
committerScott Gasch <[email protected]>2026-09-23 15:12:10 -0700
commitf0b56611d8ef5ce2d121f532931de0252628d995 (patch)
tree9db1896f7f8308b28adc500853e440f0f5122d8e /src
parentb7e4cdfeb3c1aadb3f488f6794e6d1a748dc6c8f (diff)
Real chess knowledge in the KPK[P] recognizer. Sound (but not perfect)
knowledge in eval's EvalRacersAgainstLongKing code. Other minor changes. Even in self-play.
Diffstat (limited to 'src')
-rwxr-xr-xsrc/chess.h62
-rw-r--r--src/eval.c697
-rw-r--r--src/main.c5
-rw-r--r--src/recogn.c813
-rw-r--r--src/testrecogn.c1175
5 files changed, 2443 insertions, 309 deletions
diff --git a/src/chess.h b/src/chess.h
index 8d9a8d8..72289ad 100755
--- a/src/chess.h
+++ b/src/chess.h
@@ -3630,10 +3630,26 @@ InitEval();
SCORE
Eval(SEARCHER_THREAD_CONTEXT *, SCORE, SCORE, SCORE (*)[2]);
+// Exposed (non-static) so testrecogn.c can EGTB-cross-check the
+// wrong-bishop-rook-pawn-fortress shortcut inside it -- see this
+// function's own header comment in eval.c for why that specific case
+// is fine to be occasionally wrong here (a soft eval nudge) in a way
+// it would not be as a recogn.c hard bound.
FLAG
-_EvalPasserRacesAgainstLoneKings(POSITION *,
+SideHasWinningChances(POSITION *pos, ULONG uSide);
+
+FLAG
+_EvalPasserRacesAgainstLoneKings(SEARCHER_THREAD_CONTEXT *,
PAWN_HASH_ENTRY *);
+// Exposed (non-static) so testrecogn.c can build a real,
+// fully-populated PAWN_HASH_ENTRY (bbPasserLocations included) the
+// same way Eval() itself does, in order to EGTB-cross-check
+// _EvalPasserRacesAgainstLoneKings directly rather than guessing at
+// its accuracy.
+PAWN_HASH_ENTRY *
+EvalPawns(SEARCHER_THREAD_CONTEXT *ctx, FLAG *pfDeferred);
+
ULONG
CountKingSafetyDefects(POSITION *pos,
ULONG uSide);
@@ -4211,6 +4227,24 @@ TestRecognExhaustiveKNKP(void);
void
TestRecognExhaustiveKPK(void);
+void
+TestRecognExhaustiveKPvKP(void);
+
+void
+TestRecognSampleKPvKP(void);
+
+void
+TestEvalWrongBishopFortress(void);
+
+void
+TestEvalPasserRacesAgainstLoneKings(void);
+
+void
+TestEvalSamplePasserRacesKPPKPP(void);
+
+void
+TestEvalSamplePasserRacesKPKPPP(void);
+
//
// probe.c
//
@@ -4299,6 +4333,32 @@ RecognLookup(SEARCHER_THREAD_CONTEXT *ctx,
FLAG fProbeEGTB);
//
+// Shared, ctx-free KP+KP* "unstoppable passer" static analysis --
+// deliberately touches no move generation (see recogn.c's
+// IsStaticSafeQueeningPawn for why) so it's safe to call from eval.c's
+// Eval() as well as recogn.c's own hard-bound recognizer. See that
+// function's comment for the full rationale and the trust boundary
+// between recogn.c (must never be wrong) and eval.c (a soft bonus,
+// allowed to occasionally miss what recogn.c's fuller,
+// move-generation-assisted version catches).
+//
+FLAG
+IsStaticSafeQueeningPawn(POSITION *pos, ULONG uColor, COOR cPawn);
+
+FLAG
+HasStaticUnstoppablePasser(POSITION *pos, ULONG uColor, COOR *pcPawn);
+
+//
+// True-lone-king (weak side has zero pawns) "rule of the square + king
+// escort" analysis -- exhaustively verified via TestRecognExhaustiveKPK
+// (0 wrong across all 165,676 EGTB-covered legal single-pawn KPK
+// positions). Also pure geometry, no move generation, so also safe for
+// eval.c to call directly.
+//
+FLAG
+IsUnstoppableLoneKingPasser(POSITION *pos, ULONG uStrong, COOR cPawn);
+
+//
// Unregistered/experimental recognizers, exposed only so testrecogn.c
// can exhaustively validate a candidate before it's ever re-registered
// in InitializeInteriorNodeRecognizers. Not reachable from the normal
diff --git a/src/eval.c b/src/eval.c
index e84470b..3af0d92 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -89,8 +89,8 @@ const int g_iBehind[2] = { -1, +1 };
// { -10, 0, 0, +7, +15, +20, +24, +29, +36 }, // // 2
//};
-static ULONG REDUCED_MATERIAL_DOWN_SCALER[32] =
-{
+static ULONG REDUCED_MATERIAL_DOWN_SCALER[32] =
+{
// none na na m na R 2m na
0, 0, 0, 0, 0, 0, 0, 0,
@@ -98,7 +98,7 @@ static ULONG REDUCED_MATERIAL_DOWN_SCALER[32] =
0, 1, 1, 1, 1, 1, 2, 3,
// 2R2m R4m/QRm Q3m 2R3m/ QR2m Q4m 2R4m/ QR3m
-// Q2R Q2Rm
+// Q2R Q2Rm
4, 5, 6, 6, 7, 7, 7, 7,
// na Q2R2m QR4m na Q2R3m na na full
@@ -130,7 +130,7 @@ static ULONG CASTLE_AND_DEVELOPMENT_SCALER[32] =
};
static ULONG REDUCED_MATERIAL_UP_SCALER[32] =
-{
+{
// none na na m na R 2m na
8, 8, 8, 8, 8, 8, 8, 8,
@@ -138,15 +138,15 @@ static ULONG REDUCED_MATERIAL_UP_SCALER[32] =
8, 7, 7, 7, 7, 6, 6, 5,
// 2R2m R4m/QRm Q3m 2R3m/ QR2m Q4m 2R4m/ QR3m
-// Q2R Q2Rm
+// Q2R Q2Rm
4, 3, 3, 3, 2, 2, 2, 1,
// na Q2R2m QR4m na Q2R3m na na full
1, 1, 0, 0, 0, 0, 0, 0,
};
-static ULONG PASSER_MATERIAL_UP_SCALER[32] =
-{
+static ULONG PASSER_MATERIAL_UP_SCALER[32] =
+{
// none na na m na R 2m na
8, 8, 8, 8, 8, 8, 7, 7,
@@ -154,13 +154,19 @@ static ULONG PASSER_MATERIAL_UP_SCALER[32] =
7, 6, 7, 5, 4, 5, 4, 3,
// 2R2m R4m/QRm Q3m 2R3m/ QR2m Q4m 2R4m/ QR3m
-// Q2R Q2Rm
+// Q2R Q2Rm
2, 2, 1, 1, 0, 0, 0, 0,
// na Q2R2m QR4m na Q2R3m na na full
0, 0, 0, 0, 0, 0, 0, 0,
};
+static ULONG MULTIPLE_PASSER_SCALER[9] =
+{
+// 0 1 2 3 4 5 6 7 8
+ 0, 32, 27, 22, 18, 11, 8, 4, 2
+};
+
COOR QUEENING_RANK[2] = { 1, 8 };
COOR JUMPING_RANK[2] = { 7, 2 };
@@ -210,7 +216,7 @@ static SCORE PAWN_CENTRALITY_BONUS[128] =
// most exploitable. Table is indexed by absolute square (not
// color-relative), hence the rank2/rank7, rank3/rank6, rank4/rank5
// mirroring -- same as the original layout.
-static SCORE BACKWARD_SHIELDED_BY_LOCATION[128] =
+static SCORE BACKWARD_SHIELDED_BY_LOCATION[128] =
{
+0, +0, +0, +0, +0, +0, +0, +0, 0,0,0,0,0,0,0,0,
-7, -5, -5, -7, -7, -5, -5, -7, 0,0,0,0,0,0,0,0,
@@ -223,7 +229,7 @@ static SCORE BACKWARD_SHIELDED_BY_LOCATION[128] =
+0, +0, +0, +0, +0, +0, +0, +0, 0,0,0,0,0,0,0,0
};
-static SCORE BACKWARD_EXPOSED_BY_LOCATION[128] =
+static SCORE BACKWARD_EXPOSED_BY_LOCATION[128] =
{
+0, +0, +0, +0, +0, +0, +0, +0, 0,0,0,0,0,0,0,0,
-12, -9, -9, -11, -11, -9, -9, -12, 0,0,0,0,0,0,0,0,
@@ -256,7 +262,7 @@ static SCORE BACKWARD_EXPOSED_BY_LOCATION[128] =
// "All About Doubled Pawns"
//
// Note: indexed by number of files with 1+ pawn on them.
-static SCORE DOUBLED_PAWN_PENALTY_BY_COUNT[4][9] =
+static SCORE DOUBLED_PAWN_PENALTY_BY_COUNT[4][9] =
{ // ------------ count of doubled+ pawns ------------
{// 0 1 2 3 4 5 6 7 8 : no majors alive
+0, -32, -65, -99, -134, -170, -207, -222, -250
@@ -290,13 +296,13 @@ static SCORE ISOLATED_DOUBLED_PAWN = -7;
//
// Note: -25% to -33% if the enemy occupies or controls the next sq.
//
-static SCORE PASSER_BY_RANK[2][9] =
+static SCORE PASSER_BY_RANK[2][9] =
{// 0 1 2 3 4 5 6 7 8
{ +0, +0,+162,+111, +62, +36, +18, +13, +0 }, // black
{ +0, +0, +13, +18, +36, +62,+111,+162, +0 } // white
};
-static SCORE CANDIDATE_PASSER_BY_RANK[2][9] =
+static SCORE CANDIDATE_PASSER_BY_RANK[2][9] =
{// 0 1 2 3 4 5 6 7 8
{ +0, +0, +0, +48, +34, +22, +13, +9, +0 }, // black
{ +0, +0, +9, +13, +22, +34, +48, +0, +0 } // white
@@ -358,7 +364,7 @@ static SCORE PASSER_BONUS_AS_MATERIAL_COMES_OFF[32] =
+14, +9, +20, +4, +3, +3, 0, 0,
// 2R2m R4m/QRm Q3m 2R3m/ QR2m Q4m 2R4m/ QR3m
-// Q2R Q2Rm
+// Q2R Q2Rm
0, 0, 0, 0, 0, 0, 0, 0,
// na Q2R2m QR4m na Q2R3m na na full
@@ -468,7 +474,7 @@ static SCORE KNIGHT_CENTRALITY_BONUS[128] =
static SCORE KNIGHT_KING_TROPISM_BONUS[9] =
{// 0 1 2 3 4 5 6 7 8
- 0, +15, +12, +9, +4, +0, +0, +0, +0
+ 0, +15, +12, +9, +4, +0, +0, +0, +0
};
@@ -498,7 +504,7 @@ static SCORE KNIGHT_MOBILITY_BY_COUNT[9] =
-17, -10, -6, 0, +3, +5, +7, +8, +8
};// ^ |
-static SCORE KNIGHT_WITH_N_PAWNS_SUPPORTING[3] =
+static SCORE KNIGHT_WITH_N_PAWNS_SUPPORTING[3] =
{
+0, +4, +8
};
@@ -562,7 +568,7 @@ static SCORE ROOK_LEADS_PASSER_BY_PASSER_RANK[2][9] =
static SCORE KING_TRAPPING_ROOK = -40;
static SCORE ROOK_VALUE_AS_PAWNS_COME_OFF[17] =
-{// 0 1 2 3 4 5 6 7 8
+{// 0 1 2 3 4 5 6 7 8
+55, +51, +44, +38, +33, +27, +22, +16, +7,
// 9 10 11 12 13 14 15 16
+1, -3, -6, -9, -12, -18, -22, -25
@@ -728,7 +734,7 @@ static SCORE KING_TO_CENTER[128] =
#define KEEP_KING_AT_HOME_THRESHOLD \
(VALUE_ROOK + VALUE_ROOK + VALUE_BISHOP + VALUE_KING + 1)
-static ULONG KING_COUNTER_BY_ATTACK_PATTERN[32] =
+static ULONG KING_COUNTER_BY_ATTACK_PATTERN[32] =
{
//p 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
//m 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1
@@ -2139,8 +2145,8 @@ Return value:
}
-static PAWN_HASH_ENTRY *
-_EvalPawns(IN OUT SEARCHER_THREAD_CONTEXT *ctx,
+PAWN_HASH_ENTRY *
+EvalPawns(IN OUT SEARCHER_THREAD_CONTEXT *ctx,
OUT FLAG *pfDeferred)
/**
@@ -2148,6 +2154,11 @@ Routine description:
Evaluate pawn structures; return a ptr to a pawn hash entry.
+ Exposed (non-static) so testrecogn.c can build a real,
+ fully-populated PAWN_HASH_ENTRY (bbPasserLocations included) to
+ drive an EGTB cross-check of _EvalPasserRacesAgainstLoneKings --
+ same rationale as SideHasWinningChances's own exposure above.
+
Parameters:
SEARCHER_THREAD_CONTEXT *ctx : the searcher thread context
@@ -5550,34 +5561,151 @@ Return value:
}
+static FLAG
+_EvalUnstoppablePasser(IN OUT POSITION *pos)
+/**
+
+Routine description:
+
+ True-knowledge replacement for _EvalPasserRacesAgainstLoneKings
+ (below), used ONLY when both sides are pure king-and-pawns (both
+ TotalNonPawnCount == 1) -- the exact KPvKP shape this session's
+ investigation exhaustively verified. Calls the SAME shared,
+ exhaustively-verified routines recogn.c's hard-bound KPvKP
+ recognizer uses (IsUnstoppableLoneKingPasser for a true-lone-king
+ opponent's any-rank square-rule race, HasStaticUnstoppablePasser for
+ the general "opponent also has pawns" 7th/2nd-rank case), so there
+ is exactly one implementation of "is this pawn unstoppable," not two
+ that can silently diverge the way this file's old passer-race
+ heuristic and recogn.c's KPK recognizer once did (the whole reason
+ this investigation started).
+
+ _EvalPasserRacesAgainstLoneKings's own precondition is looser (fires
+ whenever EITHER side is bare, even if the OTHER side still has real
+ pieces, e.g. K+R+P vs K) -- that broader shape was never covered by
+ this session's KPvKP work and is NOT provably safe for the
+ shared routines here (their reasoning assumes no other pieces exist
+ to interpose/defend/escape), so the call site below keeps routing
+ that case to the old function unchanged.
+
+ Returns whether it actually placed a credit for either side. This
+ function is trustworthy but narrow -- it only ever fires for the
+ true-lone-king sub-case of pure KPvKP, and stays silent (returns
+ FALSE) for the "both sides still have pawns" sub-case rather than
+ guess. The caller uses this to fall back to
+ _EvalPasserRacesAgainstLoneKings (dodgy but broader-coverage) only
+ when this function had nothing to say, instead of losing that
+ coverage outright.
+
+Parameters:
+
+ POSITION *pos
+
+Return value:
+
+ FLAG -- TRUE if a credit was placed for either side
+
+**/
+{
+ ULONG uColor;
+ FLAG fFired = FALSE;
+
+ FOREACH_COLOR(uColor)
+ {
+ ULONG xColor = FLIP(uColor);
+ COOR c;
+
+ if (0 == FastCountBits(pos->bbPawns[uColor]))
+ {
+ continue;
+ }
+
+ if (0 == FastCountBits(pos->bbPawns[xColor]))
+ {
+ // True lone-king opponent: the general "rule of the
+ // square + king escort" race, any rank, not just the
+ // 7th/2nd -- see IsUnstoppableLoneKingPasser's own header
+ // comment (0 wrong across all 165,676 EGTB-covered legal
+ // single-pawn KPK positions).
+ BITBOARD bb = pos->bbPawns[uColor];
+ while (IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(&bb)))
+ {
+ if (TRUE == IsUnstoppableLoneKingPasser(pos, uColor, c))
+ {
+ EVAL_TERM(uColor,
+ PAWN,
+ c,
+ pos->iScore[uColor],
+ RACER_WINS_RACE,
+ "unstoppable passer (lone king)");
+ fFired = TRUE;
+ //
+ // break, not return: uColor is done (one credit is
+ // enough for this side), but the OTHER color still
+ // needs to be checked by this same FOREACH_COLOR
+ // loop -- an earlier version of this function
+ // returned here, which meant whichever color
+ // happened to be checked first (by FOREACH_COLOR's
+ // iteration order) silently suppressed any check of
+ // the other side at all, even a far more urgent
+ // threat. Found via a real regression: White's Ph7
+ // one push from queening
+ // (8/7P/6p1/3k1p2/2p5/3pK3/8/8 w - -, this whole
+ // investigation's original motivating position)
+ // scored -6.09 (the exact ORIGINAL buggy value from
+ // before this session) because Black's much weaker
+ // d3 pawn got checked first and its credit's early
+ // return meant White's real threat was never
+ // evaluated at all.
+ //
+ break;
+ }
+ }
+ }
+ //
+ // Deliberately nothing here for "opponent also has pawns."
+ // A prior session (see the stashed 2026-09-2x eval.c/recogn.c
+ // "passer-race + king-escort rework") tried exactly this
+ // extension, measured it at only ~1% wrong-side rate against
+ // EGTB, and STILL found it caused a real playing-strength
+ // regression in overnight self-play -- a low measured
+ // false-positive rate doesn't guarantee an eval nudge is
+ // actually good for play; it can still systematically mislead
+ // move choice/ordering in ways a raw error-rate number doesn't
+ // capture. That session's own conclusion (matched here rather
+ // than re-litigated): restrict this function to the exact same
+ // true-lone-king precondition recogn.c's own hard bound
+ // trusts, and leave the "both sides have pawns" case to
+ // recogn.c's search-time recognizer (_RecognizeKPK) and plain
+ // positional eval terms, not a duplicate heuristic here. An
+ // eval.c attempt at this (HasRacingUnstoppablePasser) was
+ // built and measured this session before finding this same
+ // prior-session lesson -- removed rather than kept as unused
+ // dead code; see git history if this is ever revisited with a
+ // real self-play test to back it, not just an EGTB sample.
+ //
+ }
+
+ return(fFired);
+}
+
+
FLAG
-_EvalPasserRacesAgainstLoneKings(IN OUT POSITION *pos,
- IN PAWN_HASH_ENTRY *pHash)
+_EvalPasserRacesAgainstLoneKings(IN OUT SEARCHER_THREAD_CONTEXT *ctx,
+ IN PAWN_HASH_ENTRY *pHash)
/**
Routine description:
Determine if one side has a pawn that is unstoppable.
- 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.
+ This always works for KPK or KPKP but gets dodgy if a side has
+ more than 1 pawn. Error rates on KPPKPP are ~5% and KPKPPP
+ are ~1%.
Parameters:
- POSITION *pos,
+ SEARCHER_THREAD_CONTEXT *ctx,
PAWN_HASH_ENTRY *pHash,
Return value:
@@ -5587,189 +5715,187 @@ Return value:
**/
{
BITBOARD bb[2];
- ULONG uColor;
- ULONG xColor;
+ ULONG uColor, xColor;
int d1;
- COOR cQueen;
- COOR cKing, c;
- ULONG uKingDist, uPawnDist, uFriendDist;
- ULONG uRacerDist[2];
+ COOR cKing[2];
+ COOR c, cQueen;
+ ULONG uEnemyKingDist, uPawnDist, uFriendKingDist;
+ ULONG uMinRacerDist[2] = {99, 99};
+ ULONG uMovesSpent[2];
FLAG fDontCountMeOut[2];
+ POSITION *pos = &ctx->sPosition;
+
+ // Don't recognize if stm is in check.
+ PLY_INFO *pi = &ctx->sPlyInfo[ctx->uPly];
+ if (pi->fInCheck)
+ {
+ return(FALSE);
+ }
+
+ // Only deal with positions that have passers.
bb[BLACK] = pHash->bbPasserLocations[BLACK];
bb[WHITE] = pHash->bbPasserLocations[WHITE];
if (!(bb[BLACK] | bb[WHITE]))
{
return(FALSE);
}
- ASSERT((TotalNonPawnCount(pos, WHITE) == 1) ||
- (TotalNonPawnCount(pos, BLACK) == 1))
+ ASSERT((TotalNonPawnCount(pos, WHITE) == 1) || (TotalNonPawnCount(pos, BLACK) == 1));
- // Both deferred past the no-passer early return above -- no need to
- // pay for this on the common case (most positions have no passer at
- // all, and _EvalPasserRacesAgainstLoneKings itself is only even called when a side
- // is down to a bare king, so this function runs unconditionally in
- // the pre-lazy-exit segment whenever that's true).
- uRacerDist[BLACK] = 99;
- uRacerDist[WHITE] = 99;
+ // Don't make a call if the king is well placed and the side has
+ // pawns left.
fDontCountMeOut[BLACK] = ((RANK(KingPosition(pos, BLACK)) <= 3) &&
(pos->bbPawns[BLACK] != 0));
fDontCountMeOut[WHITE] = ((RANK(KingPosition(pos, WHITE)) >= 6) &&
(pos->bbPawns[WHITE] != 0));
+
+ cKing[WHITE] = KingPosition(pos, WHITE);
+ ASSERT(IS_KING(pos->rgSquare[cKing[WHITE]].pPiece));
+ ASSERT(GET_COLOR(pos->rgSquare[cKing[WHITE]].pPiece) == WHITE);
+ cKing[BLACK] = KingPosition(pos, BLACK);
+ ASSERT(IS_KING(pos->rgSquare[cKing[BLACK]].pPiece));
+ ASSERT(GET_COLOR(pos->rgSquare[cKing[BLACK]].pPiece) == BLACK);
+
FOREACH_COLOR(uColor)
{
- xColor = FLIP(uColor);
- if (TotalNonPawnCount(pos, xColor) == 1)
+ // If uColor has no passers, skip it.
+ if (!bb[uColor])
{
- d1 = 16 * g_iAhead[uColor];
- ASSERT((d1 * 2) == (32 * g_iAhead[uColor]));
- if (bb[uColor])
- {
- while(IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(
- &(bb[uColor]))))
- {
- // If the enemy king can take this passer and is
- // on the move, all bets are off!
- cKing = KingPosition(pos, xColor);
- ASSERT(IS_KING(pos->rgSquare[cKing].pPiece));
- ASSERT(GET_COLOR(pos->rgSquare[cKing].pPiece) != uColor);
- if (pos->uToMove == xColor &&
- (DISTANCE(cKing, c) == 1))
- {
- // TODO: something clever here about protected pawns
- continue;
- }
-
- cQueen = FILE_RANK_TO_COOR(FILE(c), QUEENING_RANK[uColor]);
- ASSERT(FILE(cQueen) == FILE(c));
+ continue;
+ }
- // 2026-09-18: rook pawn, strong side's OWN king on
- // the same file, on or adjacent to its own
- // queening square -- i.e. the strong king is in
- // front of its own pawn. Checked up front, before
- // either race-credit computation below gets a
- // chance to touch uRacerDist[uColor] for this
- // pawn, and skips via `continue` (not by resetting
- // uRacerDist[uColor], which would also wipe out a
- // legitimate credit already earned by a different
- // passer of the same color from an earlier loop
- // iteration -- bb[uColor] can hold more than one
- // passer). A friendly king blocking its own rook
- // pawn's queening square is a classic drawing
- // resource (the defender can potentially reach a
- // stalemate), not a neutral or helpful thing --
- // unlike a center pawn, where the king merely costs
- // a tempo (handled below via the existing +1 case),
- // a rook pawn's single queening square occupied by
- // its own king has no safe way to step aside
- // without delicate tempo/opposition analysis this
- // cheap eval-time heuristic can't do (that's what
- // recogn.c's _RecognizeKPK critical-square logic is
- // for). Found via fuzzing tests/cross_sectional_
- // 200k.ep_: two of this session's real recognizer/
- // eval bugs both had the strong side's own king in
- // front of its own rook pawn (8/8/8/8/8/8/4K2p/7k
- // w - - -- White can stalemate Black rather than
- // let Ph2 queen).
- //
- // Distance must be exactly 0 (king ON the queening
- // square), not <=1 -- if the king is merely
- // adjacent, the queening square itself is empty
- // and the pawn walks in and promotes next move
- // with nothing in the way; that's a genuine win,
- // not a stalemate risk. Only the king literally
- // occupying its own pawn's only queening square is
- // the danger this guards against.
- if (((A == FILE(c)) || (H == FILE(c))) &&
- (FILE(c) == FILE(KingPosition(pos, uColor))) &&
- (0 == DISTANCE(KingPosition(pos, uColor), cQueen)))
- {
- // Matches the unconditional assignment every
- // other path through this loop body makes --
- // this color's own passer was genuinely
- // examined here, even though disqualified.
- fDontCountMeOut[uColor] = TRUE;
- continue;
- }
+ // If xColor has any piece other than a king (and maybe some pawns), skip.
+ xColor = FLIP(uColor);
+ if (TotalNonPawnCount(pos, xColor) != 1)
+ {
+ continue;
+ }
+ d1 = 16 * g_iAhead[uColor];
- uPawnDist = RANK_DISTANCE(cQueen, c);
- if ((RANK(c) == JUMPING_RANK[uColor]) &&
- (IS_EMPTY(pos->rgSquare[c + d1].pPiece) &&
- IS_EMPTY(pos->rgSquare[c + d1 * 2].pPiece)))
- {
- uPawnDist--;
- }
- ASSERT((uPawnDist >= 1) && (uPawnDist <= 6));
+ uMovesSpent[uColor] = 1;
+ uMovesSpent[xColor] = 0;
+ while(IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(
+ &(bb[uColor]))))
+ {
+ // If this pawn is right next to the enemy king, don't
+ // predict the number of moves it takes to queen.
+ if (xColor == pos->uToMove &&
+ (DISTANCE(cKing[xColor], c) == 1) &&
+ (DISTANCE(cKing[uColor], c) != 1))
+ {
+ uMovesSpent[xColor] += 1;
+ continue;
+ }
+ fDontCountMeOut[uColor] = TRUE;
- uKingDist = DISTANCE(cKing, cQueen);
- if (uKingDist > 0)
- {
- uKingDist -= (pos->uToMove == xColor);
- }
- ASSERT((uKingDist >= 0) && (uPawnDist <= 7));
+ cQueen = FILE_RANK_TO_COOR(FILE(c), QUEENING_RANK[uColor]);
+ ASSERT(FILE(cQueen) == FILE(c));
- if (uPawnDist < uKingDist)
- {
- uRacerDist[uColor] = MINU(uRacerDist[uColor],
- uPawnDist);
- }
+ // Don't call rook pawn races with the friendly king in a
+ // dodgy position.
+ if (((A == FILE(c)) || (H == FILE(c))) &&
+ (FILE(c) == FILE(cKing[uColor])) &&
+ (0 == DISTANCE(cKing[uColor], cQueen)))
+ {
+ continue;
+ }
- cKing = KingPosition(pos, uColor);
- ASSERT(IS_KING(pos->rgSquare[cKing].pPiece));
- ASSERT(GET_COLOR(pos->rgSquare[cKing].pPiece) == uColor);
+ uPawnDist = RANK_DISTANCE(cQueen, c);
+ uPawnDist -= ((RANK(c) == JUMPING_RANK[uColor]) &&
+ (IS_EMPTY(pos->rgSquare[c + d1].pPiece) &&
+ IS_EMPTY(pos->rgSquare[c + d1 * 2].pPiece)));
+ ASSERT((uPawnDist >= 1) && (uPawnDist <= 6));
+ uPawnDist += uMovesSpent[uColor];
- uFriendDist = DISTANCE(cKing, cQueen);
- if ((uFriendDist <= 1) && (DISTANCE(cKing, c) <= 1))
- {
- if (FILE(c) != FILE(cKing))
- {
- uRacerDist[uColor] = MINU(uRacerDist[uColor],
- uPawnDist);
- }
- else if ((FILE(cKing) != A) &&
- (FILE(cKing) != H))
- {
- uRacerDist[uColor] = MINU(uRacerDist[uColor],
- (uPawnDist + 1));
- }
- // else: rook pawn, own king on the same file
- // but more than 1 square from the queening
- // square -- the specific adjacent/on-queening-
- // square danger this session found (see the
- // up-front `continue` guard above, right after
- // cQueen is computed) doesn't apply here; leave
- // as-is (no extra credit, no suppression),
- // same as this branch's original behavior.
- }
- fDontCountMeOut[uColor] = TRUE;
+ // Rule of the square; can this passer make it to
+ // the queening square before the enemy king?
+ uEnemyKingDist = DISTANCE(cKing[xColor], cQueen);
+ if (uEnemyKingDist > 0)
+ {
+ uEnemyKingDist -= (pos->uToMove == xColor);
+ }
+ ASSERT((uEnemyKingDist >= 0) && (uPawnDist <= 7));
+ if (uPawnDist < uEnemyKingDist)
+ {
+ uMinRacerDist[uColor] = MINU(uMinRacerDist[uColor], uPawnDist);
+ }
- //
- // TODO: winning connected passers
- //
+ // See if the friend king can escort this passer in. Same-
+ // file adjacency alone doesn't distinguish a king BEHIND
+ // the pawn (genuine escort) from a king sitting on the
+ // very next square in front of it, i.e. cKing[uColor] ==
+ // c + d1 (blocking its own pawn's path, not helping it) --
+ // found via an exhaustive KPvKP/EGTB cross-check
+ // (8/8/8/8/8/6Pp/7k/5K2 b - -: Black king h2 sits directly
+ // in front of its own h3 pawn, yet this branch credited an
+ // escort bonus as if h2 were behind it). Exclude that
+ // square explicitly rather than just checking direction,
+ // since it's the one same-file square DISTANCE<=1 permits
+ // that unambiguously blocks rather than escorts.
+ uFriendKingDist = DISTANCE(cKing[uColor], cQueen);
+ if ((uFriendKingDist <= 1) && (DISTANCE(cKing[uColor], c) <= 1))
+ {
+ if (FILE(c) != FILE(cKing[uColor]))
+ {
+ uMinRacerDist[uColor] = MINU(uMinRacerDist[uColor],
+ uPawnDist);
+ }
+ else if ((cKing[uColor] != (c + d1)) &&
+ (FILE(cKing[xColor]) != A) &&
+ (FILE(cKing[xColor]) != H))
+ {
+ uMinRacerDist[uColor] = MINU(uMinRacerDist[uColor],
+ (uPawnDist + 1));
}
}
}
+
+ // uMovesSpent[xColor] counts how many of uColor's OWN passers
+ // just got individually skipped above as "adjacent to xColor's
+ // king, xColor to move -- assume it's captured for free." Fine
+ // when it happens to exactly one pawn, but when it happens to
+ // TWO OR MORE, xColor's king is adjacent to multiple of
+ // uColor's passers at once and can only actually capture ONE
+ // of them -- the classic king-overload fork (found via random
+ // KPPKPP/KPKPPP sampling against EGTB:
+ // 8/5P2/1k6/8/8/8/pKp2P2/8 w - -, White king b2 flanked by
+ // Black's a2 and c2, each one square from queening -- White
+ // captures one, Black queens the other, Black wins by force,
+ // but the per-pawn skip above silently dropped BOTH of
+ // Black's passers from consideration, crediting White's own
+ // ordinary race instead). This function's per-pawn model has
+ // no way to actually resolve a fork like this ("TODO: winning
+ // connected passers" above already flags the general
+ // connected-passer gap as out of scope) -- the safe, precision-
+ // over-recall response is not to guess who really wins, but to
+ // stop trusting THE OTHER side's credit here, the same way
+ // fDontCountMeOut already protects a side with real drawing
+ // chances elsewhere in this function.
+ if (uMovesSpent[xColor] >= 2)
+ {
+ fDontCountMeOut[uColor] = TRUE;
+ }
}
- if ((uRacerDist[WHITE] < uRacerDist[BLACK]) &&
+ if ((uMinRacerDist[WHITE] < uMinRacerDist[BLACK]) &&
(FALSE == fDontCountMeOut[BLACK]))
{
EVAL_TERM(WHITE,
PAWN,
ILLEGAL_COOR,
pos->iScore[WHITE],
- RACER_WINS_RACE - uRacerDist[WHITE] * 4,
+ RACER_WINS_RACE - uMinRacerDist[WHITE] * 4,
"winning racer pawn");
return(TRUE);
}
- else if ((uRacerDist[BLACK] < uRacerDist[WHITE]) &&
+ else if ((uMinRacerDist[BLACK] < uMinRacerDist[WHITE]) &&
(FALSE == fDontCountMeOut[WHITE]))
{
EVAL_TERM(BLACK,
PAWN,
ILLEGAL_COOR,
pos->iScore[BLACK],
- RACER_WINS_RACE - uRacerDist[BLACK] * 4,
+ RACER_WINS_RACE - uMinRacerDist[BLACK] * 4,
"winning racer pawn");
return(TRUE);
}
@@ -5919,17 +6045,17 @@ Return value:
PAWN,
ILLEGAL_COOR,
pos->iScore[WHITE],
- uNumPassers[WHITE] * PASSER_BONUS_AS_MATERIAL_COMES_OFF[u],
+ (uNumPassers[WHITE] * PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] * MULTIPLE_PASSER_SCALER[uNumPassers[WHITE]]) >> 5,
"passer value material");
- // ASSERT(PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] >= 0);
+ ASSERT(PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] >= 0);
u = pos->uArmyScaler[WHITE];
EVAL_TERM(BLACK,
PAWN,
ILLEGAL_COOR,
pos->iScore[BLACK],
- uNumPassers[BLACK] * PASSER_BONUS_AS_MATERIAL_COMES_OFF[u],
+ (uNumPassers[BLACK] * PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] * MULTIPLE_PASSER_SCALER[uNumPassers[BLACK]]) >> 5,
"passer value material");
- // ASSERT(PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] >= 0);
+ ASSERT(PASSER_BONUS_AS_MATERIAL_COMES_OFF[u] >= 0);
}
@@ -5967,35 +6093,86 @@ Return value:
#endif
//
+ // A material-VALUE imbalance (uNonPawnMaterial differing, the
+ // caller's gate) is not the same fact as a piece-COUNT imbalance --
+ // e.g. 2R vs R+N is "up the exchange" with equal piece count on
+ // both sides, not a side that has simplified/traded down to fewer
+ // pieces. Ported from a stashed, previously-uncommitted eval.c
+ // change (2026-09-2x eval audit session) -- mirrors Crafty's
+ // EvaluateMaterial (~/crafty/evaluate.c), whose bad_trade bonus
+ // explicitly requires Abs(occupied_count_diff) != 2 &&
+ // occupied_count_diff != 0, i.e. it never fires when piece counts
+ // are equal, deliberately leaving pure up-the-exchange positions to
+ // ordinary material valuation instead of this term. Without this
+ // check, typhoon double-counts: real, up-the-exchange credit
+ // already exists via material value, and this term used to stack
+ // an unconditional extra "keep trading" bonus on top regardless of
+ // whether uAhead had actually traded down to fewer pieces at all --
+ // found via the original eval audit
+ // (8/2r4p/8/5p1R/8/1n4P1/1pk2PKP/4R3 b, 2R vs R+N, equal piece
+ // count: typhoon +2.07 for White, Crafty -1.63 for White --
+ // opposite sign, traced to this exact double-count).
+ //
+ if (TotalNonPawnCount(pos, uAhead) == TotalNonPawnCount(pos, uBehind))
+ {
+ return;
+ }
+
+ //
// Encourage the side that is ahead in piece material to continue
// trading pieces and not to trade pawns. This also has the
// effect of making the side that is behind in material want to
// trade pawns and not pieces. This also gives the side that is
// ahead a bit of a "bad trade" bonus. Note that the bonus is not
- // given if the side "ahead" has no pawns; this is so that the
- // engine will not like positions like KNB vs KRP - the side with
- // the pawn has the winning chances.
+ // given if the side "ahead" has no pawns AND no mating material of
+ // its own; this is so that the engine will not like positions like
+ // KNB vs KRP - the side with the pawn has the winning chances.
//
ULONG uAheadPawnCount = FastCountBits(pos->bbPawns[uAhead]);
ULONG uInverseBehindPieceCount = 9 - MINU(9, TotalNonPawnCount(pos, uBehind));
+ //
+ // "No mating material" here means the SAME lone-minor-piece test
+ // SideHasWinningChances (above) already uses: king + exactly one
+ // knight/bishop and nothing else -- a K+N or K+B can never force
+ // mate alone, which is the actual KNB-vs-KRP failure mode this
+ // whole guard exists for. Found via a real counterexample
+ // (2k5/4r3/KP2P3/P7/7P/8/8/8 b - -): Black is ahead in non-pawn
+ // material with a ROOK, not a lone minor -- a rook can genuinely
+ // blockade/pick off multiple passed pawns on its own, nothing like
+ // the KNB-vs-KRP case this guard was built for -- yet the old
+ // "zero pawns -> zero bonus" version zeroed this out unconditionally
+ // by piece count alone, silencing exactly the term that should
+ // have been pushing back against White's four separately-scored
+ // passed pawns.
+ //
+ FLAG fAheadLoneMinorNoMate =
+ (2 == TotalNonPawnCount(pos, uAhead)) &&
+ (1 == (FastCountBits(pos->bbPieces[uAhead][KNIGHT]) +
+ FastCountBits(pos->bbPieces[uAhead][BISHOP])));
EVAL_TERM(uAhead,
0,
ILLEGAL_COOR,
pos->iScore[uAhead],
- (uInverseBehindPieceCount * 16) * (uAheadPawnCount != 0),
+ (uInverseBehindPieceCount * 16) *
+ ((uAheadPawnCount != 0) || (FALSE == fAheadLoneMinorNoMate)),
"trade pieces");
- // Zero pawns left for the ahead side is the classic false-positive
- // (KNB vs KRP, minor-up-no-pawns endings that are often drawn or
- // even lost) -- the old DONT_TRADE_PAWNS table existed specifically
- // to punish it (-43/-10 depending on lead size) rather than just
- // trailing off toward a small positive number. Keep that guard as
- // a cheap branch rather than a lookup table; the positive slope
- // for 1+ pawns doesn't need the same care.
+ // Zero pawns AND no mating material for the ahead side is the
+ // classic false-positive (KNB vs KRP, minor-up-no-pawns endings
+ // that are often drawn or even lost) -- the old DONT_TRADE_PAWNS
+ // table existed specifically to punish it (-43/-10 depending on
+ // lead size) rather than just trailing off toward a small positive
+ // number. Keep that guard as a cheap branch rather than a lookup
+ // table; the positive slope for 1+ pawns doesn't need the same
+ // care. Same mating-material exception as "trade pieces" above:
+ // zero pawns with REAL mating material (a rook, say) isn't the
+ // KNB-vs-KRP failure mode this -30 penalty exists for, so it gets
+ // a neutral 0 instead of being punished.
EVAL_TERM(uAhead,
0,
ILLEGAL_COOR,
pos->iScore[uAhead],
- (0 == uAheadPawnCount) ? -30 : (SCORE)(uAheadPawnCount * 2),
+ (0 != uAheadPawnCount) ? (SCORE)(uAheadPawnCount * 2) :
+ (TRUE == fAheadLoneMinorNoMate) ? -30 : 0,
"don't trade pawns");
}
@@ -6275,9 +6452,9 @@ Return value:
}
-static FLAG
-_SideHasWinningChances(IN POSITION *pos,
- IN ULONG uSide)
+FLAG
+SideHasWinningChances(IN POSITION *pos,
+ IN ULONG uSide)
/**
Routine description:
@@ -6293,14 +6470,16 @@ Routine description:
recognizer bound can (see recogn.c's _RecognizeKNKP/_RecognizeKBNK
incident).
- Deliberately conservative: only covers material shapes where "no,
+ Deliberately conservative: mostly covers material shapes where "no,
this can't be forced" is basic, textbook chess knowledge
(insufficient mating material, up the exchange with nothing else
- changed, bare knights vs a bare king), not trickier exceptions
- (fortress draws, wrong-bishop-pawn-with-king-in-time geometry)
- that need real board information -- those are recogn.c's
- hard-recognizer territory (already exact for tiny material there)
- or plain search's job, not this cheap pre-check's.
+ changed, bare knights vs a bare king). One exception that DOES need
+ real board information, not just material counts, is included below
+ (wrong-bishop-pawn-with-king-in-time) precisely because it's common
+ enough and cheap enough to be worth it; anything else needing real
+ board geometry is recogn.c's hard-recognizer territory (already
+ exact for tiny material there) or plain search's job, not this
+ cheap pre-check's.
Parameters:
@@ -6321,6 +6500,77 @@ Return value:
ULONG uSideKnights;
ULONG uSideBishops;
+ // uSide's bishop count is needed by the wrong-bishop-fortress check
+ // immediately below (which must run BEFORE the "any pawn -> TRUE"
+ // early return that follows it) and is reused again later by the
+ // lone-minor/iMinorDiff/two-knights checks -- compute it once, up
+ // front.
+ uSideBishops = FastCountBits(pos->bbPieces[uSide][BISHOP]);
+
+ //
+ // Wrong-bishop rook-pawn fortress: a lone bishop + rook-pawn(s) (all
+ // on the A- or H-file), with the bishop unable to control the
+ // promotion square, is a known draw once the defending bare king
+ // can reach the corner in time -- regardless of how much raw pawn
+ // material uSide has. Checked before the blanket "any pawn -> TRUE"
+ // return below, which would otherwise hide this case entirely.
+ // Mirrors Crafty's EvaluateWinningChances (~/crafty/evaluate.c) --
+ // "in time" uses the same tempo-adjusted Chebyshev distance Crafty
+ // uses (one square of credit to the defender if it's their move).
+ // Ported from a stashed, previously-uncommitted eval.c change
+ // (2026-09-2x eval audit session); testing methodology for its
+ // false-positive rate against this session's own KP+KP* EGTB
+ // harness is still to be built (see testeval.c/testrecogn.c) before
+ // trusting the ~2.12% figure the stash's own comment cited -- that
+ // number was never independently re-verified in this session. Only
+ // ever softens a score (via EvalLookForDrawishSituations' /16
+ // shrink), never used as a search-terminating bound the way
+ // recogn.c's tighter dist<=1-only version is, so a wrong FALSE here
+ // is tolerable in a way it would not be in recogn.c.
+ //
+ if ((2 == TotalNonPawnCount(pos, uSide)) &&
+ (1 == uSideBishops) &&
+ (1 == TotalNonPawnCount(pos, uEnemy)) &&
+ (0 == FastCountBits(pos->bbPawns[uEnemy])) &&
+ (FastCountBits(pos->bbPawns[uSide]) > 0))
+ {
+ BITBOARD bbPawns = pos->bbPawns[uSide];
+ COOR cPromote = ILLEGAL_COOR;
+
+ if (uSide == WHITE)
+ {
+ if (!(bbPawns & ~BBFILE[A]) && (WhiteSqBishopCount(pos, WHITE) == 0))
+ {
+ cPromote = A8;
+ }
+ else if (!(bbPawns & ~BBFILE[H]) && (WhiteSqBishopCount(pos, WHITE) == 1))
+ {
+ cPromote = H8;
+ }
+ }
+ else
+ {
+ if (!(bbPawns & ~BBFILE[A]) && (WhiteSqBishopCount(pos, BLACK) == 1))
+ {
+ cPromote = A1;
+ }
+ else if (!(bbPawns & ~BBFILE[H]) && (WhiteSqBishopCount(pos, BLACK) == 0))
+ {
+ cPromote = H1;
+ }
+ }
+
+ if (IS_ON_BOARD(cPromote))
+ {
+ COOR cWeakKing = KingPosition(pos, uEnemy);
+ int iTempo = (pos->uToMove == uEnemy) ? 1 : 0;
+ if (((int)DISTANCE(cWeakKing, cPromote) - iTempo) < 1)
+ {
+ return(FALSE);
+ }
+ }
+ }
+
//
// A pawn always gives some winning chances (it can always try to
// queen with support) -- nothing below applies.
@@ -6330,12 +6580,10 @@ Return value:
return(TRUE);
}
- // uSide's knight/bishop counts are each read three times below
- // (the lone-minor check, iMinorDiff, and the two-knights check) --
- // compute them once and reuse rather than re-popcounting the same
- // two bitboards repeatedly.
+ // uSide's knight count is read twice below (the lone-minor check
+ // and the two-knights check); uSideBishops was already computed
+ // above.
uSideKnights = FastCountBits(pos->bbPieces[uSide][KNIGHT]);
- uSideBishops = FastCountBits(pos->bbPieces[uSide][BISHOP]);
//
// No pawns and the only piece besides the king is a single minor:
@@ -6433,9 +6681,9 @@ _EnsureDrawishFactsComputed(IN POSITION *pos, IN ULONG uColor,
{
return;
}
- pFacts->fSideToMoveHasWinningChances = _SideHasWinningChances(pos, uColor);
+ pFacts->fSideToMoveHasWinningChances = SideHasWinningChances(pos, uColor);
pFacts->fOpponentHasWinningChances =
- _SideHasWinningChances(pos, FLIP(uColor));
+ SideHasWinningChances(pos, FLIP(uColor));
pFacts->fBOOC = (TotalNonPawnCount(pos, WHITE) == 2) &&
(TotalNonPawnCount(pos, BLACK) == 2) &&
(FastCountBits(pos->bbPieces[WHITE][BISHOP]) == 1) &&
@@ -7061,7 +7309,7 @@ Return value:
// TABLES IT NEEDS TO CLEAR/POPULATE THEM THOUGH!!!
#ifdef EVAL_TIME
UINT64 u64PawnTimer = SystemReadTimeStampCounter();
- pHash = _EvalPawns(ctx, &fDeferred);
+ pHash = EvalPawns(ctx, &fDeferred);
{
UINT64 u64Elapsed = SystemReadTimeStampCounter() - u64PawnTimer;
ctx->sCounters.tree.u64CyclesEvalPawns += u64Elapsed;
@@ -7077,7 +7325,7 @@ Return value:
}
}
#else
- pHash = _EvalPawns(ctx, &fDeferred);
+ pHash = EvalPawns(ctx, &fDeferred);
#endif
ASSERT(NULL != pHash);
ASSERT(IS_VALID_FLAG(fDeferred));
@@ -7099,7 +7347,32 @@ Return value:
#endif
if (fBareKingEitherSide)
{
- (void)_EvalPasserRacesAgainstLoneKings(pos, pHash);
+ //
+ // Pure king-and-pawns on BOTH sides: try the new,
+ // exhaustively-verified shared logic first (see
+ // _EvalUnstoppablePasser's header comment) -- it's trustworthy
+ // whenever it has something to say. But it only ever fires for
+ // the true-lone-king sub-case and stays silent (FALSE) for the
+ // "both sides still have pawns" sub-case, which the old,
+ // broader-but-dodgier heuristic used to cover -- fall back to
+ // that old heuristic rather than simply losing that coverage.
+ // Anything broader than pure KPvKP (one bare side, but the
+ // OTHER side still has real pieces -- e.g. K+R+P vs K) always
+ // uses the old heuristic unchanged; that shape was never
+ // covered by this session's KPvKP investigation.
+ //
+ if ((1 == TotalNonPawnCount(pos, WHITE)) &&
+ (1 == TotalNonPawnCount(pos, BLACK)))
+ {
+ if (FALSE == _EvalUnstoppablePasser(pos))
+ {
+ (void)_EvalPasserRacesAgainstLoneKings(ctx, pHash);
+ }
+ }
+ else
+ {
+ (void)_EvalPasserRacesAgainstLoneKings(ctx, pHash);
+ }
#ifdef EVAL_DUMP
Trace("After passer races:\n%d\t\t%d\t\t(%d for %s)\n",
pos->iScore[WHITE], pos->iScore[BLACK],
diff --git a/src/main.c b/src/main.c
index dc21248..f96ba4a 100644
--- a/src/main.c
+++ b/src/main.c
@@ -605,6 +605,11 @@ Return value:
// back down next to TestMakeUnmakeMove (its natural home) once
// recogn.c work settles.
TestRecogn();
+ // Re-enabled at user's request to look at failing FENs; user will
+ // disable when done.
+ //TestEvalSamplePasserRacesKPPKPP();
+ //TestEvalSamplePasserRacesKPKPPP();
+ //TestEvalPasserRacesAgainstLoneKings();
TestDraw();
#ifdef EVAL_DUMP
diff --git a/src/recogn.c b/src/recogn.c
index 7b7ba84..2219e48 100644
--- a/src/recogn.c
+++ b/src/recogn.c
@@ -856,14 +856,6 @@ Return value:
__FILE__, __LINE__);
#endif
- at_best_draw_for_strong:
- *piScore = g_iDrawScore[pos->uToMove];
- if (pos->uToMove == uStrong)
- {
- return(RECOGN_UPPER);
- }
- return(RECOGN_LOWER);
-
draw:
*piScore = g_iDrawScore[pos->uToMove];
return(RECOGN_EXACT);
@@ -951,6 +943,655 @@ Return value:
ASSERT(IS_ON_BOARD(cSquare[2]));
}
+//
+// Is every square between cPawn and its queening square (inclusive of
+// the queening square itself) empty? The critical-square race math
+// below has no notion of a blocked pawn -- it assumes cPawn can walk
+// straight to promotion in RANK_DISTANCE(cQueen,cPawn) moves, which was
+// always true for _PawnCriticalSquareRace's original caller (lone-king
+// KPK, nothing else on the board) but is no longer guaranteed once the
+// opponent may have pawns of its own sitting in the way. Deliberately
+// simple/conservative: this also returns FALSE if the STRONG side's
+// own king blocks its own pawn's path (eval.c's now-superseded
+// _EvalPasserRacesAgainstLoneKings had a narrower rook-pawn-only
+// carve-out for exactly this shape and still separately credited a
+// non-rook-pawn "own king in the way" case as a tempo-cost rather than
+// a disqualifier) -- that nuance is not reproduced here; bailing to
+// UNRECOGNIZED for it is the conservative, "when in doubt do nothing"
+// choice this recognizer is supposed to make, not a proven-safe corner
+// this function has verified.
+//
+static FLAG
+_PawnPromotionPathClear(IN POSITION *pos, IN ULONG uColor, IN COOR cPawn)
+{
+ COOR cQueen = QUEENING_SQUARE_BY_COLOR_FILE[uColor][FILE(cPawn)];
+ int iStep = (WHITE == uColor) ? -16 : 16;
+ COOR c;
+
+ for (c = cPawn + iStep; c != cQueen; c += iStep)
+ {
+ ASSERT(IS_ON_BOARD(c));
+ if (!IS_EMPTY(pos->rgSquare[c].pPiece))
+ {
+ return(FALSE);
+ }
+ }
+ return(IS_EMPTY(pos->rgSquare[cQueen].pPiece));
+}
+
+//
+// Does the side to move have a pawn that safely promotes to a queen
+// THIS move -- a clean, non-capturing push from the 7th (or 2nd, for
+// Black) rank to an empty queening square that isn't attacked by the
+// opponent (so the new queen can't just be captured back for nothing),
+// with the resulting position not stalemating the opponent?
+//
+// This is a fundamentally different, much narrower and safer question
+// than a multi-move pawn race: since only the side to move can act this
+// move, there is no "does the opponent also have a race" ambiguity to
+// resolve at all -- whatever the opponent's other pawns are doing, they
+// get no say in whether THIS move safely produces a new queen. This is
+// exactly the shape of the position that motivated this whole
+// investigation (8/7P/6p1/3k1p2/2p5/3pK3/8/8 w - -, White's Ph7 one
+// push from queening).
+//
+// An earlier attempt at general KP+KP* coverage instead estimated each
+// side's "plies until queening" via a per-pawn critical-square/outrun
+// race analysis and compared the two numbers with a safety margin. Per
+// empirical testing (TestRecognSampleKPvKP), that approach has a
+// persistent ~15% wrong rate against EGTB ground truth NO MATTER how
+// generous the margin was made (tried up to 60 plies) -- because a
+// race-distance comparison answers "who queens first," not "is the
+// resulting position actually won," and those are different claims
+// once the "losing" side still has pawns of its own (even ones with no
+// confident race of their own can still produce real drawing
+// counterplay -- see the historical comment on this file's
+// _RecognizeKPK about a duo that isn't individually passed). That
+// approach was abandoned rather than kept as dead code; see git history
+// if it's ever worth revisiting. This function answers a narrower,
+// fully checkable question instead: it delegates the actual
+// legality/safety checking to the engine's own move generation and
+// attack detection rather than re-deriving any of it by hand, so
+// there's no independent-implementation risk.
+//
+// Deliberately narrow for v1: only a clean, non-capturing push
+// promotion to a QUEEN; a capturing promotion or underpromotion is
+// deferred (this function returns FALSE for them, not handled
+// unsafely). Mutates and restores ctx's ply/move-stack state (like
+// testrecogn.c's _GetEgtbLine) but leaves pos itself unchanged on
+// return.
+//
+// Also requires the OPPONENT not have a pending queen of their own
+// (_ColorHasPendingQueen) in the resulting position -- found via a real
+// counterexample (8/5K1P/8/8/2p5/1p1P4/6kp/8 w - -): White's Ph7 queens
+// safely by every check above, but Black's Ph2 is ALSO one push from
+// promoting and nothing about White's move touches it, so Black
+// immediately replies ...h1=Q and the position is mutual queens plus
+// Black's dangerous connected b3/c4 passers -- nowhere near the
+// confident material win this function is supposed to only claim when
+// certain. "My queening move is locally safe" and "the resulting
+// position is actually won" are different claims; this closes that gap
+// the cheap way (deny the claim entirely rather than try to evaluate
+// the resulting queen-and-pawns middlegame, which is well outside what
+// a recognizer should attempt).
+//
+static FLAG
+_ColorHasPendingQueen(IN POSITION *pos, IN ULONG uColor)
+{
+ BITBOARD bb = pos->bbPawns[uColor] &
+ ((WHITE == uColor) ? BBRANK77 : BBRANK22);
+ COOR c;
+ int iStep = (WHITE == uColor) ? -16 : 16;
+
+ while (IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(&bb)))
+ {
+ PIECE pBlocker = pos->rgSquare[c + iStep].pPiece;
+ //
+ // A queening square blocked only by uColor's OWN king isn't a
+ // real block -- the king just steps aside next move and the
+ // pawn queens the move after, exactly as real a threat as an
+ // already-clear square (one tempo later). Found via a real
+ // counterexample (5K2/k4Pp1/8/8/8/4P3/3p4/8 b - -): White's
+ // Pf7 sits one push from queening with White's OWN Kf8 sitting
+ // on f8, which this check used to treat identically to a
+ // genuine block (e.g. an enemy piece that isn't going anywhere)
+ // and so never flagged as a threat -- but White simply plays
+ // Kg8/Ke8 next and f8=Q follows, which is exactly the kind of
+ // second queen _HasImmediateSafeQueen's caller needs to know
+ // about before trusting Black's own immediate queening move as
+ // a sure win.
+ //
+ if (IS_EMPTY(pBlocker) ||
+ (IS_KING(pBlocker) && (GET_COLOR(pBlocker) == uColor)))
+ {
+ return(TRUE);
+ }
+ }
+ return(FALSE);
+}
+
+//
+// Does uColor have TWO OR MORE pawns (any files, connected or not) each
+// on the 6th or 7th rank (White) / 3rd or 2nd rank (Black)? Reverted
+// from a stricter "must be on ADJACENT files" version: that version
+// correctly stayed silent on the original motivating position (Black's
+// lone d3 pawn, nowhere near dangerous) but missed real counterexamples
+// like 8/8/PKP3P1/8/8/8/2p5/3k4 b - - (White's a6/c6/g6 aren't
+// file-adjacent to each other -- b6 has White's OWN KING sitting
+// between a6 and c6, not a pawn -- yet three pawns this advanced still
+// can't all be stopped). Plain count (>=2 anywhere in the zone) doesn't
+// have that blind spot and, per the original motivating position check,
+// still correctly ignores a single lone advanced pawn.
+//
+static FLAG
+_ColorHasMultipleAdvancedPassers(IN POSITION *pos, IN ULONG uColor)
+{
+ BITBOARD bb = pos->bbPawns[uColor] &
+ ((WHITE == uColor) ? (BBRANK77 | BBRANK66)
+ : (BBRANK22 | BBRANK33));
+ return(FastCountBits(bb) >= 2);
+}
+
+//
+// Does uColor have a single NON-ROOK-FILE pawn on the 6th/7th (White) or
+// 3rd/2nd (Black) rank whose own king already controls (is adjacent to)
+// EITHER its queening square OR the square directly in front of it (the
+// pawn's very next push destination)? A rook pawn is excluded because
+// its single critical square and corner-stalemate resources make it a
+// fundamentally different (usually WEAKER, sometimes drawish-for-the-
+// runner) case, not a stronger one -- this check is specifically about
+// a pawn whose promotion is essentially guaranteed because its own king
+// is already standing guard over the square it needs next, the
+// escort-completed mirror image of the critical-square logic this
+// investigation dropped earlier for being too failure-prone to
+// hand-derive in the general case. Originally only checked the
+// queening square itself; broadened to also cover a king controlling
+// just the very next square (e.g. a 6th-rank pawn whose king guards the
+// 7th-rank square ahead of it, without yet reaching the queening square
+// itself) -- still a one-hop-from-the-king check, no race/tempo
+// arithmetic, so still judged safe enough to keep.
+//
+static FLAG
+_ColorHasEscortedAdvancedPasser(IN POSITION *pos, IN ULONG uColor)
+{
+ BITBOARD bb = pos->bbPawns[uColor] &
+ ((WHITE == uColor) ? (BBRANK77 | BBRANK66)
+ : (BBRANK22 | BBRANK33));
+ COOR cKing = KingPosition(pos, uColor);
+ int iStep = (WHITE == uColor) ? -16 : 16;
+ COOR c;
+
+ while (IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(&bb)))
+ {
+ if ((A != FILE(c)) && (H != FILE(c)))
+ {
+ COOR cQueen = QUEENING_SQUARE_BY_COLOR_FILE[uColor][FILE(c)];
+ COOR cAhead = c + iStep;
+ if ((DISTANCE(cKing, cQueen) <= 1) ||
+ (DISTANCE(cKing, cAhead) <= 1))
+ {
+ return(TRUE);
+ }
+ }
+ }
+ return(FALSE);
+}
+
+//
+// Does the side to move (in ctx's CURRENT position) have any legal pawn
+// move that gives check? Found via a real counterexample
+// (8/P7/8/4p3/8/2p5/8/1k1K4 w - -): White's Pa7 queens safely by every
+// check above (a8 unattacked, not stalemate, Black has no pawn already
+// pending), but Black's Pc3 -- not yet on its pending-queen rank, so
+// _ColorHasPendingQueen doesn't see it -- pushes to c2 with CHECK,
+// forces White's king to move, and only THEN queens: 1. a8=Q c2+ 2. Ke2
+// c1=Q, mutual queens again. A tempo bought by a check is just as real
+// as a pawn that's already one push from queening; the fix is not to
+// hand-special-case "two squares away via a checking push" (which
+// would just invite the same gap one square further back, or via a
+// discovered/double check this codebase's move generator might produce
+// in some other pawn shape) but to ask the engine's own move generator
+// and check detector directly, the same way this whole function
+// already avoids hand-rolled legality logic elsewhere. Assumes
+// GenerateMoves has already been run for ctx's current position/ply
+// (true at both call sites below, which reuse the move list already
+// built for the stalemate check).
+//
+static FLAG
+_ColorHasCheckingPawnPush(IN OUT SEARCHER_THREAD_CONTEXT *ctx)
+{
+ POSITION *pos = &ctx->sPosition;
+ ULONG u;
+
+ for (u = ctx->sMoveStack.uBegin[ctx->uPly];
+ u < ctx->sMoveStack.uEnd[ctx->uPly];
+ u++)
+ {
+ MOVE mv = ctx->sMoveStack.mvf[u].mv;
+ FLAG fGivesCheck;
+
+ if (FALSE == IS_PAWN(mv.pMoved))
+ {
+ continue;
+ }
+ //
+ // Only a push landing on the mover's OWN pending-queen-adjacent
+ // rank matters here -- that's what turns a mere tempo-stealing
+ // check into a real "queens next move" threat (see
+ // _ColorHasPendingQueen), which is the actual danger the
+ // original counterexample (c3-c2+, then c1=Q) showed. A check
+ // from a pawn NOT landing there -- confirmed via a real false
+ // positive on the motivating example itself, f5-f4+ two ranks
+ // from queening in 8/7P/6p1/3k1p2/2p5/3pK3/8/8 w - -.-- costs a
+ // tempo but touches no material, utterly harmless when the
+ // stronger side just safely gained a whole queen.
+ //
+ if (0 == (COOR_TO_BB(mv.cTo) &
+ ((WHITE == GET_COLOR(mv.pMoved)) ? BBRANK77 : BBRANK22)))
+ {
+ continue;
+ }
+ if (FALSE == MakeMove(ctx, mv))
+ {
+ ASSERT(FALSE);
+ continue;
+ }
+ fGivesCheck = InCheck(pos, pos->uToMove);
+ UnmakeMove(ctx, mv);
+ if (TRUE == fGivesCheck)
+ {
+ return(TRUE);
+ }
+ }
+ return(FALSE);
+}
+
+//
+// Does uOpp have ANY pawn past its own halfway line (ranks 4-7 for
+// White, ranks 2-5 for Black) that OUR OWN king (uMoverColor) is too
+// far away from to help our brand new queen deal with? Originally
+// written narrower (only fire on a CONNECTED, adjacent-file pair --
+// "two mutually-defended passers overwhelm a lone queen"), found via
+// 8/4Pp2/8/1K6/4p3/5p2/8/7k w - -. That version missed a related
+// counterexample with only ONE relevant advanced pawn (c3) diagnosed by
+// hand, 8/P4K2/4p3/8/8/2p5/8/4k3 w - - and its one-move-later sibling
+// Q7/5K2/4p3/8/8/8/2p5/4k3 w - -: White's king (f7) happens to be near
+// a DIFFERENT, irrelevant Black pawn (e6, nowhere near queening) while
+// the actually-dangerous c3 pawn is far away and unconnected to
+// anything -- checking "is the king near SOME pawn in the zone" (the
+// original version, scanning for connectivity across the whole zone
+// bitboard) can't tell relevant from irrelevant proximity. Fixed by
+// requiring the king be close to EVERY pawn in the zone, individually
+// -- a single one too far away is enough to disqualify, connected or
+// not: a lone queen can't always be assumed to handle even ONE
+// sufficiently advanced passer alone when its own king isn't around
+// (checking forever is a draw; letting it queen and having to trade
+// the queen for it is a draw), any more than it can handle two
+// connected ones.
+//
+// UNSUPPORTED_DUO_KING_DISTANCE is a coarse, empirically-tuned proximity
+// gate (see testrecogn.c's TestRecognSampleKPvKP), not a proof that any
+// particular distance is exactly the boundary between "queen can still
+// handle this alone" and "needs king help" -- consistent with this
+// whole investigation's approach of tuning cheap, conservative
+// thresholds against real counterexamples rather than deriving them
+// from first principles.
+//
+#define UNSUPPORTED_DUO_KING_DISTANCE (2)
+
+static FLAG
+_ColorHasUnsupportedAdvancedPawn(IN POSITION *pos,
+ IN ULONG uOpp,
+ IN ULONG uMoverColor)
+{
+ BITBOARD bb = pos->bbPawns[uOpp] &
+ ((WHITE == uOpp) ? (BBRANK77 | BBRANK66 | BBRANK55 | BBRANK44)
+ : (BBRANK22 | BBRANK33 | BBRANK44 | BBRANK55));
+ COOR cKing = KingPosition(pos, uMoverColor);
+ COOR c;
+
+ while (IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(&bb)))
+ {
+ // Rook-file pawns get one tempo less slack: found via a real
+ // counterexample (8/5K2/7P/8/8/3k4/6p1/8 b - -, White's Ph6
+ // exactly AT the general threshold) that a plain distance
+ // check still let through. Same "rook pawns are a special,
+ // MORE resilient case" theme that recurs throughout this file
+ // (the KPK critical-square 2-tempo margin, the escorted-passer
+ // rook-file exclusion above) -- corner-adjacent stalemate/
+ // fortress resources give the defender extra reach a center
+ // pawn doesn't have, so the same raw king distance that's safe
+ // for an internal file isn't safe here.
+ ULONG uMargin = ((A == FILE(c)) || (H == FILE(c))) ?
+ (UNSUPPORTED_DUO_KING_DISTANCE - 2) : UNSUPPORTED_DUO_KING_DISTANCE;
+ COOR cQueen = QUEENING_SQUARE_BY_COLOR_FILE[uOpp][FILE(c)];
+ if (DISTANCE(cKing, cQueen) > uMargin)
+ {
+ return(TRUE); // this pawn is too far from our king to trust
+ // our queen to handle it alone -- disqualify
+ }
+ }
+ return(FALSE); // king's close enough to every zone pawn there is
+}
+
+//
+// Is uColor's king within one square of any board corner? Found via two
+// real counterexamples that survived every other check here
+// (6K1/8/7P/8/8/7k/1p6/8 b - -, Black's Kh3 tucked next to h1;
+// 8/2P5/8/8/8/2K2p2/8/7k w - -, Black's Kh1 in the corner itself): a
+// defending king already in or hugging a corner creates stalemate-
+// adjacent resources that a plain king-to-pawn distance measurement
+// (_ColorHasUnsupportedAdvancedPawn) doesn't see at all -- the exact
+// same corner-stalemate hazard already documented at length elsewhere
+// in this file (_RecognizeKPK's original 7k/8/5KP1/8/8/8/8/8 w - -
+// writeup, and the rook-pawn exclusion in
+// _ColorHasEscortedAdvancedPasser). Deliberately coarse (ANY corner,
+// distance<=1, no attempt to characterize exactly which corner
+// configurations are actually dangerous) -- consistent with this
+// investigation's whole approach of bailing to UNRECOGNIZED rather than
+// precisely characterizing a hazardous boundary.
+//
+static FLAG
+_ColorKingNearCorner(IN POSITION *pos, IN ULONG uColor)
+{
+ COOR cKing = KingPosition(pos, uColor);
+ static const COOR rgcCorner[4] = { A1, A8, H1, H8 };
+ ULONG u;
+
+ for (u = 0; u < 4; u++)
+ {
+ if (DISTANCE(cKing, rgcCorner[u]) <= 1)
+ {
+ return(TRUE);
+ }
+ }
+ return(FALSE);
+}
+
+//
+// Is uColor's king on A3/H3/A6/H6 with its OWN pawn sitting directly
+// behind it on A2/H2/A7/H7 respectively? Found via all 8 of the
+// remaining exhaustive-KPvKP counterexamples at this point in the
+// investigation (e.g. 8/pP6/k7/8/K7/8/8/8 w - -, Black Ka6+Pa7): the
+// king is on the edge file, can't retreat (its own pawn blocks the
+// only square behind it), can't advance either (the enemy king or
+// board edge takes the other square), and is left with only 2 legal
+// squares -- both on the file the newly-promoted queen is about to
+// fully control. The queening move that looked completely safe by
+// every other check here (destination unattacked, no stalemate by a
+// naive pseudo-legal move count, no pending/checking/connected/
+// escorted opposing pawn) turns out to stalemate the opponent outright.
+// Deliberately a small, exact, hardcoded table rather than a general
+// "count the king's genuinely legal escape squares" geometric
+// computation -- the latter would need to reason about pawn-attacked
+// squares and enemy-king-adjacency in general, which starts sliding
+// back toward reimplementing move generation by hand (exactly what
+// this function is meant to avoid); this exact shape is what every
+// counterexample found so far actually looks like, so it's what gets
+// coded, not a generalization of it.
+//
+// Also requires the QUEENING pawn's own file be B or G -- the file
+// immediately next to the trapped corner, whose queen is what actually
+// sweeps the king's 2 remaining squares. A queening pawn on any other
+// file doesn't produce a queen that controls those particular squares,
+// so this shape wouldn't apply to it.
+//
+static FLAG
+_ColorKingTrappedByOwnRookPawn(IN POSITION *pos, IN ULONG uColor,
+ IN ULONG uPawnFile)
+{
+ static const COOR rgcKing[4] = { A3, H3, A6, H6 };
+ static const COOR rgcPawn[4] = { A2, H2, A7, H7 };
+ static const ULONG rguRequiredPawnFile[4] = { B, G, B, G };
+ COOR cKing = KingPosition(pos, uColor);
+ ULONG u;
+
+ for (u = 0; u < 4; u++)
+ {
+ if ((cKing == rgcKing[u]) &&
+ (uPawnFile == rguRequiredPawnFile[u]) &&
+ IS_PAWN(pos->rgSquare[rgcPawn[u]].pPiece) &&
+ (GET_COLOR(pos->rgSquare[rgcPawn[u]].pPiece) == uColor))
+ {
+ return(TRUE);
+ }
+ }
+ return(FALSE);
+}
+
+//
+// Is cPawn (belonging to uColor) confidently safe to queen, using ONLY
+// static, ctx-free positional analysis -- no move generation, no
+// MakeMove/UnmakeMove? This is every check from this investigation's
+// KPvKP recognizer EXCEPT the two genuinely move-generation-dependent
+// ones (immediate stalemate, a checking pawn push landing on the
+// opponent's pending-queen rank) -- see _HasImmediateSafeQueen (below)
+// for those, and HasStaticUnstoppablePasser (next) for the per-color
+// entry point. Exposed (non-static, declared in chess.h) specifically
+// so eval.c can call this SAME logic for a soft "unstoppable passer"
+// EVAL_TERM bonus: eval.c's Eval() runs interleaved with search's own
+// move-generation/iteration at the current ply (a caller may already be
+// mid-iteration over ctx->sMoveStack for that same ply), so anything in
+// Eval() that called GenerateMoves/MakeMove there would corrupt that
+// caller's move list out from under it -- exactly why none of eval.c's
+// existing code touches move generation at all, and exactly why this
+// function deliberately doesn't either. Missing the stalemate/
+// checking-push checks means eval.c's soft version can occasionally be
+// wrong in ways this file's exhaustively-verified recogn.c version
+// isn't -- acceptable for an eval.c bonus (see this whole
+// investigation's very first framing: recogn.c must never be wrong,
+// eval.c is allowed to be a little wrong), not acceptable for a
+// RECOGN_LOWER/UPPER hard bound.
+//
+FLAG
+IsStaticSafeQueeningPawn(IN POSITION *pos, IN ULONG uColor, IN COOR cPawn)
+{
+ ULONG uOpp = FLIP(uColor);
+ COOR cQueen = QUEENING_SQUARE_BY_COLOR_FILE[uColor][FILE(cPawn)];
+
+ if (FALSE == _PawnPromotionPathClear(pos, uColor, cPawn))
+ {
+ return(FALSE);
+ }
+ if (TRUE == IsAttacked(cQueen, pos, uOpp))
+ {
+ return(FALSE);
+ }
+ if (TRUE == _ColorHasPendingQueen(pos, uOpp))
+ {
+ return(FALSE);
+ }
+ if (TRUE == _ColorHasMultipleAdvancedPassers(pos, uOpp))
+ {
+ return(FALSE);
+ }
+ if (TRUE == _ColorHasEscortedAdvancedPasser(pos, uOpp))
+ {
+ return(FALSE);
+ }
+ if (TRUE == _ColorHasUnsupportedAdvancedPawn(pos, uOpp, uColor))
+ {
+ return(FALSE);
+ }
+ if (TRUE == _ColorKingNearCorner(pos, uOpp))
+ {
+ return(FALSE);
+ }
+ if (TRUE == _ColorKingTrappedByOwnRookPawn(pos, uOpp, FILE(cPawn)))
+ {
+ return(FALSE);
+ }
+ return(TRUE);
+}
+
+//
+// uColor's version of "does ANY pawn pass IsStaticSafeQueeningPawn" --
+// the per-color entry point eval.c actually calls. Only ever examines
+// pawns already on the pending-queen rank (7th/2nd); see
+// IsStaticSafeQueeningPawn for everything that has to be true beyond
+// that.
+//
+FLAG
+HasStaticUnstoppablePasser(IN POSITION *pos, IN ULONG uColor, OUT COOR *pcPawn)
+{
+ BITBOARD bb = pos->bbPawns[uColor] &
+ ((WHITE == uColor) ? BBRANK77 : BBRANK22);
+ COOR c;
+
+ while (IS_ON_BOARD(c = CoorFromBitBoardRank8ToRank1(&bb)))
+ {
+ if (TRUE == IsStaticSafeQueeningPawn(pos, uColor, c))
+ {
+ *pcPawn = c;
+ return(TRUE);
+ }
+ }
+ return(FALSE);
+}
+
+static FLAG
+_HasImmediateSafeQueen(IN OUT SEARCHER_THREAD_CONTEXT *ctx, OUT COOR *pcPawn)
+{
+ POSITION *pos = &ctx->sPosition;
+ ULONG uColor = pos->uToMove;
+ ULONG uOpp = FLIP(uColor);
+ FLAG fInCheck = InCheck(pos, uColor);
+ ULONG u;
+ FLAG fFound = FALSE;
+
+ if (0 == (pos->bbPawns[uColor] &
+ ((WHITE == uColor) ? BBRANK77 : BBRANK22)))
+ {
+ return(FALSE);
+ }
+
+ //
+ // Cheap, ctx-free positional pre-filter first (see
+ // IsStaticSafeQueeningPawn -- shared with eval.c's soft
+ // _EvalUnstoppablePasser, which can't touch move generation at all;
+ // see that function's comment for why). If nothing even passes the
+ // static checks, there's no need to touch the move generator at
+ // all.
+ //
+ if (FALSE == HasStaticUnstoppablePasser(pos, uColor, pcPawn))
+ {
+ return(FALSE);
+ }
+
+ ctx->sPlyInfo[ctx->uPly].fInCheck = fInCheck;
+ GenerateMoves(ctx, NULLMOVE,
+ fInCheck ? GENERATE_ESCAPES : GENERATE_ALL_MOVES);
+
+ for (u = ctx->sMoveStack.uBegin[ctx->uPly];
+ (FALSE == fFound) && (u < ctx->sMoveStack.uEnd[ctx->uPly]);
+ u++)
+ {
+ MOVE mv = ctx->sMoveStack.mvf[u].mv;
+ FLAG fSafe;
+
+ if ((0 == mv.pPromoted) || (FALSE == IS_QUEEN(mv.pPromoted)) ||
+ (0 != mv.pCaptured) || (FALSE == IS_PAWN(mv.pMoved)) ||
+ (FALSE == IsStaticSafeQueeningPawn(pos, uColor, mv.cFrom)))
+ {
+ continue;
+ }
+
+ if (FALSE == MakeMove(ctx, mv))
+ {
+ ASSERT(FALSE);
+ continue;
+ }
+
+ //
+ // Everything reusable across eval.c has already been checked
+ // by IsStaticSafeQueeningPawn above -- only the two genuinely
+ // move-generation-dependent checks remain, which only recogn.c
+ // (never eval.c -- see this function's header comment) can
+ // afford to run.
+ //
+ {
+ FLAG fOppInCheck = InCheck(pos, uOpp);
+ ctx->sPlyInfo[ctx->uPly].fInCheck = fOppInCheck;
+ GenerateMoves(ctx, NULLMOVE,
+ fOppInCheck ? GENERATE_ESCAPES : GENERATE_ALL_MOVES);
+ fSafe = TRUE;
+ if ((ctx->sMoveStack.uBegin[ctx->uPly] ==
+ ctx->sMoveStack.uEnd[ctx->uPly]) &&
+ (FALSE == fOppInCheck))
+ {
+ fSafe = FALSE; // stalemate -- the classic corner trick
+ }
+ else if (TRUE == _ColorHasCheckingPawnPush(ctx))
+ {
+ fSafe = FALSE; // opponent buys a tempo with check, then
+ // queens next -- see
+ // _ColorHasCheckingPawnPush's comment
+ }
+ }
+ UnmakeMove(ctx, mv);
+
+ if (TRUE == fSafe)
+ {
+ *pcPawn = mv.cFrom;
+ fFound = TRUE;
+ }
+ }
+ return(fFound);
+}
+
+//
+// Does uStrong's single pawn cPawn beat a bare uWeak king via the
+// classic critical-square race, with the 2-tempo safety margin
+// TestRecognExhaustiveKPK verified gives 0 wrong verdicts across all
+// 165,676 EGTB-covered legal single-pawn KPK positions (a 1-tempo
+// margin gave 35 wrong -- see the long-standing comment this was
+// extracted from, still attached at _RecognizeKPK's call site below)?
+// Extracted verbatim (no behavior change) from _RecognizeKPK's own
+// lone-king branch so eval.c can reuse this SAME exhaustively-verified
+// logic for its "unstoppable passer" bonus in the true-lone-king case
+// (weak side has zero pawns) -- the "rule of the square" generalized
+// with a king escort, not just a pawn already on the 7th/2nd rank with
+// the move (that narrower case is IsStaticSafeQueeningPawn, above, for
+// when the weak side still has pawns of its own). Pure geometry --
+// KingPosition/DISTANCE/_GetPassersCriticalSquares -- no move
+// generation, so just as safe for eval.c to call as
+// IsStaticSafeQueeningPawn is.
+//
+FLAG
+IsUnstoppableLoneKingPasser(IN POSITION *pos, IN ULONG uStrong, IN COOR cPawn)
+{
+ ULONG uWeak = FLIP(uStrong);
+ COOR cCritical[3];
+ ULONG uDist[2];
+ ULONG u;
+
+ uDist[uStrong] = DISTANCE(KingPosition(pos, uStrong), cPawn);
+ uDist[uWeak] = DISTANCE(KingPosition(pos, uWeak), cPawn);
+ if (uDist[uStrong] > uDist[uWeak])
+ {
+ return(FALSE);
+ }
+
+ _GetPassersCriticalSquares(uStrong, cPawn, cCritical);
+ for (u = 0; u < 3; u++)
+ {
+ uDist[uStrong] = DISTANCE(KingPosition(pos, uStrong), cCritical[u]);
+ uDist[uWeak] = DISTANCE(KingPosition(pos, uWeak), cCritical[u]);
+ if (uDist[uWeak] > 0)
+ {
+ uDist[uWeak] -= (pos->uToMove == uWeak);
+ }
+ if (uDist[uStrong] > 0)
+ {
+ uDist[uStrong] += (pos->uToMove == uWeak) * 2;
+ }
+ if (uDist[uStrong] + 1 < uDist[uWeak])
+ {
+ return(TRUE);
+ }
+ }
+ return(FALSE);
+}
static ULONG
_RecognizeKPK(IN SEARCHER_THREAD_CONTEXT *ctx,
@@ -975,9 +1616,6 @@ Return value:
ULONG uStrong;
ULONG uWeak;
COOR cPawn, cQueen;
- COOR cCritical[3];
- ULONG uDist[2];
- ULONG u;
PAWN_HASH_ENTRY *pHash;
POSITION *pos = &ctx->sPosition;
@@ -994,12 +1632,13 @@ Return value:
if (pHash->u64Key == pos->u64PawnSig)
{
pos->iScore[BLACK] = pos->iScore[WHITE] = 0;
- if (TRUE == _EvalPasserRacesAgainstLoneKings(pos, pHash))
+ if (TRUE == _EvalPasserRacesAgainstLoneKings(ctx, pHash))
{
//
- // 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
+ // Someone wins -- but only trust this as a hard bound in
+ // the two shapes it's been exhaustively/statistically
+ // proven safe in, not universally. 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
@@ -1015,14 +1654,38 @@ Return value:
// -- 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.
+ // here.
+ //
+ // Two trusted shapes, both re-verified against the CURRENT
+ // _EvalPasserRacesAgainstLoneKings (thresholds/fork-overload
+ // fix included) before this was wired in, not just assumed
+ // from an earlier version's numbers:
+ // 1. Loser has zero pawns left -- no pawn to produce a
+ // connected-duo counterplay like the Pc5 case above,
+ // since there's nothing left to connect.
+ // 2. Exact one-pawn-per-side KPKP -- exhaustively checked
+ // against EGTB (testrecogn.c's
+ // TestEvalPasserRacesAgainstLoneKings): 0 wrong across
+ // all 14,872,176 EGTB-covered legal KPKP positions.
+ // With only one pawn per side there is no second pawn
+ // to form a duo with, so the exact same blind spot the
+ // Pc5 case exploits structurally cannot occur here
+ // either -- this isn't a narrower coincidental case of
+ // #1, it's independently safe for the same underlying
+ // reason. Any other multi-pawn-per-side shape (KPPKPP,
+ // KPKPPP, ...) measured a real ~1-5% wrong rate via
+ // random EGTB sampling (testrecogn.c's
+ // TestEvalSamplePasserRacesKPPKPP/KPKPPP) and stays
+ // OUT of this hard-bound trust -- falls through to the
+ // UNRECOGNIZED/sophisticated-analysis logic below same
+ // as always.
//
ASSERT((pos->iScore[BLACK] == 0) || (pos->iScore[WHITE] == 0));
ASSERT((pos->iScore[BLACK] != 0) || (pos->iScore[WHITE] != 0));
if ((pos->iScore[BLACK] > 0) &&
- (0 == FastCountBits(pos->bbPawns[WHITE])))
+ ((0 == FastCountBits(pos->bbPawns[WHITE])) ||
+ ((1 == FastCountBits(pos->bbPawns[WHITE])) &&
+ (1 == FastCountBits(pos->bbPawns[BLACK])))))
{
ASSERT(pos->iScore[WHITE] == 0);
*piScore = (pos->iMaterialBalance[BLACK] + 2 * VALUE_PAWN +
@@ -1036,7 +1699,9 @@ Return value:
return(RECOGN_LOWER);
}
else if ((pos->iScore[WHITE] > 0) &&
- (0 == FastCountBits(pos->bbPawns[BLACK])))
+ ((0 == FastCountBits(pos->bbPawns[BLACK])) ||
+ ((1 == FastCountBits(pos->bbPawns[BLACK])) &&
+ (1 == FastCountBits(pos->bbPawns[WHITE])))))
{
*piScore = (pos->iMaterialBalance[WHITE] + 2 * VALUE_PAWN +
pos->iScore[WHITE]);
@@ -1048,7 +1713,7 @@ Return value:
}
return(RECOGN_LOWER);
}
- // Else: the "losing" side still has pawns of its own --
+ // Else: the "losing" side still has 2+ 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
@@ -1057,10 +1722,31 @@ Return value:
}
//
- // No one has a winning passer or we did not find the hash entry.
+ // No one has a winning passer (per the lone-king-only fast path
+ // above) or we did not find the hash entry.
//
if ((FastCountBits(pos->bbPawns[WHITE]) > 0) && (FastCountBits(pos->bbPawns[BLACK]) > 0))
{
+ //
+ // Both sides still have pawns -- the general KP+KP* shape. Only
+ // coverage attempted here: the side to move has a pawn that
+ // safely queens THIS move (_HasImmediateSafeQueen) -- see its
+ // comment for why this narrow question is answerable at all
+ // when a general race-distance comparison was measured not to
+ // be (~15% wrong no matter the safety margin). Everything else
+ // in real king-and-pawn endgame theory (races that take more
+ // than one move, breakthroughs, outside passers, opposition,
+ // mutual zugzwang) is left as UNRECOGNIZED -- never wrong, not
+ // always right.
+ //
+ COOR cPawn;
+ if (TRUE == _HasImmediateSafeQueen(ctx, &cPawn))
+ {
+ ULONG uColor = pos->uToMove;
+ *piScore = (pos->iMaterialBalance[uColor] + VALUE_QUEEN +
+ (2 * VALUE_PAWN));
+ return(RECOGN_LOWER);
+ }
return(UNRECOGNIZED);
}
@@ -1099,83 +1785,18 @@ Return value:
ASSERT(IS_ON_BOARD(cPawn));
ASSERT(IS_PAWN(pos->rgSquare[cPawn].pPiece));
- //
- // Step 1: the strong king must be closer to the pawn than
- // the weak king.
- //
- uDist[uStrong] = DISTANCE(KingPosition(pos, uStrong), cPawn);
- ASSERT((1 <= uDist[uStrong]) && (uDist[uStrong] <= 7));
- uDist[uWeak] = DISTANCE(KingPosition(pos, uWeak), cPawn);
- ASSERT((1 <= uDist[uWeak]) && (uDist[uWeak] <= 7));
- if (uDist[uStrong] <= uDist[uWeak])
+ if (TRUE == IsUnstoppableLoneKingPasser(pos, uStrong, cPawn))
{
- //
- // Step 2: the strong king must be closer to one of the
- // passer's critical squares than the weak king.
- //
- _GetPassersCriticalSquares(uStrong, cPawn, cCritical);
- for (u = 0; u < 3; u++)
+ cQueen = QUEENING_SQUARE_BY_COLOR_FILE[uStrong][FILE(cPawn)];
+ *piScore = (pos->iMaterialBalance[uStrong] +
+ VALUE_QUEEN + (2 * VALUE_PAWN) -
+ (RANK_DISTANCE(cPawn, cQueen) * 32));
+ if (pos->uToMove == uWeak)
{
- uDist[uStrong] = DISTANCE(KingPosition(pos, uStrong),
- cCritical[u]);
- ASSERT((0 <= uDist[uStrong]) && (uDist[uStrong] <= 7));
- uDist[uWeak] = DISTANCE(KingPosition(pos, uWeak),
- cCritical[u]);
-
- //
- // Assume if the weak side is on move he will move
- // towards the critical square. Also assume that
- // in so doing he will increase the path that the
- // strong king must take to get to the critical
- // square.
- //
- if (uDist[uWeak] > 0) {
- uDist[uWeak] -= (pos->uToMove == uWeak);
- }
- if (uDist[uStrong] > 0) {
- uDist[uStrong] += (pos->uToMove == uWeak) * 2;
- }
- ASSERT((0 <= uDist[uWeak]) && (uDist[uWeak] <= 7));
- //
- // 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)];
- *piScore = (pos->iMaterialBalance[uStrong] +
- VALUE_QUEEN + (2 * VALUE_PAWN) -
- (RANK_DISTANCE(cPawn, cQueen) * 32));
- if (pos->uToMove == uWeak)
- {
- *piScore *= -1;
- return(RECOGN_UPPER);
- }
- return(RECOGN_LOWER);
- }
+ *piScore *= -1;
+ return(RECOGN_UPPER);
}
+ return(RECOGN_LOWER);
}
//
diff --git a/src/testrecogn.c b/src/testrecogn.c
index d320394..b64643e 100644
--- a/src/testrecogn.c
+++ b/src/testrecogn.c
@@ -882,4 +882,1179 @@ Return value:
uTotal, uChecked, uSkipped, uWrong);
}
+//
+// ============================================================================
+// KP+KP* (general king-and-pawn endgame, ANY number of pawns per side)
+// recognizer verification, with EGTB-DTZ-based winning-line reconstruction
+// to classify WHY a mismatch happened.
+// ============================================================================
+//
+// Everything above tests recognizers whose material shape has pawns on at
+// most one side (KPK) or exactly one pawn per side via fixed square
+// enumeration. Both sides having pawns, in general, is real king-and-pawn
+// endgame theory (breakthroughs, outside passers, opposition/corresponding
+// squares, mutual zugzwang) -- no cheap static recognizer classifies that
+// exhaustively. The design goal is NOT a recognizer that is always right,
+// but one that is never wrong: assert a bound only when a position is
+// provably, conservatively a clear win, and return UNRECOGNIZED (do
+// nothing) the moment there's any doubt. _RecognizeKPK (recogn.c) already
+// does exactly this today -- whenever both sides still have at least one
+// pawn, it currently falls straight through to UNRECOGNIZED unconditionally
+// (see its "no one has a winning passer" comment). Running this harness
+// against that baseline should show 0 wrong / 0 checked / 100% unrecognized
+// for the KPKP shape -- that's the correct, honest starting point. Rerun
+// after every incremental addition to that function's "both sides have
+// pawns" branch, watching the wrong-count stay at exactly 0 while the
+// checked-count rises.
+//
+
+#define EGTB_LINE_MAX_PLY (24)
+
+typedef struct _EGTB_LINE_MOVE
+{
+ MOVE mv;
+ FLAG fGivesCheck;
+} EGTB_LINE_MOVE;
+
+//
+// Follow Syzygy DTZ's own suggested move at every ply from the CURRENT
+// position in ctx, building a genuine, engine-verified winning line --
+// not a search, just repeated GenerateMoves + ProbeEGTBRoot + MakeMove,
+// exactly the mechanism root.c's Iterate() uses to pick a single root
+// move (see ProbeEGTBRoot's own header comment). Stops at whichever
+// comes first: uMaxPly plies, a position with no legal moves (checkmate/
+// stalemate), or ProbeEGTBRoot declining (draw/cursed-win/blessed-loss/
+// out of tablebase range/no coverage at all) -- all meaningful stopping
+// points for a "why did the recognizer miss this" line, not failures.
+// Restores ctx to its original position before returning (unwinds every
+// move it made), so the caller can keep reusing ctx exactly as it found
+// it.
+//
+static ULONG
+_GetEgtbLine(IN OUT SEARCHER_THREAD_CONTEXT *ctx,
+ OUT EGTB_LINE_MOVE *pLine,
+ IN ULONG uMaxPly)
+{
+ ULONG uPly;
+ MOVE mv;
+ SCORE iScore;
+ FLAG fInCheck;
+
+ for (uPly = 0; uPly < uMaxPly; uPly++)
+ {
+ fInCheck = InCheck(&ctx->sPosition, ctx->sPosition.uToMove);
+ ctx->sPlyInfo[ctx->uPly].fInCheck = fInCheck;
+ GenerateMoves(ctx, NULLMOVE,
+ fInCheck ? GENERATE_ESCAPES : GENERATE_ALL_MOVES);
+ if (ctx->sMoveStack.uBegin[ctx->uPly] ==
+ ctx->sMoveStack.uEnd[ctx->uPly])
+ {
+ break; // checkmate or stalemate -- nothing more to follow
+ }
+ if (FALSE == ProbeEGTBRoot(ctx, &mv, &iScore))
+ {
+ break; // draw/cursed/blessed/out of range/no coverage
+ }
+ pLine[uPly].mv = mv;
+ if (FALSE == MakeMove(ctx, mv))
+ {
+ ASSERT(FALSE);
+ break;
+ }
+ pLine[uPly].fGivesCheck = InCheck(&ctx->sPosition,
+ ctx->sPosition.uToMove);
+ }
+
+ // Unwind back to the original position, reverse order.
+ {
+ ULONG u = uPly;
+ while (u > 0)
+ {
+ u--;
+ UnmakeMove(ctx, pLine[u].mv);
+ }
+ }
+ return(uPly);
+}
+
+//
+// Print an EGTB-DTZ line built by _GetEgtbLine in coordinate notation,
+// tagging each move with a cheap, mechanical classification (capture /
+// check / promotion / which piece moved) so a human scanning a batch of
+// mismatches can group them by pattern -- e.g. "every miss here starts
+// with a king move, not a pawn push" points straight at a king-escort/
+// opposition case; "starts with a capture" points at a tactical shot the
+// margin-based race logic has no way to see; "promotes with check" flags
+// exactly the family named in the original request. Deliberately NOT
+// trying to name the endgame-theory concept itself (opposition,
+// corresponding squares, breakthrough) -- that's for a human reading the
+// tagged line plus the position dump, not this mechanical tagger.
+//
+static void
+_PrintEgtbLine(IN EGTB_LINE_MOVE *pLine, IN ULONG uLen)
+{
+ ULONG u;
+ const CHAR *pszPiece;
+ MOVE mv;
+
+ if (0 == uLen)
+ {
+ Trace(" (no EGTB line available -- draw/cursed/blessed/out of "
+ "tablebase range/no coverage/terminal already)\n");
+ return;
+ }
+ for (u = 0; u < uLen; u++)
+ {
+ mv = pLine[u].mv;
+ pszPiece = IS_KING(mv.pMoved) ? "K" : (IS_PAWN(mv.pMoved) ? "P" : "?");
+ Trace(" %2lu. %s%s%s%s%s\n",
+ u + 1, pszPiece, MoveToIcs(mv),
+ mv.pCaptured ? " (capture)" : "",
+ mv.pPromoted ? " (promotes)" : "",
+ pLine[u].fGivesCheck ? " (check)" : "");
+ }
+}
+
+//
+// Classification of the FIRST move of a mismatch's EGTB line -- the
+// single most useful signal for bucketing misses, since a recognizer's
+// verdict is computed BEFORE any move is made, so whatever the correct
+// first move turns out to be is exactly what that logic failed to
+// anticipate.
+//
+static const CHAR *
+_ClassifyFirstMove(IN EGTB_LINE_MOVE *pLine, IN ULONG uLen)
+{
+ MOVE mv;
+
+ if (0 == uLen)
+ {
+ return("no-line");
+ }
+ mv = pLine[0].mv;
+ if (mv.pPromoted)
+ {
+ return(pLine[0].fGivesCheck ? "promotes-with-check" : "promotes");
+ }
+ if (mv.pCaptured)
+ {
+ return("capture");
+ }
+ if (pLine[0].fGivesCheck)
+ {
+ return("check");
+ }
+ if (IS_KING(mv.pMoved))
+ {
+ return("king-move");
+ }
+ if (IS_PAWN(mv.pMoved))
+ {
+ return("pawn-push");
+ }
+ return("other");
+}
+
+//
+// Shared "we found a wrong verdict" reporting path for the KPvKP tests
+// below: dump the position, the recognizer's claim vs. EGTB's, the
+// mechanical first-move classification, and the full EGTB-DTZ line. ctx
+// must already be ReInitializeSearcherContext'd on pos (the failing
+// position) when this is called -- _GetEgtbLine needs a live, correctly
+// positioned ctx to generate moves and probe from.
+//
+static void
+_ReportRecognMismatch(IN OUT SEARCHER_THREAD_CONTEXT *ctx,
+ IN POSITION *pos,
+ IN const CHAR *pszCaseName,
+ IN SCORE iRecScore,
+ IN ULONG uVal,
+ IN SCORE iEgtb)
+{
+ EGTB_LINE_MOVE rgLine[EGTB_LINE_MAX_PLY];
+ ULONG uLen;
+
+ Trace("MISMATCH [%s]: recognizer=%d (uVal=%lu) EGTB=%d toMove=%lu\n",
+ pszCaseName, iRecScore, uVal, iEgtb, pos->uToMove);
+ DumpPosition(pos);
+ uLen = _GetEgtbLine(ctx, rgLine, EGTB_LINE_MAX_PLY);
+ Trace(" first winning move classified as: %s\n",
+ _ClassifyFirstMove(rgLine, uLen));
+ _PrintEgtbLine(rgLine, uLen);
+}
+
+//
+// Build a KP vs KP (exactly one pawn per side, otherwise bare kings)
+// position directly from square placements.
+//
+static FLAG
+_BuildKPvKPPosition(OUT POSITION *pos,
+ IN COOR cWhiteKing, IN COOR cBlackKing,
+ IN COOR cWhitePawn, IN COOR cBlackPawn,
+ IN ULONG uToMove)
+{
+ PIECE pBoard[128];
+ CHAR szFen[256];
+ CHAR *p;
+ ULONG x;
+
+ memset(pBoard, 0, sizeof(pBoard));
+ pBoard[cWhiteKing] = WHITE_KING;
+ pBoard[cBlackKing] = BLACK_KING;
+ pBoard[cWhitePawn] = WHITE_PAWN;
+ pBoard[cBlackPawn] = BLACK_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
+TestRecognExhaustiveKPvKP(void)
+/**
+
+Routine description:
+
+ Exhaustively enumerate every legal KP vs KP (exactly one pawn per
+ side, otherwise bare kings) position and verify RecognLookup's
+ verdict against direct EGTB probes wherever coverage exists. This is
+ the smallest slice of the general "both sides have pawns" shape and
+ the only one small enough (~10^7 raw combos) to fully exhaust rather
+ than sample -- the acceptance bar any new "both sides have pawns"
+ detection logic added to _RecognizeKPK should clear before being
+ trusted at all: zero wrong verdicts across the complete space, not
+ "zero wrong in N random samples" (exactly the false confidence that
+ let the pre-2026-09-16 KPK recognizer and the eval.c "general KPvKP"
+ heuristic both ship with real bugs -- see chess.h/eval.c's writeups).
+
+ On-demand, not part of the automatic startup suite -- same
+ precedent as TestRecognExhaustiveKPK/KNKP.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ COOR cWhiteKing, cBlackKing, cWhitePawn, cBlackPawn;
+ ULONG uToMove;
+ SCORE iScore, iEgtb;
+ ULONG uVal;
+ ULONG uTotal = 0, uChecked = 0, uSkipped = 0, uUnrecognized = 0, uWrong = 0;
+ ULONG uReportedWrong = 0;
+
+ Trace("Exhaustive KP vs KP (one pawn each) recognizer verification "
+ "against EGTB -- this takes a while...\n");
+ ctx = malloc(sizeof(SEARCHER_THREAD_CONTEXT));
+ if (NULL == ctx)
+ {
+ return;
+ }
+ InitializeSearcherContext(&pos, ctx);
+
+ FOREACH_SQUARE(cWhiteKing)
+ {
+ if (!IS_ON_BOARD(cWhiteKing)) continue;
+
+ FOREACH_SQUARE(cBlackKing)
+ {
+ if (!IS_ON_BOARD(cBlackKing)) continue;
+ if (DISTANCE(cWhiteKing, cBlackKing) < 2) continue;
+
+ FOREACH_SQUARE(cWhitePawn)
+ {
+ if (!IS_ON_BOARD(cWhitePawn)) continue;
+ if (RANK1(cWhitePawn) || RANK8(cWhitePawn)) continue;
+ if ((cWhitePawn == cWhiteKing) ||
+ (cWhitePawn == cBlackKing)) continue;
+
+ FOREACH_SQUARE(cBlackPawn)
+ {
+ if (!IS_ON_BOARD(cBlackPawn)) continue;
+ if (RANK1(cBlackPawn) || RANK8(cBlackPawn)) continue;
+ if ((cBlackPawn == cWhiteKing) ||
+ (cBlackPawn == cBlackKing) ||
+ (cBlackPawn == cWhitePawn)) continue;
+
+ for (uToMove = BLACK; uToMove <= WHITE; uToMove++)
+ {
+ uTotal++;
+ if (0 == (uTotal % 1000000))
+ {
+ Trace(" ... %lu raw combos so far (%lu checked, "
+ "%lu wrong)\n", uTotal, uChecked, uWrong);
+ }
+ if (FALSE == _BuildKPvKPPosition(&pos, cWhiteKing,
+ cBlackKing,
+ cWhitePawn,
+ cBlackPawn,
+ uToMove))
+ {
+ continue;
+ }
+ ReInitializeSearcherContext(&pos, ctx);
+ uVal = RecognLookup(ctx, &iScore, FALSE);
+ if (UNRECOGNIZED == uVal)
+ {
+ uUnrecognized++;
+ continue;
+ }
+ if (FALSE == ProbeEGTB(ctx, &iEgtb))
+ {
+ uSkipped++;
+ continue;
+ }
+ uChecked++;
+ if (FALSE == _RecognizerAgreesWithEGTB(iScore, uVal,
+ iEgtb))
+ {
+ uWrong++;
+ if (uReportedWrong < 20)
+ {
+ ReInitializeSearcherContext(&pos, ctx);
+ _ReportRecognMismatch(ctx, &pos, "KPvKP-exh",
+ iScore, uVal, iEgtb);
+ uReportedWrong++;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ free(ctx);
+ Trace("Exhaustive KPvKP: %lu raw combos, %lu checked against EGTB, "
+ "%lu skipped (no EGTB coverage), %lu unrecognized, %lu WRONG.\n",
+ uTotal, uChecked, uSkipped, uUnrecognized, uWrong);
+}
+
+//
+// ============================================================================
+// eval.c's _EvalPasserRacesAgainstLoneKings EGTB cross-check.
+// ============================================================================
+//
+// Same "how dodgy is this, really" question the 2026-09-16 KPvKP
+// investigation asked of recogn.c's KPK/KPvKP recognizers, now asked
+// of the eval.c heuristic that fills the gap when both sides are pure
+// KPvKP but at least one side still has pawns -- the exact case
+// _EvalUnstoppablePasser (eval.c) deliberately stays silent on and
+// falls back to this function for (see that function's own header
+// comment). This is a SOFT eval nudge, not a recogn.c hard bound (a
+// wrong verdict biases a score, never corrupts a search bound), but
+// it's a much louder nudge than SideHasWinningChances's fortress
+// carve-out (RACER_WINS_RACE is +300, most of a minor piece) so it's
+// worth knowing its real false-positive rate rather than assuming
+// "dodgy" means "ignorable."
+//
+// Uses the exact same board-building code as
+// TestRecognExhaustiveKPvKP (_BuildKPvKPPosition) -- same enumeration,
+// same EGTB ground truth, different function under test -- so a
+// side-by-side comparison of the two WRONG counts is directly
+// meaningful.
+//
+// Unlike RecognLookup's uVal/iScore (side-to-move-relative,
+// LOWER/UPPER/EXACT bound semantics), _EvalPasserRacesAgainstLoneKings
+// writes directly into pos->iScore[WHITE]/[BLACK] (absolute color,
+// unconditional "this color is winning the race" credit -- always a
+// positive RACER_WINS_RACE-derived add, never negative, so whichever
+// color's iScore moved off zero is the color it claimed). A "wrong"
+// verdict here means it claimed a color is winning an unstoppable
+// pawn race when EGTB says that color does NOT win (a draw, or the
+// other color wins) -- the one direction that actually matters, since
+// a false "this side has no race credit" just falls through to
+// ordinary positional eval terms rather than asserting anything false.
+//
+void
+TestEvalPasserRacesAgainstLoneKings(void)
+/**
+
+Routine description:
+
+ Exhaustively enumerate every legal KP vs KP (exactly one pawn per
+ side, otherwise bare kings) position -- same space as
+ TestRecognExhaustiveKPvKP -- and cross-check
+ _EvalPasserRacesAgainstLoneKings's verdict (via a real,
+ Eval()-populated PAWN_HASH_ENTRY from EvalPawns) against direct
+ EGTB probes wherever coverage exists.
+
+ On-demand, not part of the automatic startup suite -- same
+ precedent as TestRecognExhaustiveKPvKP.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ COOR cWhiteKing, cBlackKing, cWhitePawn, cBlackPawn;
+ ULONG uToMove, uClaimedColor;
+ SCORE iEgtb;
+ FLAG fDeferred, fFired;
+ PAWN_HASH_ENTRY *pHash;
+ ULONG uTotal = 0, uChecked = 0, uSkipped = 0, uFired = 0;
+ ULONG uWrong = 0, uMissed = 0;
+ ULONG uReportedWrong = 0;
+
+ Trace("Exhaustive KP vs KP (one pawn each) _EvalPasserRacesAgainst"
+ "LoneKings verification against EGTB -- this takes a "
+ "while...\n");
+ ctx = malloc(sizeof(SEARCHER_THREAD_CONTEXT));
+ if (NULL == ctx)
+ {
+ return;
+ }
+ InitializeSearcherContext(&pos, ctx);
+
+ FOREACH_SQUARE(cWhiteKing)
+ {
+ if (!IS_ON_BOARD(cWhiteKing)) continue;
+
+ FOREACH_SQUARE(cBlackKing)
+ {
+ if (!IS_ON_BOARD(cBlackKing)) continue;
+ if (DISTANCE(cWhiteKing, cBlackKing) < 2) continue;
+
+ FOREACH_SQUARE(cWhitePawn)
+ {
+ if (!IS_ON_BOARD(cWhitePawn)) continue;
+ if (RANK1(cWhitePawn) || RANK8(cWhitePawn)) continue;
+ if ((cWhitePawn == cWhiteKing) ||
+ (cWhitePawn == cBlackKing)) continue;
+
+ FOREACH_SQUARE(cBlackPawn)
+ {
+ if (!IS_ON_BOARD(cBlackPawn)) continue;
+ if (RANK1(cBlackPawn) || RANK8(cBlackPawn)) continue;
+ if ((cBlackPawn == cWhiteKing) ||
+ (cBlackPawn == cBlackKing) ||
+ (cBlackPawn == cWhitePawn)) continue;
+
+ for (uToMove = BLACK; uToMove <= WHITE; uToMove++)
+ {
+ uTotal++;
+ if (0 == (uTotal % 1000000))
+ {
+ Trace(" ... %lu raw combos so far (%lu fired, "
+ "%lu wrong, %lu missed)\n", uTotal,
+ uFired, uWrong, uMissed);
+ }
+ if (FALSE == _BuildKPvKPPosition(&pos, cWhiteKing,
+ cBlackKing,
+ cWhitePawn,
+ cBlackPawn,
+ uToMove))
+ {
+ continue;
+ }
+ ReInitializeSearcherContext(&pos, ctx);
+ ctx->sPosition.iScore[WHITE] = 0;
+ ctx->sPosition.iScore[BLACK] = 0;
+ // ReInitializeSearcherContext doesn't touch
+ // sPlyInfo, so ctx->sPlyInfo[ctx->uPly].fInCheck
+ // would otherwise be stale -- establish it
+ // explicitly, same fix command.c's EvalCommand/
+ // EvalDetailCommand needed. _EvalPasserRacesAgainst
+ // LoneKings now takes ctx directly (not pos) and
+ // reads this field to bail out early on in-check
+ // positions.
+ ctx->sPlyInfo[ctx->uPly].fInCheck =
+ InCheck(&ctx->sPosition, ctx->sPosition.uToMove);
+
+ pHash = EvalPawns(ctx, &fDeferred);
+ fFired = _EvalPasserRacesAgainstLoneKings(ctx, pHash);
+
+ if (FALSE == ProbeEGTB(ctx, &iEgtb))
+ {
+ uSkipped++;
+ continue;
+ }
+ uChecked++;
+
+ // iEgtb is relative to side to move; convert to
+ // an absolute-color winner (or DRAW).
+ if (FALSE == fFired)
+ {
+ if (0 != iEgtb)
+ {
+ uMissed++;
+ }
+ continue;
+ }
+ uFired++;
+ uClaimedColor =
+ (ctx->sPosition.iScore[WHITE] != 0) ?
+ WHITE : BLACK;
+ ASSERT(ctx->sPosition.iScore[uClaimedColor] > 0);
+
+ if ((0 == iEgtb) ||
+ (((iEgtb > 0) ? ctx->sPosition.uToMove
+ : FLIP(ctx->sPosition.uToMove))
+ != uClaimedColor))
+ {
+ uWrong++;
+ if (uReportedWrong < 20)
+ {
+ Trace("MISMATCH [EvalPasserRace-exh]: "
+ "claimed=%s EGTB(stm-relative)=%d "
+ "toMove=%s\n",
+ COLOR_NAME(uClaimedColor), iEgtb,
+ COLOR_NAME(ctx->sPosition.uToMove));
+ _ReportRecognMismatch(ctx, &pos,
+ "EvalPasserRace-exh",
+ ctx->sPosition.iScore[
+ uClaimedColor],
+ RECOGN_LOWER, iEgtb);
+ uReportedWrong++;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ free(ctx);
+ //
+ // %u, not %lu: ULONG is `unsigned int` (32-bit) here, not
+ // `unsigned long`. With 6 integer varargs, the first 5 land in
+ // registers (SysV x86-64 ABI zero-extends a 32-bit register move,
+ // masking a %lu/ULONG width mismatch) but the 6th spills to the
+ // stack un-zero-extended -- %lu then reads 8 bytes and prints
+ // garbage in the upper 32 bits. Found live: this printed "8599473172
+ // missed" on a run where uMissed was actually ~14M. Matching the
+ // format specifier to the real type is the fix, not trimming the
+ // argument count.
+ //
+ Trace("Exhaustive _EvalPasserRacesAgainstLoneKings: %u raw combos, "
+ "%u checked against EGTB, %u skipped (no EGTB coverage), "
+ "%u fired, %u WRONG (claimed a win that wasn't), %u missed "
+ "(a real win it said nothing about).\n",
+ uTotal, uChecked, uSkipped, uFired, uWrong, uMissed);
+}
+
+//
+// Random-sampling cross-check of _EvalPasserRacesAgainstLoneKings for
+// multi-pawn-per-side KP+KP* shapes (KPPKPP, KPKPPP, ...) that are far
+// too large to enumerate exhaustively (unlike the exact-one-pawn-each
+// KPvKP shape above, which is small enough to exhaust at ~15M raw
+// combos). This is what recogn.c:1635 (_RecognizeKPK) actually needs
+// validated before it could ever trust _EvalPasserRacesAgainstLoneKings
+// as a hard RECOGN_LOWER/UPPER bound when the "losing" side still has
+// pawns of its own -- today it only trusts a verdict when the losing
+// side has ZERO pawns left, specifically because that broader multi-
+// pawn shape was never validated. Requires 6-man Syzygy WDL coverage
+// for the 2-pawns-per-side case (KPPvKPP is 2 kings + 4 pawns = 6 men)
+// -- confirmed present on this box (matching .rtbw/.rtbz counts under
+// /zscratch/egtb) even though CLAUDE.md's older note says 6-man WDL is
+// missing; that note is stale, verified stale by directly diffing the
+// .rtbw/.rtbz basename sets rather than trusting the note.
+//
+static void
+_TestEvalSamplePasserRaces(IN ULONG uPawnA, IN ULONG uPawnB,
+ IN ULONG uIterations, IN const CHAR *pszLabel)
+{
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ ULONG uIter, uClaimedColor, uLoserColor, uLoserPawns, uWinnerPawns;
+ SCORE iEgtb;
+ FLAG fDeferred, fFired;
+ PAWN_HASH_ENTRY *pHash;
+ ULONG uChecked = 0, uSkipped = 0, uFired = 0, uWrong = 0, uUnrecognized = 0;
+ ULONG uReportedWrong = 0;
+ // Classify each wrong verdict by which side had 2+ pawns -- lets us
+ // tell whether the LOSER's extra pawns (connected/backup passer
+ // counterplay this per-pawn scan can't see) or the WINNER's extra
+ // pawns (e.g. picking the wrong one of several candidates) is the
+ // actual driver, rather than guessing at a fix.
+ ULONG uWrongLoserMulti = 0, uWrongWinnerMulti = 0, uWrongBothMulti = 0,
+ uWrongNeitherMulti = 0;
+ // Does the true winner (per EGTB -- the side our verdict called
+ // the LOSER) even have a pawn flagged "passed" at all? If not, the
+ // real threat was invisible to this function from the start (a
+ // detection gap upstream of any comparison logic here); if so, it
+ // WAS visible but still lost the comparison/veto to the wrongly-
+ // credited side (a bug in this function's own arithmetic).
+ ULONG uWrongTrueWinnerNoPasser = 0, uWrongTrueWinnerHasPasser = 0;
+
+ Trace("Random-sampling %s _EvalPasserRacesAgainstLoneKings against "
+ "EGTB ground truth -- %u iterations...\n", pszLabel, uIterations);
+ ctx = malloc(sizeof(SEARCHER_THREAD_CONTEXT));
+ if (NULL == ctx)
+ {
+ return;
+ }
+ InitializeSearcherContext(&pos, ctx);
+
+ for (uIter = 0; uIter < uIterations; uIter++)
+ {
+ if ((uIter != 0) && (0 == (uIter % 500000)))
+ {
+ Trace(" ... %u iterations so far (%u fired, %u wrong, "
+ "%u skipped, %u unrecognized)\n",
+ uIter, uFired, uWrong, uSkipped, uUnrecognized);
+ }
+
+ _MakeRandomEndgame(&pos, uPawnA, 0, 0, uPawnB, 0, 0);
+ ReInitializeSearcherContext(&pos, ctx);
+ ctx->sPosition.iScore[WHITE] = 0;
+ ctx->sPosition.iScore[BLACK] = 0;
+ ctx->sPlyInfo[ctx->uPly].fInCheck =
+ InCheck(&ctx->sPosition, ctx->sPosition.uToMove);
+
+ pHash = EvalPawns(ctx, &fDeferred);
+ fFired = _EvalPasserRacesAgainstLoneKings(ctx, pHash);
+
+ if (FALSE == fFired)
+ {
+ uUnrecognized++;
+ continue;
+ }
+ if (FALSE == ProbeEGTB(ctx, &iEgtb))
+ {
+ uSkipped++;
+ continue;
+ }
+ uChecked++;
+ uFired++;
+
+ uClaimedColor = (ctx->sPosition.iScore[WHITE] != 0) ? WHITE : BLACK;
+ ASSERT(ctx->sPosition.iScore[uClaimedColor] > 0);
+
+ if ((0 == iEgtb) ||
+ (((iEgtb > 0) ? ctx->sPosition.uToMove
+ : FLIP(ctx->sPosition.uToMove)) != uClaimedColor))
+ {
+ uWrong++;
+
+ uLoserColor = FLIP(uClaimedColor);
+ uWinnerPawns = FastCountBits(ctx->sPosition.bbPawns[uClaimedColor]);
+ uLoserPawns = FastCountBits(ctx->sPosition.bbPawns[uLoserColor]);
+ if ((uLoserPawns > 1) && (uWinnerPawns > 1))
+ {
+ uWrongBothMulti++;
+ }
+ else if (uLoserPawns > 1)
+ {
+ uWrongLoserMulti++;
+ }
+ else if (uWinnerPawns > 1)
+ {
+ uWrongWinnerMulti++;
+ }
+ else
+ {
+ uWrongNeitherMulti++;
+ }
+
+ // uLoserColor here is the side OUR verdict didn't credit --
+ // since the verdict is wrong, that's actually the TRUE
+ // winner per EGTB. Did it even have a pawn flagged passed?
+ if (0 == pHash->bbPasserLocations[uLoserColor])
+ {
+ uWrongTrueWinnerNoPasser++;
+ }
+ else
+ {
+ uWrongTrueWinnerHasPasser++;
+ if (uReportedWrong < 20)
+ {
+ Trace(" (true winner %s DID have a flagged passer -- "
+ "comparison/veto bug, not a detection gap)\n",
+ COLOR_NAME(uLoserColor));
+ }
+ }
+
+ if (uReportedWrong < 20)
+ {
+ Trace("MISMATCH [%s]: claimed=%s (winner pawns=%u) "
+ "loser=%s (loser pawns=%u) EGTB(stm-relative)=%d "
+ "toMove=%s\n", pszLabel, COLOR_NAME(uClaimedColor),
+ uWinnerPawns, COLOR_NAME(uLoserColor), uLoserPawns,
+ iEgtb, COLOR_NAME(ctx->sPosition.uToMove));
+ _ReportRecognMismatch(ctx, &pos, pszLabel,
+ ctx->sPosition.iScore[uClaimedColor],
+ RECOGN_LOWER, iEgtb);
+ uReportedWrong++;
+ }
+ }
+ }
+
+ free(ctx);
+ Trace("%s sampling: %u iterations, %u checked against EGTB, "
+ "%u fired, %u WRONG (claimed a win that wasn't), %u skipped "
+ "(no EGTB coverage), %u unrecognized (didn't fire).\n",
+ pszLabel, uIterations, uChecked, uFired, uWrong, uSkipped,
+ uUnrecognized);
+ Trace("%s wrong breakdown: %u loser-has-2+-pawns-only, "
+ "%u winner-has-2+-pawns-only, %u both-have-2+-pawns, "
+ "%u neither (both <=1 pawn -- shouldn't happen for a shape "
+ "with a multi-pawn side, worth investigating if nonzero).\n",
+ pszLabel, uWrongLoserMulti, uWrongWinnerMulti, uWrongBothMulti,
+ uWrongNeitherMulti);
+ Trace("%s wrong mechanism: %u true-winner-had-no-flagged-passer "
+ "(detection gap -- the real threat was invisible to this "
+ "function), %u true-winner-DID-have-a-flagged-passer "
+ "(comparison/veto bug -- the info was there and still lost).\n",
+ pszLabel, uWrongTrueWinnerNoPasser, uWrongTrueWinnerHasPasser);
+}
+
+void
+TestEvalSamplePasserRacesKPPKPP(void)
+/**
+
+Routine description:
+
+ Random-sampling _EvalPasserRacesAgainstLoneKings cross-check for
+ KPPKPP (2 pawns each side, 6 men total -- needs 6-man WDL Syzygy
+ coverage). On-demand, not part of the automatic startup suite.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ _TestEvalSamplePasserRaces(2, 2, 1000000, "KPPKPP");
+}
+
+void
+TestEvalSamplePasserRacesKPKPPP(void)
+/**
+
+Routine description:
+
+ Random-sampling _EvalPasserRacesAgainstLoneKings cross-check for
+ KPKPPP (1 pawn one side, 3 the other -- 6 men total, asymmetric
+ pawn-count shape). _MakeRandomEndgame randomly assigns which color
+ gets which count, so a single run exercises both KPKPPP and
+ KPPPKP. On-demand, not part of the automatic startup suite.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ _TestEvalSamplePasserRaces(1, 3, 1000000, "KPKPPP");
+}
+
+#define RECOGN_KPVKP_MAX_PAWNS_PER_SIDE (4)
+#define RECOGN_KPVKP_SAMPLE_ITERATIONS (20000)
+
+void
+TestRecognSampleKPvKP(void)
+/**
+
+Routine description:
+
+ Random-sample the general "both sides have pawns" (KPKP material
+ signature, any pawn count 1..RECOGN_KPVKP_MAX_PAWNS_PER_SIDE each
+ side -- recogn.c's material-signature dispatch is presence-based,
+ not count-based, so one registered "pawns only, both sides"
+ recognizer already covers every pawn count via this single
+ signature) shape, cross-checking RecognLookup's verdict against
+ EGTB wherever coverage exists. Exhaustive enumeration is only
+ tractable for exactly one pawn per side (TestRecognExhaustiveKPvKP
+ above) -- beyond that this is sampling, not exhaustion, so a clean
+ run here is evidence, not proof; keep TestRecognExhaustiveKPvKP as
+ the actual acceptance bar and treat this as a wider (if shallower)
+ smoke net, especially useful for 2-3 pawns/side where EGTB WDL
+ coverage still fully exists (this box has full 5-man WDL+DTZ; 3
+ total pawns + 2 kings = 5 men) even though exhaustive enumeration of
+ that space is not attempted.
+
+ Every mismatch gets the same EGTB-DTZ winning-line reconstruction
+ and first-move classification as the exhaustive test (see
+ _ReportRecognMismatch) -- this is what actually matters for a
+ material shape too large to enumerate: a handful of concrete,
+ classified counterexamples to fix and turn into permanent
+ regression FENs (see g_rgRecognFenCases above), not just a wrong
+ count.
+
+ On-demand, not part of the automatic startup suite.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ ULONG uIter, uPawnA, uPawnB;
+ SCORE iScore, iEgtb;
+ ULONG uVal;
+ ULONG uChecked = 0, uSkipped = 0, uUnrecognized = 0, uWrong = 0;
+ ULONG uReportedWrong = 0;
+
+ Trace("Random-sampling KP+KP* (both sides have pawns, up to %u each) "
+ "against EGTB ground truth -- %u iterations...\n",
+ RECOGN_KPVKP_MAX_PAWNS_PER_SIDE, RECOGN_KPVKP_SAMPLE_ITERATIONS);
+ ctx = malloc(sizeof(SEARCHER_THREAD_CONTEXT));
+ if (NULL == ctx)
+ {
+ return;
+ }
+ InitializeSearcherContext(&pos, ctx);
+
+ for (uIter = 0; uIter < RECOGN_KPVKP_SAMPLE_ITERATIONS; uIter++)
+ {
+ uPawnA = 1 + (rand() % RECOGN_KPVKP_MAX_PAWNS_PER_SIDE);
+ uPawnB = 1 + (rand() % RECOGN_KPVKP_MAX_PAWNS_PER_SIDE);
+
+ _MakeRandomEndgame(&pos, uPawnA, 0, 0, uPawnB, 0, 0);
+ ReInitializeSearcherContext(&pos, ctx);
+
+ uVal = RecognLookup(ctx, &iScore, FALSE);
+ if (UNRECOGNIZED == uVal)
+ {
+ uUnrecognized++;
+ continue;
+ }
+ if (FALSE == ProbeEGTB(ctx, &iEgtb))
+ {
+ uSkipped++;
+ continue;
+ }
+ uChecked++;
+ if (FALSE == _RecognizerAgreesWithEGTB(iScore, uVal, iEgtb))
+ {
+ uWrong++;
+ if (uReportedWrong < 20)
+ {
+ ReInitializeSearcherContext(&pos, ctx);
+ _ReportRecognMismatch(ctx, &pos, "KPKP-sample", iScore,
+ uVal, iEgtb);
+ uReportedWrong++;
+ }
+ }
+ }
+
+ free(ctx);
+ Trace("KP+KP* sampling: %u iterations, %lu checked against EGTB, "
+ "%lu skipped (no EGTB coverage), %lu unrecognized, %lu WRONG.\n",
+ RECOGN_KPVKP_SAMPLE_ITERATIONS, uChecked, uSkipped, uUnrecognized,
+ uWrong);
+ if (0 == uChecked)
+ {
+ Trace("WARNING: zero positions were actually cross-checked against "
+ "EGTB.\n");
+ }
+}
+
+//
+// ============================================================================
+// Wrong-bishop-rook-pawn fortress (eval.c's SideHasWinningChances)
+// EGTB cross-check.
+// ============================================================================
+//
+// Unlike every recogn.c check above, this is a SOFT eval.c heuristic --
+// wrong answers only bias a score, never corrupt a search bound -- so
+// the acceptance bar is different: not "zero wrong, ever" but "measure
+// the false-positive rate and confirm it's the kind of small, tolerable
+// number the eval.c comment above SideHasWinningChances expects," the
+// same spirit as this session's original KPvKP investigation but with
+// a softer landing. A "false positive" here means SideHasWinningChances
+// claimed uStrong has NO winning chances (the fortress verdict) but
+// EGTB shows uStrong actually wins -- the direction that would make the
+// eval wrongly pessimistic about a real win, the only direction that
+// matters for a heuristic that's only ever used to softenscores, not to
+// claim a win.
+//
+#define WRONG_BISHOP_SAMPLE_ITERATIONS (20000)
+
+//
+// Build a random KBP+K position: uStrong has a king, one bishop, and
+// uPawnCount pawns all on a single file (uFile) -- the exact shape
+// SideHasWinningChances' fortress check looks at (a rook-file pawn
+// mass, doubled/tripled included, since the check itself never
+// verifies path-clearness or how many pawns share the file). uWeak has
+// only a bare king.
+//
+static FLAG
+_TryBuildWrongBishopPosition(OUT POSITION *pos, IN ULONG uStrong,
+ IN ULONG uFile, IN ULONG uPawnCount)
+{
+ PIECE pBoard[128];
+ CHAR szFen[256];
+ CHAR *p;
+ COOR cStrongKing, cWeakKing, cBishop, cPawn;
+ ULONG uWeak = FLIP(uStrong);
+ ULONG u, x;
+ FLAG fUsedRank[9];
+
+ memset(pBoard, 0, sizeof(pBoard));
+ memset(fUsedRank, 0, sizeof(fUsedRank));
+
+ do
+ {
+ cStrongKing = RANDOM_COOR;
+ }
+ while (!IS_ON_BOARD(cStrongKing));
+ do
+ {
+ cWeakKing = RANDOM_COOR;
+ }
+ while ((!IS_ON_BOARD(cWeakKing)) ||
+ (DISTANCE(cStrongKing, cWeakKing) < 2));
+ pBoard[cStrongKing] = (WHITE == uStrong) ? WHITE_KING : BLACK_KING;
+ pBoard[cWeakKing] = (WHITE == uWeak) ? WHITE_KING : BLACK_KING;
+
+ do
+ {
+ cBishop = RANDOM_COOR;
+ }
+ while ((!IS_ON_BOARD(cBishop)) || (0 != pBoard[cBishop]));
+ pBoard[cBishop] = (WHITE == uStrong) ? WHITE_BISHOP : BLACK_BISHOP;
+
+ for (u = 0; u < uPawnCount; u++)
+ {
+ ULONG uRank;
+ ULONG uTries = 0;
+ do
+ {
+ uRank = 2 + (rand() % 6); // 2..7
+ uTries++;
+ }
+ while (fUsedRank[uRank] && (uTries < 100));
+ if (fUsedRank[uRank])
+ {
+ return(FALSE); // ran out of distinct ranks on this file
+ }
+ fUsedRank[uRank] = TRUE;
+ cPawn = FILE_RANK_TO_COOR(uFile, uRank);
+ if (0 != pBoard[cPawn])
+ {
+ return(FALSE);
+ }
+ pBoard[cPawn] = (WHITE == uStrong) ? WHITE_PAWN : BLACK_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++ = (RANDOM_COLOR == WHITE) ? 'w' : 'b';
+ strcat(p, " - - 0 1");
+
+ if (FALSE == FenToPosition(pos, szFen))
+ {
+ return(FALSE);
+ }
+ if (InCheck(pos, FLIP(pos->uToMove)))
+ {
+ return(FALSE);
+ }
+ return(TRUE);
+}
+
+void
+TestEvalWrongBishopFortress(void)
+/**
+
+Routine description:
+
+ Random-sample the wrong-bishop-rook-pawn-fortress shape (KBP+ vs
+ bare K, all pawns on one rook file) and measure how often
+ SideHasWinningChances' fortress claim (uStrong has no winning
+ chances) disagrees with EGTB in the direction that matters -- EGTB
+ actually showing uStrong winning. This is a SOFT eval.c heuristic,
+ not a recogn.c hard bound, so the goal is measuring a tolerable
+ false-positive rate, not proving zero wrong.
+
+ On-demand, not part of the automatic startup suite.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ ULONG uIter, uStrong, uFile, uPawnCount;
+ SCORE iEgtb, iRecScore;
+ ULONG uClaimedFortress = 0, uChecked = 0, uSkipped = 0, uWrong = 0;
+ ULONG uReportedWrong = 0;
+ ULONG uRecognClaimed = 0, uRecognChecked = 0, uRecognSkipped = 0;
+ ULONG uRecognWrong = 0, uRecognReportedWrong = 0;
+
+ Trace("Random-sampling wrong-bishop-rook-pawn-fortress claims against "
+ "EGTB -- %u iterations...\n", WRONG_BISHOP_SAMPLE_ITERATIONS);
+ ctx = malloc(sizeof(SEARCHER_THREAD_CONTEXT));
+ if (NULL == ctx)
+ {
+ return;
+ }
+ InitializeSearcherContext(&pos, ctx);
+
+ for (uIter = 0; uIter < WRONG_BISHOP_SAMPLE_ITERATIONS; uIter++)
+ {
+ uStrong = RANDOM_COLOR;
+ uFile = (rand() & 1) ? A : H;
+ uPawnCount = 1 + (rand() % 3);
+
+ if (FALSE == _TryBuildWrongBishopPosition(&pos, uStrong, uFile,
+ uPawnCount))
+ {
+ continue;
+ }
+ ReInitializeSearcherContext(&pos, ctx);
+
+ if (FALSE == SideHasWinningChances(&pos, uStrong))
+ {
+ uClaimedFortress++;
+ if (FALSE == ProbeEGTB(ctx, &iEgtb))
+ {
+ uSkipped++;
+ continue;
+ }
+ uChecked++;
+ {
+ FLAG fStrongWins = (pos.uToMove == uStrong) ?
+ (iEgtb > 0) : (iEgtb < 0);
+ if (TRUE == fStrongWins)
+ {
+ uWrong++;
+ if (uReportedWrong < 20)
+ {
+ Trace("FALSE POSITIVE: fortress claimed for %lu but "
+ "EGTB says %lu wins (iEgtb=%d toMove=%lu)\n",
+ uStrong, uStrong, iEgtb, pos.uToMove);
+ DumpPosition(&pos);
+ uReportedWrong++;
+ }
+ }
+ }
+ }
+
+ //
+ // Same position, cross-checking recogn.c's OWN pre-existing
+ // wrong-bishop hard bound (_RecognizeKBKP's bare-weak-king
+ // branch, a flat DISTANCE<=1 check with NO tempo credit --
+ // written before this session's tempo-boundary fix to the
+ // eval.c copy and never itself re-verified against EGTB in
+ // this investigation) via the real RecognLookup dispatch, not
+ // a direct static call -- exercises the actual material-
+ // signature routing a real search would use.
+ //
+ {
+ ULONG uVal = RecognLookup(ctx, &iRecScore, FALSE);
+ if (UNRECOGNIZED != uVal)
+ {
+ uRecognClaimed++;
+ if (TRUE == ProbeEGTB(ctx, &iEgtb))
+ {
+ uRecognChecked++;
+ if (FALSE == _RecognizerAgreesWithEGTB(iRecScore, uVal,
+ iEgtb))
+ {
+ uRecognWrong++;
+ if (uRecognReportedWrong < 300)
+ {
+ Trace("recogn.c MISMATCH: iRecScore=%d uVal=%lu "
+ "iEgtb=%d toMove=%lu\n",
+ iRecScore, uVal, iEgtb, pos.uToMove);
+ DumpPosition(&pos);
+ uRecognReportedWrong++;
+ }
+ }
+ }
+ else
+ {
+ uRecognSkipped++;
+ }
+ }
+ }
+ }
+
+ free(ctx);
+ Trace("recogn.c's existing _RecognizeKBKP wrong-bishop hard bound: "
+ "%lu claimed, %lu checked against EGTB, %lu skipped (no "
+ "coverage), %lu WRONG.\n",
+ uRecognClaimed, uRecognChecked, uRecognSkipped, uRecognWrong);
+ Trace("Wrong-bishop fortress: %u iterations, %lu claimed fortress, "
+ "%lu checked against EGTB, %lu skipped (no coverage), "
+ "%lu WRONG (false positives).\n",
+ WRONG_BISHOP_SAMPLE_ITERATIONS, uClaimedFortress, uChecked,
+ uSkipped, uWrong);
+ if (uChecked > 0)
+ {
+ Trace(" False-positive rate: %.2f%%\n",
+ 100.0 * (double)uWrong / (double)uChecked);
+ }
+}
+
#endif // TEST