From 460f2935ee2148e52eb10628599533d46fd664b0 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 18 Oct 2026 11:15:00 -0500 Subject: [PATCH] Stop iterative deepening after finding winning mate at root --- CMakeLists.txt | 7 +- docs/mate-stop.md | 28 ++++++++ include/ironphoenix/search.hpp | 4 ++ src/multipv.cpp | 5 ++ src/search.cpp | 16 +++++ tests/mate_stop.cpp | 117 +++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 docs/mate-stop.md create mode 100644 tests/mate_stop.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f117c3f..328c35a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,7 +181,12 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_search_stack PRIVATE cxx_std_20) add_test(NAME ironphoenix_search_stack COMMAND ironphoenix_search_stack) - set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack PROPERTIES + add_executable(ironphoenix_mate_stop tests/mate_stop.cpp) + target_link_libraries(ironphoenix_mate_stop PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_mate_stop PRIVATE cxx_std_20) + add_test(NAME ironphoenix_mate_stop COMMAND ironphoenix_mate_stop) + + set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop PROPERTIES FOLDER "Tests" ) endif() diff --git a/docs/mate-stop.md b/docs/mate-stop.md new file mode 100644 index 0000000..80fe8c3 --- /dev/null +++ b/docs/mate-stop.md @@ -0,0 +1,28 @@ +# Stop iterative deepening on a winning mate + +After a **completed** root search iteration produces an exact winning mate +score (between `MATE_THRESHOLD` and `MATE_SCORE`) and a valid best move, +IronPhoenix emits its ordinary PV/info and then exits the depth loop to +send `bestmove` immediately. + +The guard runs **after** aspiration-window re-search is finished. Lower- and +upper-bound intermediate scores cannot trigger it. MultiPV waits for all +requested lines of the completed depth to be emitted first. + +* **Winning mate:** stop early after a completed iteration. +* **Losing mate:** keep searching for defenses / a way to delay defeat. +* **`go infinite`:** keep analysing until an explicit `stop`. +* **Active `go ponder`:** keep thinking until `ponderhit` or `stop`; + after a hit, normal early-exit behavior applies. +* **No valid root move:** do not trigger the shortcut. + +This changes search *duration* only, not the mate scoring, pruning rules, +transposition table logic, or evaluation. + +Regression test: `tests/mate_stop.cpp` checks the predicate and searches +a 4PC Teams position with a winning king capture at depth one in normal and +MultiPV modes; the search should report `score mate 1`, emit `bestmove`, +and not deepen to depth two. + +A mate-score-based early exit is still only as reliable as the engine's +mate detection and evaluation; this does not independently prove a checkmate. diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index 3cc0044..6c65d6b 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -65,6 +65,9 @@ namespace ironphoenix { void newGame() noexcept; private: + // Exposed only to the mate-stop regression test. + friend struct MateStopTestAccess; + struct TimeBudget final { std::int64_t minimumMs = 0; std::int64_t softMs = 0; @@ -126,6 +129,7 @@ namespace ironphoenix { [[nodiscard]] bool shouldStop() noexcept; [[nodiscard]] bool iterationTimeExpired(int stableDepths, bool bestMoveChanged, int scoreDelta) noexcept; + [[nodiscard]] bool shouldStopOnWinningMate(int score, Move rootMove) const noexcept; void processPonderHit() noexcept; [[nodiscard]] std::int64_t elapsedMs() const noexcept; [[nodiscard]] std::int64_t budgetElapsedMs() const noexcept; diff --git a/src/multipv.cpp b/src/multipv.cpp index 5bbaa90..428c26f 100644 --- a/src/multipv.cpp +++ b/src/multipv.cpp @@ -277,6 +277,11 @@ void SearchEngine::runMultiPV(Position position, int multiPV, std::ostream& out) ); } + // MultiPV must complete and report every requested line at this depth + // before exiting on a winning mate in the top-ranked line. + if (shouldStopOnWinningMate(bestScore, bestMove)) + break; + if (requestedDepth > 0 && depth == requestedDepth) break; if (limits_.nodes > 0 && nodes_ >= limits_.nodes) diff --git a/src/search.cpp b/src/search.cpp index 4aa240c..eb06cf1 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -311,6 +311,16 @@ namespace ironphoenix { return budgetElapsedMs() >= target; } + bool SearchEngine::shouldStopOnWinningMate(int score, Move rootMove) const noexcept { + // Only a completed root search with an actual move and a positive + // mate score may terminate iterative deepening. A losing mate should + // keep searching for defenses and for ways to delay defeat. + // UCI infinite analysis and active pondering must wait for stop / + // ponderhit respectively, even when a mate is available. + return !limits_.infinite && !pondering_ && static_cast(rootMove) + && score >= MATE_THRESHOLD && score <= MATE_SCORE; + } + int SearchEngine::evaluate(const Position& pos) const noexcept { return Eval::evaluate(pos); } @@ -1238,6 +1248,12 @@ namespace ironphoenix { pv_[0].data(), pvLength_[0] ); + + // This is a completed root iteration (not an aspiration bound). + // Return the proven winning mate immediately; do not spend + // additional time deepening an already decisive move. + if (shouldStopOnWinningMate(bestScore, bestMove)) + break; } if (requestedDepth > 0 && depth == requestedDepth) diff --git a/tests/mate_stop.cpp b/tests/mate_stop.cpp new file mode 100644 index 0000000..86e1b32 --- /dev/null +++ b/tests/mate_stop.cpp @@ -0,0 +1,117 @@ +#include "ironphoenix/search.hpp" +#include "ironphoenix/movegen.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace ironphoenix; + +namespace ironphoenix { +struct MateStopTestAccess { + static bool shouldStop(SearchEngine& engine, int score, Move move, + bool infinite = false, bool pondering = false) { + engine.limits_ = SearchLimits{}; + engine.limits_.infinite = infinite; + engine.pondering_ = pondering; + return engine.shouldStopOnWinningMate(score, move); + } +}; +} // namespace ironphoenix + +namespace { +int failures = 0; + +void expect(bool ok, const char* message) { + if (!ok) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } +} + +Square sq(char file, int rank) { + return Geometry::square(file - 'a', rank - 1); +} + +Position immediateTeamMate() { + Position pos; + pos.clear(); + pos.placePiece(sq('g', 1), makePiece(RED, KING)); + pos.placePiece(sq('a', 7), makePiece(BLUE, KING)); + pos.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + pos.placePiece(sq('n', 7), makePiece(GREEN, KING)); + // From d7, RED can capture BLUE's king on a7 immediately. + pos.placePiece(sq('d', 7), makePiece(RED, ROOK)); + pos.setSideToMove(RED); + pos.finalizeSetup(); + return pos; +} + +bool waitForCompletion(SearchEngine& engine) { + for (int i = 0; i < 400; ++i) { + if (!engine.searching()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return !engine.searching(); +} + +void testRootMate(bool multiPV) { + Position pos = immediateTeamMate(); + const Key original = pos.key(); + MoveList legal; + generateLegalMoves(pos, legal); + bool terminalAvailable = false; + for (Move move : legal) + if (isTerminalKingCapture(pos, move)) + terminalAvailable = true; + expect(terminalAvailable, "fixture has legal immediate enemy king capture"); + + auto engine = std::make_unique(); + SearchLimits limits; + limits.depth = 10; // Mate in one should terminate at depth one, not ten. + std::ostringstream output; + if (multiPV) + engine->startMultiPV(pos, limits, 2, output); + else + engine->start(pos, limits, output); + + const bool completed = waitForCompletion(*engine); + engine->stopAndWait(); + const std::string text = output.str(); + expect(completed, multiPV ? "MultiPV returned promptly on mate" : "root search returned promptly on mate"); + expect(text.find("score mate 1") != std::string::npos, "engine reports winning mate in one"); + expect(text.find("bestmove d7a7") != std::string::npos, "engine returns king-capturing move"); + expect(text.find("info depth 2 ") == std::string::npos, "no further deepening after winning mate"); + if (multiPV) { + expect(text.find("multipv 1 ") != std::string::npos, "MultiPV top line emitted"); + expect(text.find("multipv 2 ") != std::string::npos, "MultiPV second line emitted before stop"); + } + expect(pos.key() == original && pos.verify(), "search did not modify caller's position"); +} +} // namespace + +int main() { + Geometry::init(); + auto engine = std::make_unique(); + const Move move(sq('d', 7), sq('a', 7)); + + expect(MateStopTestAccess::shouldStop(*engine, 29999, move), "winning mate causes stop"); + expect(MateStopTestAccess::shouldStop(*engine, 29000, move), "valid long mate score causes stop"); + expect(!MateStopTestAccess::shouldStop(*engine, -29999, move), "losing mate keeps searching"); + expect(!MateStopTestAccess::shouldStop(*engine, 1500, move), "centipawn score keeps searching"); + expect(!MateStopTestAccess::shouldStop(*engine, 30001, move), "invalid over-mate score ignored"); + expect(!MateStopTestAccess::shouldStop(*engine, 29999, Move{}), "missing root move cannot stop"); + expect(!MateStopTestAccess::shouldStop(*engine, 29999, move, true), "go infinite waits for stop"); + expect(!MateStopTestAccess::shouldStop(*engine, 29999, move, false, true), "ponder waits for ponderhit"); + + testRootMate(false); + testRootMate(true); + + if (!failures) + std::cout << "Mate-stop regression tests: PASS\n"; + return failures ? 1 : 0; +}