Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions docs/mate-stop.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions include/ironphoenix/search.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/multipv.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions src/search.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(rootMove)
&& score >= MATE_THRESHOLD && score <= MATE_SCORE;
}

int SearchEngine::evaluate(const Position& pos) const noexcept {
return Eval::evaluate(pos);
}
Expand Down Expand Up @@ -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)
Expand Down
117 changes: 117 additions & 0 deletions tests/mate_stop.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#include "ironphoenix/search.hpp"
#include "ironphoenix/movegen.hpp"

#include <chrono>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <thread>

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<SearchEngine>();
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<SearchEngine>();
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;
}
Loading