summaryrefslogtreecommitdiff
path: root/src/testsearch.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-16 00:08:27 -0700
committerScott Gasch <[email protected]>2026-09-16 00:08:27 -0700
commitb91c8453f3fe21c8ccd3568923d8e39141c9c945 (patch)
treeb908fea282ecd4075744590d3dfc88ffa903d943 /src/testsearch.c
parentc0ac3d7845f60c7f6f2c3bfc306ceb0e4ac045ba (diff)
Add search self-tests (hash consistency, QSearch sanity) and a native EPD parser (`epd` command + ParseEpdRecord), fixing a silent-corruption bug in convert_epd.pl that broke every Black-to-move EPD conversion.
testsearch.c: TestSearchHashConsistency exercises the hash table through real search (repeat-with-warm-table and shallow-then-deep agreement) rather than synthetic store/probe calls. TestQSearchSanity checks termination and a node-count ceiling; its own header documents that the TEST=1 self-test build doesn't define PERF_COUNTERS, so it checks whole-search node count rather than the qsearch-only counter. A planned null-move zugzwang test was dropped in favor of tests/zugzwang.epd (best-move outcome, not an internal counter) after the three classic literature positions turned out not to structurally reach this engine's verification-search code path on this build. fen.c/script.c/command.c: new `epd <file>` command reads raw EPD directly via ParseEpdRecord, reusing ScriptCommand's existing suite-reporting machinery (factored into _ProcessSuiteLine/_PrintSuiteReport). Replaces the need for tests/convert_epd.pl, which had a real bug: its bm/am regex's [^b]+ filler breaks on any Black-to-move record (side-to-move field is literally "b"), and unscoped $fen/$id/$solution globals meant a failed match silently duplicated the previous record instead of erroring. tests/ep_to_epd.pl converts the existing setboard/solution/avoid/id/go suites back to real EPD; cross-checked ecm_ringers.ep_ via `script` against ecm_ringers.epd via `epd` -- byte-identical node counts and solve stats. Added .epd siblings for every genuine tactical suite in tests/. Added src/TESTING_PLAN.md: full audit of test*.c coverage gaps (dead SEE oracle, unwired eval/recognizer tests, zero hash-table correctness coverage, thin search self-tests) with a prioritized plan for closing them. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/testsearch.c')
-rw-r--r--src/testsearch.c298
1 files changed, 298 insertions, 0 deletions
diff --git a/src/testsearch.c b/src/testsearch.c
index 3d560ac..6b43f64 100644
--- a/src/testsearch.c
+++ b/src/testsearch.c
@@ -131,4 +131,302 @@ TestSearch(void)
SystemFreeMemory(ctx);
return(fRet);
}
+
+
+//
+// Shared setup for the hash-consistency and zugzwang tests below: run
+// one fixed-depth search on a fresh SEARCHER_THREAD_CONTEXT-relative
+// position (ctx is caller-owned and reused across calls, matching
+// TestSearch's own pattern -- SystemAllocateMemory'ing a fresh 75MB
+// context per call would be wasteful for tests that need many runs
+// over the same handful of positions) and return the root score.
+// Does NOT touch the hash table itself (no Clear/Dirty call) -- that
+// decision is the caller's, since the whole point of the hash tests
+// below is controlling exactly what's in the table before each call.
+//
+static SCORE
+_FixedDepthSearchScore(SEARCHER_THREAD_CONTEXT *ctx,
+ POSITION *pos,
+ ULONG uMaxDepth)
+{
+ GAME_RESULT result;
+ FLAG fPost = g_Options.fShouldPost;
+
+ g_Options.fShouldPost = FALSE;
+ InitializeSearcherContext(pos, ctx);
+ g_MoveTimer.bvFlags = 0;
+ g_Options.fPondering = FALSE;
+ g_Options.fThinking = TRUE;
+ g_Options.fSuccessfulPonder = FALSE;
+ MaintainDynamicMoveOrdering();
+ SetMoveTimerForTestingSearch();
+ g_Options.uMaxDepth = uMaxDepth;
+#if (PERF_COUNTERS && MP)
+ ClearHelperThreadIdleness();
+#endif
+ result = Iterate(ctx);
+ g_Options.fShouldPost = fPost;
+ if (RESULT_IN_PROGRESS != result.eResult)
+ {
+ // Mate/stalemate/draw at the root -- no root score was set by
+ // this iteration (there's no move to score). Not a bug, just
+ // not a comparable data point for the callers below.
+ return(INVALID_SCORE);
+ }
+ return(ctx->iRootScore);
+}
+
+
+//
+// Hash-table correctness, exercised through real search rather than
+// synthetic store/probe calls -- the actual bug class this is meant
+// to catch (stale entries surviving a generation boundary, a bound
+// getting misapplied, ADJUST_MATE_IN_N misfiring on an entry that
+// shouldn't get it) only shows up when a search's outcome actually
+// depends on what it reads back out of the table, which a
+// hand-constructed store()-then-probe() unit test can't exercise --
+// see TESTING_PLAN.md's "hash correctness" section for the case for a
+// synthetic store/probe test too; this is the complementary
+// behavioral half, not a replacement for it.
+//
+// Two invariants, neither of which cares about search internals
+// (move ordering, PV, node count) changing across future search work
+// -- only that the table is *transparent*, i.e. its presence changes
+// performance, never the answer:
+//
+// 1. Searching the same position to the same depth twice in a row
+// (second time with the table already populated by the first)
+// must produce the same score both times.
+// 2. Searching to depth D2 with the table pre-populated by a
+// shallower depth-D1 search of the same position must produce
+// the same score as searching directly to D2 from a freshly
+// cleared table. A depth-D1 entry read back during the depth-D2
+// search is only ever a bound, never trusted as exact at a
+// higher depth than it was stored at -- if it were, this would
+// catch it.
+//
+FLAG
+TestSearchHashConsistency(void)
+{
+ static const char *rgszFen[] =
+ {
+ "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
+ "r1bq1rk1/pp2bppp/2n1pn2/2pp4/3P4/2NBPN2/PP3PPP/R1BQ1RK1 w - - 0 1",
+ "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
+ "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1",
+ };
+ const ULONG uD1 = 4;
+ const ULONG uD2 = 6;
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ SCORE iFirst, iSecond, iShallow, iDirect;
+ ULONG u;
+ FLAG fRet = TRUE;
+
+ Trace("Testing hash table consistency through search...\n");
+ ctx = SystemAllocateMemory(sizeof(SEARCHER_THREAD_CONTEXT));
+ ASSERT(ctx);
+
+ for (u = 0; u < ARRAY_LENGTH(rgszFen); u++)
+ {
+ if (FALSE == FenToPosition(&pos, (char *)rgszFen[u]))
+ {
+ UtilPanic(INCONSISTENT_STATE,
+ NULL, NULL, NULL, NULL,
+ __FILE__, __LINE__);
+ }
+
+ // Invariant 1: repeat-with-warm-table stability.
+ ClearHashTable();
+ DirtyHashTable();
+ iFirst = _FixedDepthSearchScore(ctx, &pos, uD1);
+ DirtyHashTable();
+ iSecond = _FixedDepthSearchScore(ctx, &pos, uD1);
+ if ((INVALID_SCORE != iFirst) &&
+ (INVALID_SCORE != iSecond) &&
+ (iFirst != iSecond))
+ {
+ Trace("TestSearchHashConsistency: position %lu: same-depth "
+ "rerun with warm hash table diverged (%d vs %d) -- "
+ "FEN: %s\n", u, iFirst, iSecond, rgszFen[u]);
+ fRet = FALSE;
+ }
+
+ // Invariant 2: a shallower pre-population must not corrupt a
+ // deeper search's answer.
+ ClearHashTable();
+ DirtyHashTable();
+ iShallow = _FixedDepthSearchScore(ctx, &pos, uD1);
+ DirtyHashTable();
+ iSecond = _FixedDepthSearchScore(ctx, &pos, uD2);
+
+ ClearHashTable();
+ DirtyHashTable();
+ iDirect = _FixedDepthSearchScore(ctx, &pos, uD2);
+
+ if ((INVALID_SCORE != iSecond) &&
+ (INVALID_SCORE != iDirect) &&
+ (iSecond != iDirect))
+ {
+ Trace("TestSearchHashConsistency: position %lu: depth-%lu "
+ "search seeded by a depth-%lu warm-up (%d) disagreed "
+ "with a from-scratch depth-%lu search (%d) -- FEN: "
+ "%s\n", u, uD2, uD1, iSecond, uD2, iDirect,
+ rgszFen[u]);
+ fRet = FALSE;
+ }
+ (void)iShallow;
+ }
+
+ if (FALSE == fRet)
+ {
+ UtilPanic(TESTCASE_FAILURE,
+ NULL, "TestSearchHashConsistency", NULL, NULL,
+ __FILE__, __LINE__);
+ }
+ ClearHashTable();
+ SystemFreeMemory(ctx);
+ return(fRet);
+}
+
+
+// Null-move soundness is intentionally NOT tested here as a C unit
+// test. An earlier version of this file asserted against
+// ctx->sCounters.extension.uZugzwang (searchsup.c's verification-
+// search counter) using the three classic "verified null-move
+// pruning" zugzwang positions -- but on this codebase's own material
+// gates (WeShouldTryNullmovePruning's `> 2` non-pawn-count guard,
+// TryNullmovePruning's `< 4` verification window) and this box's
+// Syzygy coverage, none of the three ever actually reached the
+// mechanism: two have too little material for null-move pruning to
+// be attempted at all, and the third gets resolved directly off the
+// local tablebase before search ever touches null-move logic. That
+// made the internal-counter approach fragile and environment-
+// dependent in a way a real best-move suite isn't. See
+// tests/zugzwang.epd (run with the `epd` command, e.g. `epd
+// tests/zugzwang.epd`) instead -- five known zugzwang positions
+// checked by best-move outcome, which doesn't care why the engine
+// gets it right, just whether it does.
+//
+
+//
+// QSearch is meant to stay small and shallow (search.c's own
+// ASSERT(pf->uQsearchNodes < 20000) at its only call site is a
+// no-op in a release build -- this makes a version of that bound a
+// real, always-checked test instead of a DEBUG-only assertion).
+//
+// Note this checks ctx->sCounters.tree.u64TotalNodeCount (whole
+// search, root.c's own unconditional per-node ++), not
+// u64QNodeCount specifically -- that counter, and every other field
+// in sCounters, is wrapped in the INC() macro (chess.h), which
+// compiles to nothing unless PERF_COUNTERS is defined, and this
+// self-test suite is built with plain `gmake TEST=1` (see CLAUDE.md),
+// which doesn't define it (confirmed live: a real `sd 3; go` on this
+// TEST-only binary reports "0 qnodes" no matter how much qsearch
+// activity actually happened). A whole-search bound is a strictly
+// weaker signal than a qsearch-only one -- it wouldn't distinguish
+// "qsearch exploded" from "full-width search exploded" -- but it's
+// the honest thing this build can actually check; rebuilding with
+// PERF_COUNTERS added would be needed to test u64QNodeCount for real.
+//
+// "Small" is also exactly what makes qsearch trees hand-auditable in
+// principle: a person could Trace() every node visited on one of the
+// positions below and read the resulting tree start to finish against
+// the board, unlike a full alpha-beta tree at real search depth. No
+// such tracing is wired up here (that would need a new instrumented
+// build define, not written this session) -- noting it as a
+// worthwhile follow-up, not implying it already exists.
+//
+FLAG
+TestQSearchSanity(void)
+{
+ static const char *rgszFen[] =
+ {
+ // Quiet position -- qsearch should do almost nothing (stand
+ // pat immediately, no captures available).
+ "4k3/8/8/8/8/8/8/4K3 w - - 0 1",
+ // A short, bounded capture sequence on one square.
+ "4k3/8/8/3p4/2P5/8/8/4K3 w - - 0 1",
+ // A deeper capturing melee -- the kind of position qsearch
+ // exists for.
+ "r1bq1rk1/pp2bppp/2n1pn2/2pp4/3P4/2NBPN2/PP3PPP/R1BQ1RK1 w - - 0 1",
+ "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
+ };
+ const ULONG uDepth = 3;
+ SEARCHER_THREAD_CONTEXT *ctx;
+ POSITION pos;
+ SCORE iScore;
+ ULONG u;
+ FLAG fRet = TRUE;
+
+ Trace("Testing QSearch sanity (node-count bound, termination)...\n");
+ ctx = SystemAllocateMemory(sizeof(SEARCHER_THREAD_CONTEXT));
+ ASSERT(ctx);
+
+ for (u = 0; u < ARRAY_LENGTH(rgszFen); u++)
+ {
+ if (FALSE == FenToPosition(&pos, (char *)rgszFen[u]))
+ {
+ UtilPanic(INCONSISTENT_STATE,
+ NULL, NULL, NULL, NULL,
+ __FILE__, __LINE__);
+ }
+ // uMaxDepth == 3 plies -- enough that the last full-width ply
+ // (uDepth == 0 in Search()'s ONE_PLY units) actually falls
+ // below Search()'s THREE_QUARTERS_PLY qsearch-entry threshold
+ // and real qsearch nodes get visited (measured live: depth 1
+ // resolved every position here without qsearch ever firing --
+ // g_uQNodeCount stayed 0 -- since a two-king or short-capture
+ // position this shallow settles via CommonSearchInit's own
+ // early-outs before the qsearch dispatch is ever reached).
+ // Still small enough to trace by hand per this function's own
+ // header comment.
+ ClearHashTable();
+ DirtyHashTable();
+ iScore = _FixedDepthSearchScore(ctx, &pos, uDepth);
+
+ if ((INVALID_SCORE != iScore) &&
+ !IS_VALID_SCORE(iScore))
+ {
+ Trace("TestQSearchSanity: position %lu returned an "
+ "invalid score %d -- FEN: %s\n", u, iScore,
+ rgszFen[u]);
+ fRet = FALSE;
+ }
+ Trace("TestQSearchSanity: position %lu total nodes this "
+ "search: %llu\n", u,
+ ctx->sCounters.tree.u64TotalNodeCount);
+
+ // Not the same invariant as search.c's per-entry
+ // ASSERT(pf->uQsearchNodes < 20000) (that resets per qsearch
+ // entry point and counts qsearch nodes only; this is a
+ // whole-search cumulative total, per this function's header
+ // comment on why), but a real, always-checked (not DEBUG-only)
+ // ceiling on "a shallow fixed-depth search on a small position
+ // stays small" -- a qsearch that's stopped terminating cleanly
+ // (e.g. a check-extension/repetition interaction letting
+ // QPLIES_OF_NON_CAPTURE_CHECKS get bypassed) will blow through
+ // this by orders of magnitude, not marginally, regardless of
+ // it being mixed in with full-width nodes here.
+ if (ctx->sCounters.tree.u64TotalNodeCount > 500000)
+ {
+ Trace("TestQSearchSanity: position %lu total node count "
+ "%llu is wildly outside the expected 'small' range "
+ "for depth %lu -- FEN: %s\n", u,
+ ctx->sCounters.tree.u64TotalNodeCount, uDepth,
+ rgszFen[u]);
+ fRet = FALSE;
+ }
+ }
+
+ if (FALSE == fRet)
+ {
+ UtilPanic(TESTCASE_FAILURE,
+ NULL, "TestQSearchSanity", NULL, NULL,
+ __FILE__, __LINE__);
+ }
+ ClearHashTable();
+ SystemFreeMemory(ctx);
+ return(fRet);
+}
#endif