summaryrefslogtreecommitdiff
path: root/src/testrecogn.c
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/testrecogn.c
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/testrecogn.c')
-rw-r--r--src/testrecogn.c1175
1 files changed, 1175 insertions, 0 deletions
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