From b04fc05ba2e623e225be0c6dcf2ffec53dc276d5 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 00:28:19 +0200 Subject: [PATCH 01/18] save - adding time controls --- include/bitbishop/interface/search_worker.hpp | 20 +++- include/bitbishop/tools/stop_timer.hpp | 55 +++++++++ src/bitbishop/interface/search_worker.cpp | 106 ++++++++++++++---- .../interface/test_search_limits.cpp | 33 ++++++ .../interface/test_search_worker.cpp | 21 ++++ tests/bitbishop/interface/test_uci_engine.cpp | 6 + 6 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 include/bitbishop/tools/stop_timer.hpp diff --git a/include/bitbishop/interface/search_worker.hpp b/include/bitbishop/interface/search_worker.hpp index 23c7eb28..ab2d5048 100644 --- a/include/bitbishop/interface/search_worker.hpp +++ b/include/bitbishop/interface/search_worker.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include #include @@ -30,6 +30,22 @@ struct SearchLimits { * @return The built SearchLimits object. */ static SearchLimits from_uci_cmd(const std::vector& line); + + /** + * @brief Returns whether any time-based search limit was provided. + */ + [[nodiscard]] bool has_time_limit() const; + + /** + * @brief Estimates how much time the current side should spend on this move. + * + * `movetime` takes precedence when present. Otherwise the estimate is derived + * from the side-to-move remaining clock and increment. + * + * @param side_to_move Side that is currently searching. + * @return Think time budget in milliseconds, or std::nullopt if no clock is available. + */ + [[nodiscard]] std::optional think_time_ms(Color side_to_move) const; }; /** @@ -86,7 +102,7 @@ class SearchWorker { * @brief Starts the search process with given parameters. * * Initializes the search controller with a new worker thread and parameters. - * If no depth is specified, switches to infinite search mode. + * If no search limit is specified, switches to infinite search mode. */ void start(); diff --git a/include/bitbishop/tools/stop_timer.hpp b/include/bitbishop/tools/stop_timer.hpp new file mode 100644 index 00000000..778ca5cb --- /dev/null +++ b/include/bitbishop/tools/stop_timer.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Tools { + +/** + * @brief Sets a stop flag once a timeout expires unless cancelled first. + * + * The timer starts immediately on construction. Destroying the instance + * cancels the pending timeout and joins the worker thread automatically. + */ +class StopTimer { + public: + StopTimer(std::atomic& stop_flag, std::chrono::steady_clock::duration timeout) : stop_flag(stop_flag) { + worker = std::thread([this, timeout] { + std::unique_lock lock(mutex); + const bool interrupted = cv.wait_for(lock, timeout, [this] { return cancelled || this->stop_flag.load(); }); + if (!interrupted) { + this->stop_flag.store(true); + } + }); + } + + ~StopTimer() { cancel(); } + + StopTimer(const StopTimer&) = delete; + StopTimer& operator=(const StopTimer&) = delete; + StopTimer(StopTimer&&) = delete; + StopTimer& operator=(StopTimer&&) = delete; + + private: + void cancel() { + { + std::lock_guard lock(mutex); + cancelled = true; + } + cv.notify_one(); + if (worker.joinable()) { + worker.join(); + } + } + + std::atomic& stop_flag; + std::mutex mutex; + std::condition_variable cv; + bool cancelled = false; + std::thread worker; +}; + +} // namespace Tools diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index 414be7dc..4e508927 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,4 +1,32 @@ #include +#include + +#include +#include + +namespace { + +constexpr int DEFAULT_MOVES_TO_GO = 20; +constexpr int MIN_THINK_TIME_MS = 1; +constexpr int SAFETY_BUFFER_MS = 50; + +[[nodiscard]] int estimate_clock_think_time_ms(int remaining_ms, int increment_ms) { + remaining_ms = std::max(remaining_ms, 0); + increment_ms = std::max(increment_ms, 0); + + if (remaining_ms <= MIN_THINK_TIME_MS) { + return MIN_THINK_TIME_MS; + } + + const int reserve_ms = std::min(remaining_ms - MIN_THINK_TIME_MS, std::max(SAFETY_BUFFER_MS, remaining_ms / 20)); + const int spendable_ms = std::max(MIN_THINK_TIME_MS, remaining_ms - reserve_ms); + const int base_ms = spendable_ms / DEFAULT_MOVES_TO_GO; + const int increment_bonus_ms = (increment_ms * 3) / 4; + + return std::clamp(base_ms + increment_bonus_ms, MIN_THINK_TIME_MS, spendable_ms); +} + +} // namespace Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector& line) { SearchLimits limits; @@ -29,14 +57,31 @@ Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector } } - const bool has_time_control = limits.movetime || limits.wtime || limits.btime || limits.winc || limits.binc; - if (!limits.depth && !has_time_control && !limits.infinite) { + if (!limits.depth && !limits.has_time_limit() && !limits.infinite) { limits.infinite = true; } return limits; } +bool Uci::SearchLimits::has_time_limit() const { + return movetime.has_value() || wtime.has_value() || btime.has_value() || winc.has_value() || binc.has_value(); +} + +std::optional Uci::SearchLimits::think_time_ms(Color side_to_move) const { + if (movetime.has_value()) { + return std::max(*movetime, MIN_THINK_TIME_MS); + } + + const std::optional& remaining_opt = (side_to_move == Color::WHITE) ? wtime : btime; + if (!remaining_opt.has_value()) { + return std::nullopt; + } + + const std::optional& increment_opt = (side_to_move == Color::WHITE) ? winc : binc; + return estimate_clock_think_time_ms(*remaining_opt, increment_opt.value_or(0)); +} + Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits) : board(board), position(Position(this->board)), limits(limits) {} @@ -57,37 +102,54 @@ void Uci::SearchWorker::run() { SearchStats stats{}; BestMove best; BestMove last_best; + int last_completed_depth = 0; + int last_attempted_depth = 0; + + const std::optional think_time_ms = + limits.infinite ? std::nullopt : limits.think_time_ms(board.get_side_to_move()); + std::optional deadline; + if (think_time_ms.has_value()) { + deadline.emplace(stop_flag, std::chrono::milliseconds(*think_time_ms)); + } + + auto publish_iteration = [&](const BestMove& completed_best, int depth) { + last_best = completed_best; + last_completed_depth = depth; + push_report(SearchReport{ + .kind = SearchReportKind::Iteration, + .best = last_best, + .depth = depth, + .stats = stats, + }); + }; + + auto search_depth = [&](int depth) { + last_attempted_depth = depth; + best = negamax(position, depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); + if (!stop_flag.load()) { + publish_iteration(best, depth); + } + }; if (limits.infinite) { for (int current_depth = 1; !stop_flag.load(); ++current_depth) { - best = negamax(position, current_depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); - if (!stop_flag.load()) { - last_best = best; - push_report(SearchReport{ - .kind = SearchReportKind::Iteration, - .best = last_best, - .depth = current_depth, - .stats = stats, - }); - } + search_depth(current_depth); } - } else if (limits.depth) { - best = negamax(position, *limits.depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); - if (!stop_flag.load()) { - push_report(SearchReport{ - .kind = SearchReportKind::Iteration, - .best = best, - .depth = *limits.depth, - .stats = stats, - }); + } else if (think_time_ms.has_value()) { + const int max_depth = limits.depth.value_or(std::numeric_limits::max()); + for (int current_depth = 1; current_depth <= max_depth && !stop_flag.load(); ++current_depth) { + search_depth(current_depth); } + } else if (limits.depth) { + search_depth(*limits.depth); } const BestMove& final = (last_best.move) ? last_best : best; + const int final_depth = (last_completed_depth > 0) ? last_completed_depth : last_attempted_depth; push_report(SearchReport{ .kind = SearchReportKind::Finish, .best = final, - .depth = limits.depth.value_or(0), + .depth = final_depth, .stats = stats, }); } diff --git a/tests/bitbishop/interface/test_search_limits.cpp b/tests/bitbishop/interface/test_search_limits.cpp index b2f94ab6..ece882f4 100644 --- a/tests/bitbishop/interface/test_search_limits.cpp +++ b/tests/bitbishop/interface/test_search_limits.cpp @@ -14,6 +14,39 @@ TEST(SearchLimitsTest, DefaultsAreEmptyAndNotInfinite) { EXPECT_FALSE(limits.infinite); } +TEST(SearchLimitsTest, HasTimeLimitDetectsAnyClockField) { + Uci::SearchLimits limits; + EXPECT_FALSE(limits.has_time_limit()); + + limits.wtime = 10'000; + EXPECT_TRUE(limits.has_time_limit()); +} + +TEST(SearchLimitsTest, ThinkTimeUsesMovetimeWhenProvided) { + Uci::SearchLimits limits{ + .movetime = 750, + .wtime = 10'000, + .btime = 8'000, + .winc = 100, + .binc = 200, + }; + + EXPECT_EQ(limits.think_time_ms(Color::WHITE), 750); + EXPECT_EQ(limits.think_time_ms(Color::BLACK), 750); +} + +TEST(SearchLimitsTest, ThinkTimeUsesSideSpecificClockAndIncrement) { + Uci::SearchLimits limits{ + .wtime = 10'000, + .btime = 8'000, + .winc = 100, + .binc = 200, + }; + + EXPECT_EQ(limits.think_time_ms(Color::WHITE), 550); + EXPECT_EQ(limits.think_time_ms(Color::BLACK), 530); +} + struct SearchLimitsFromUciTestCase { std::string test_name; std::vector command_line; diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index 96aa55c3..49e11c25 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -41,3 +41,24 @@ TEST(SearchControllerTest, StartPublishesFinishReportWithInfiniteSearch) { return report.kind == Uci::SearchReportKind::Iteration; })); } + +TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { + Board board = Board::StartingPosition(); + Uci::SearchLimits limits; + limits.movetime = 50; + + Uci::SearchWorker controller(board, limits); + const auto start = std::chrono::steady_clock::now(); + controller.start(); + controller.wait(); + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - start); + + const auto reports = controller.drain_reports(); + ASSERT_FALSE(reports.empty()); + EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); + EXPECT_TRUE(reports.back().best.move.has_value()); + EXPECT_LT(elapsed.count(), 1'000); + EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { + return report.kind == Uci::SearchReportKind::Iteration; + })); +} diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index 6d3a4347..7ef8cf28 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -283,6 +283,12 @@ TEST_F(UciEngineTest, GoWithoutPositionProducesBestMove) { assert_output_contains(output, "bestmove "); } +TEST_F(UciEngineTest, GoMovetimeProducesBestMove) { + input.write("go movetime 50\n"); + + assert_output_contains(output, "bestmove "); +} + TEST_F(UciEngineTest, UnknownCommandProducesNoOutput) { // Clear the output containing the startup message. assert_output_contains(output, " by "); From 98fc72039c1259042e414949f8a2720ccb00a101 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 10:05:03 +0200 Subject: [PATCH 02/18] refactored and renamed time guard class --- include/bitbishop/tools/stop_timer.hpp | 55 ----------------- include/bitbishop/tools/time_guard.hpp | 72 +++++++++++++++++++++++ src/bitbishop/interface/search_worker.cpp | 16 ++--- src/bitbishop/tools/time_guard.cpp | 34 +++++++++++ 4 files changed, 111 insertions(+), 66 deletions(-) delete mode 100644 include/bitbishop/tools/stop_timer.hpp create mode 100644 include/bitbishop/tools/time_guard.hpp create mode 100644 src/bitbishop/tools/time_guard.cpp diff --git a/include/bitbishop/tools/stop_timer.hpp b/include/bitbishop/tools/stop_timer.hpp deleted file mode 100644 index 778ca5cb..00000000 --- a/include/bitbishop/tools/stop_timer.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -namespace Tools { - -/** - * @brief Sets a stop flag once a timeout expires unless cancelled first. - * - * The timer starts immediately on construction. Destroying the instance - * cancels the pending timeout and joins the worker thread automatically. - */ -class StopTimer { - public: - StopTimer(std::atomic& stop_flag, std::chrono::steady_clock::duration timeout) : stop_flag(stop_flag) { - worker = std::thread([this, timeout] { - std::unique_lock lock(mutex); - const bool interrupted = cv.wait_for(lock, timeout, [this] { return cancelled || this->stop_flag.load(); }); - if (!interrupted) { - this->stop_flag.store(true); - } - }); - } - - ~StopTimer() { cancel(); } - - StopTimer(const StopTimer&) = delete; - StopTimer& operator=(const StopTimer&) = delete; - StopTimer(StopTimer&&) = delete; - StopTimer& operator=(StopTimer&&) = delete; - - private: - void cancel() { - { - std::lock_guard lock(mutex); - cancelled = true; - } - cv.notify_one(); - if (worker.joinable()) { - worker.join(); - } - } - - std::atomic& stop_flag; - std::mutex mutex; - std::condition_variable cv; - bool cancelled = false; - std::thread worker; -}; - -} // namespace Tools diff --git a/include/bitbishop/tools/time_guard.hpp b/include/bitbishop/tools/time_guard.hpp new file mode 100644 index 00000000..13f13dc9 --- /dev/null +++ b/include/bitbishop/tools/time_guard.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Tools { + +/** + * @class TimeGuard + * @brief An RAII-based asynchronous countdown timer used to signal a timeout via an atomic flag. + * + * This class spawns a background thread that waits for a specified duration. If the duration + * elapses without the object being destroyed or cancelled, it sets the provided @p stop_flag to true. + * + * @note This is designed for search algorithms where a search must + * be terminated after a certain amount of time. + */ +class TimeGuard { + public: + /** + * @brief Constructs the timer and immediately starts the countdown in a background thread. + * + * @param stop_flag Reference to an atomic boolean. This flag is monitored for external + * cancellation and is set to @c true by this class upon timeout. + * @param timeout The maximum duration to wait before setting the flag. + */ + TimeGuard(std::atomic& stop_flag, std::chrono::steady_clock::duration timeout); + + /** + * @brief Destructor. Safely cancels the timer and joins the background thread. + * + * This ensures that no "zombie" threads are left running. If the timer has not yet + * expired, it is signaled to wake up and exit immediately. + */ + ~TimeGuard(); + + // Deleted copy and move operations to prevent accidental thread ownership issues. + TimeGuard(const TimeGuard&) = delete; + TimeGuard& operator=(const TimeGuard&) = delete; + TimeGuard(TimeGuard&&) = delete; + TimeGuard& operator=(TimeGuard&&) = delete; + + private: + /** + * @brief The main loop for the worker thread. + * + * Uses a @c std::condition_variable::wait_for to sleep efficiently. It wakes up if: + * 1. The @p timeout expires (sets @p stop_flag to @c true). + * 2. The @p cancelled flag is set via the destructor/cancel(). + * 3. The @p stop_flag is set to @c true by external logic. + */ + void start(); + + /** + * @brief Internal helper to signal the worker thread to stop and clean up resources. + * + * Thread-safe. It notifies the condition variable and joins the worker thread. + */ + void cancel(); + + std::atomic& stop_flag; + std::chrono::steady_clock::duration timeout; + std::mutex mutex; + std::condition_variable cv; + bool cancelled = false; + std::thread worker; +}; + +} // namespace Tools diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index 4e508927..046852ab 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,7 +1,6 @@ -#include -#include - #include +#include +#include #include namespace { @@ -107,9 +106,9 @@ void Uci::SearchWorker::run() { const std::optional think_time_ms = limits.infinite ? std::nullopt : limits.think_time_ms(board.get_side_to_move()); - std::optional deadline; + std::optional timeguard; if (think_time_ms.has_value()) { - deadline.emplace(stop_flag, std::chrono::milliseconds(*think_time_ms)); + timeguard.emplace(stop_flag, std::chrono::milliseconds(*think_time_ms)); } auto publish_iteration = [&](const BestMove& completed_best, int depth) { @@ -131,15 +130,10 @@ void Uci::SearchWorker::run() { } }; - if (limits.infinite) { + if (limits.infinite || think_time_ms.has_value()) { for (int current_depth = 1; !stop_flag.load(); ++current_depth) { search_depth(current_depth); } - } else if (think_time_ms.has_value()) { - const int max_depth = limits.depth.value_or(std::numeric_limits::max()); - for (int current_depth = 1; current_depth <= max_depth && !stop_flag.load(); ++current_depth) { - search_depth(current_depth); - } } else if (limits.depth) { search_depth(*limits.depth); } diff --git a/src/bitbishop/tools/time_guard.cpp b/src/bitbishop/tools/time_guard.cpp new file mode 100644 index 00000000..83b48c51 --- /dev/null +++ b/src/bitbishop/tools/time_guard.cpp @@ -0,0 +1,34 @@ +#include + +namespace Tools { + +TimeGuard::TimeGuard(std::atomic& stop_flag, std::chrono::steady_clock::duration timeout) + : stop_flag(stop_flag), timeout(timeout) { + worker = std::thread(&TimeGuard::start, this); +} + +TimeGuard::~TimeGuard() { cancel(); } + +void TimeGuard::start() { + std::unique_lock lock(mutex); + + const bool was_interrupted = cv.wait_for(lock, timeout, [this] { return cancelled || this->stop_flag.load(); }); + + if (!was_interrupted) { + this->stop_flag.store(true); + } +} + +void TimeGuard::cancel() { + { + std::lock_guard lock(mutex); + cancelled = true; + } + cv.notify_one(); + + if (worker.joinable()) { + worker.join(); + } +} + +} // namespace Tools From 757236662e7c46755faf2fd29a6929f9d8d4cb64 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 10:20:38 +0200 Subject: [PATCH 03/18] refactored search worker run function --- src/bitbishop/interface/search_worker.cpp | 67 ++++++++++------------- 1 file changed, 28 insertions(+), 39 deletions(-) diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index 046852ab..99c3aeb2 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include namespace { @@ -8,6 +9,7 @@ namespace { constexpr int DEFAULT_MOVES_TO_GO = 20; constexpr int MIN_THINK_TIME_MS = 1; constexpr int SAFETY_BUFFER_MS = 50; +constexpr int MAX_DEPTH = 50; [[nodiscard]] int estimate_clock_think_time_ms(int remaining_ms, int increment_ms) { remaining_ms = std::max(remaining_ms, 0); @@ -93,59 +95,46 @@ void Uci::SearchWorker::push_report(SearchReport report) { void Uci::SearchWorker::run() { using namespace Search; - struct FinishGuard { - std::atomic& finished_ref; - ~FinishGuard() { finished_ref.store(true); } - } guard{finished}; + + const auto guard = std::experimental::scope_exit([this] { finished.store(true); }); SearchStats stats{}; - BestMove best; - BestMove last_best; - int last_completed_depth = 0; - int last_attempted_depth = 0; + SearchReport current_best_report{.kind = SearchReportKind::Iteration}; - const std::optional think_time_ms = - limits.infinite ? std::nullopt : limits.think_time_ms(board.get_side_to_move()); + const auto side = board.get_side_to_move(); + const auto think_time = limits.infinite ? std::nullopt : limits.think_time_ms(side); std::optional timeguard; - if (think_time_ms.has_value()) { - timeguard.emplace(stop_flag, std::chrono::milliseconds(*think_time_ms)); + if (think_time) { + timeguard.emplace(stop_flag, std::chrono::milliseconds(*think_time)); } - auto publish_iteration = [&](const BestMove& completed_best, int depth) { - last_best = completed_best; - last_completed_depth = depth; - push_report(SearchReport{ - .kind = SearchReportKind::Iteration, - .best = last_best, - .depth = depth, - .stats = stats, - }); - }; + auto perform_search_at_depth = [&](int depth) { + auto result = negamax(position, depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); - auto search_depth = [&](int depth) { - last_attempted_depth = depth; - best = negamax(position, depth, ALPHA_INIT, BETA_INIT, 0, stats, &stop_flag); if (!stop_flag.load()) { - publish_iteration(best, depth); + current_best_report.best = result; + current_best_report.depth = depth; + current_best_report.stats = stats; + push_report(current_best_report); + return true; } + return false; }; - if (limits.infinite || think_time_ms.has_value()) { - for (int current_depth = 1; !stop_flag.load(); ++current_depth) { - search_depth(current_depth); + if (limits.depth && !limits.infinite && !think_time) { + // Case: Fixed depth search (e.g., "go depth 10") + perform_search_at_depth(*limits.depth); + } else { + // Case: Iterative deepening (Infinite or Time-limited) + for (int depth = 1; depth <= MAX_DEPTH && !stop_flag.load(); ++depth) { + if (!perform_search_at_depth(depth)) { + break; + } } - } else if (limits.depth) { - search_depth(*limits.depth); } - const BestMove& final = (last_best.move) ? last_best : best; - const int final_depth = (last_completed_depth > 0) ? last_completed_depth : last_attempted_depth; - push_report(SearchReport{ - .kind = SearchReportKind::Finish, - .best = final, - .depth = final_depth, - .stats = stats, - }); + current_best_report.kind = SearchReportKind::Finish; + push_report(current_best_report); } void Uci::SearchWorker::start() { From 0fd532e0a689a77e0f3c21033e6e507a0f1efcea Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 10:32:10 +0200 Subject: [PATCH 04/18] updated search worker test to monitor more closely elapsed time --- tests/bitbishop/interface/test_search_worker.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index 49e11c25..749137b3 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -57,7 +57,8 @@ TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { ASSERT_FALSE(reports.empty()); EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); - EXPECT_LT(elapsed.count(), 1'000); + EXPECT_GE(elapsed.count(), 50); + EXPECT_LT(elapsed.count(), 55); EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); From 05999eb838c543a9bde5b0f567de0e3f248f3548 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 10:58:54 +0200 Subject: [PATCH 05/18] added tests for time guard --- tests/bitbishop/tools/test_time_guard.cpp | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/bitbishop/tools/test_time_guard.cpp diff --git a/tests/bitbishop/tools/test_time_guard.cpp b/tests/bitbishop/tools/test_time_guard.cpp new file mode 100644 index 00000000..5de6e78e --- /dev/null +++ b/tests/bitbishop/tools/test_time_guard.cpp @@ -0,0 +1,71 @@ +#include + +#include + +using namespace Tools; + +/** + * Verifies the flag is set after the timer thread finishes its work. + */ +TEST(TimeGuardTest, SetsFlagOnTimeout) { + std::atomic_bool should_stop{false}; + { + // Use a small timeout for speed, but the join in the destructor + // ensures we wait until the thread is actually finished. + TimeGuard guard(should_stop, std::chrono::milliseconds(10)); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + } + + EXPECT_TRUE(should_stop); +} + +/** + * Verifies that the guard stops early if destroyed (RAII works). + * This ensures the condition_variable is actually interrupting the sleep. + */ +TEST(TimeGuardTest, DestructorJoinsQuicklyBeforeTimeout) { + std::atomic_bool should_stop{false}; + auto timeout = std::chrono::milliseconds(1'000); + auto sleep_time = std::chrono::milliseconds(10); + + auto start = std::chrono::steady_clock::now(); + { + TimeGuard guard(should_stop, timeout); + std::this_thread::sleep_for(sleep_time); + } + auto end = std::chrono::steady_clock::now(); + + auto duration = std::chrono::duration_cast(end - start); + + // Destruction should be near-instant + EXPECT_LE(duration.count(), 1.2 * sleep_time.count()); + EXPECT_FALSE(should_stop); +} + +/** + * External code sets stop flag to true, resulting in time guard interruption. + * Interruption should not alter external stop flag, already set to true. + */ +TEST(TimeGuardTest, GuardDoesNotModifyExternalFlagOnExternalBoolFlagInterruption) { + std::atomic_bool should_stop{false}; + std::chrono::milliseconds timeout(20); + { + TimeGuard guard(should_stop, timeout); + should_stop.store(true); + } + + EXPECT_TRUE(should_stop); +} + +/** + * Time guard should not alter the stop flag when still alive. + */ +TEST(TimeGuardTest, GuardDoesNotSetExternalFlagWhenStillAlive) { + std::atomic_bool should_stop{false}; + { + std::chrono::milliseconds timeout(20); + TimeGuard guard(should_stop, timeout); + + EXPECT_FALSE(should_stop); + } +} From 15093c9dbf7b9dff5d4695e30f97513e383cca64 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 11:17:48 +0200 Subject: [PATCH 06/18] fixed bug in test helper --- tests/bitbishop/helpers/async.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bitbishop/helpers/async.hpp b/tests/bitbishop/helpers/async.hpp index 185a2235..2d1b073b 100644 --- a/tests/bitbishop/helpers/async.hpp +++ b/tests/bitbishop/helpers/async.hpp @@ -40,7 +40,7 @@ void assert_output_contains(const std::stringstream& output, const std::string& void assert_output_not_contains(const std::stringstream& output, const std::string& token, std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { - ASSERT_FALSE(wait_for([&] { return output.str().find(token) == std::string::npos; })) + ASSERT_TRUE(wait_for([&] { return output.str().find(token) == std::string::npos; })) << "Expected output not to contain: " << token << "\nActual output:\n" << output.str(); } From 48b8766c88419c5ce152d8e16106e24516a7648b Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 11:18:04 +0200 Subject: [PATCH 07/18] added test cases for movetime --- tests/bitbishop/interface/test_uci_engine.cpp | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index 7ef8cf28..b6ff6cab 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -6,6 +6,7 @@ #include using namespace Squares; +using namespace std::chrono; /** * @brief GoogleTest fixture for exercising UciEngine in a realistic threaded setup. @@ -159,7 +160,7 @@ TEST_F(UciEngineTest, IsReadyBeforeUciStillWorks) { TEST_F(UciEngineTest, PositionCommandWithOneArgDoesNothing) { input.write("position\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -168,7 +169,7 @@ TEST_F(UciEngineTest, PositionCommandWithMissingFenArgDoesNothing) { // Invalid fen: missing color to play (FEN has 5 components instead of 6) input.write("position fen rnkqnbbr/pppppppp/8/8/8/8/PPPPPPPP/RNKQNBBR - - 0 1\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -177,7 +178,7 @@ TEST_F(UciEngineTest, PositionCommandWithInvalidSecondaryKeyworkDoesNothing) { // Invalid command: fren instead of fen input.write("position fren ...\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -221,7 +222,7 @@ TEST_F(UciEngineTest, PositionStartposMovesWithSpacesAppliesMoves) { TEST_F(UciEngineTest, PositionStartposWithEmptyMovesDoesNothing) { input.write("position startpos moves\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -229,7 +230,7 @@ TEST_F(UciEngineTest, PositionStartposWithEmptyMovesDoesNothing) { TEST_F(UciEngineTest, CommandWithoutNewlineDoesNotApply) { input.write("position startpos moves e2e4"); // no '\n' - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(milliseconds(50)); ASSERT_EQ(engine->get_board(), Board::StartingPosition()); } @@ -297,7 +298,7 @@ TEST_F(UciEngineTest, UnknownCommandProducesNoOutput) { input.write("this_is_not_a_uci_command\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); EXPECT_TRUE(output.str().empty()); } @@ -356,7 +357,7 @@ TEST_F(UciEngineTest, GoInfiniteThenStopProducesBestmove) { TEST_F(UciEngineTest, GoWithoutDepthIsInfinite) { input.write("go\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(milliseconds(50)); ASSERT_TRUE(output.str().find("bestmove ") == std::string::npos); } @@ -388,7 +389,7 @@ TEST_F(UciEngineTest, StopWithoutGoDoesNothing) { input.write("stop\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(milliseconds(20)); EXPECT_TRUE(output.str().empty()); } @@ -485,3 +486,35 @@ TEST_F(UciEngineTest, BenchCanBeStopped) { assert_output_contains(output, "bench nodes "); } + +TEST_F(UciEngineTest, ZeroMoveTimeUsesDefaultMinThinkTimeInstead) { + input.write("go movetime 0\n"); + + assert_output_contains(output, "bestmove ", milliseconds(10)); +} + +TEST_F(UciEngineTest, InvalidNegativeMoveTimeUsesDefaultMinThinkTimeInstead) { + input.write("go movetime -642\n"); + + assert_output_contains(output, "bestmove ", milliseconds(10)); +} + +TEST_F(UciEngineTest, MoveTimeComputesRightAmountOfTime) { + input.write("go movetime 100\n"); + + assert_output_contains(output, "bestmove ", milliseconds(110)); +} + +TEST_F(UciEngineTest, MoveTimeCanBeStoppedWhenTooLong) { + input.write( + "go movetime 1000000000\n" + "stop\n"); + + assert_output_contains(output, "bestmove ", milliseconds(10)); +} + +TEST_F(UciEngineTest, MoveTimeTooLongDoesNotDisplayResults) { + input.write("go movetime 1000000000\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} From 8d5a550fab497cb0f4676131c092faada79dd88b Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 12:05:07 +0200 Subject: [PATCH 08/18] added uci tests to cover new time controls --- tests/bitbishop/interface/test_uci_engine.cpp | 131 ++++++++++++++++-- 1 file changed, 118 insertions(+), 13 deletions(-) diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index b6ff6cab..83212a29 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -284,12 +284,6 @@ TEST_F(UciEngineTest, GoWithoutPositionProducesBestMove) { assert_output_contains(output, "bestmove "); } -TEST_F(UciEngineTest, GoMovetimeProducesBestMove) { - input.write("go movetime 50\n"); - - assert_output_contains(output, "bestmove "); -} - TEST_F(UciEngineTest, UnknownCommandProducesNoOutput) { // Clear the output containing the startup message. assert_output_contains(output, " by "); @@ -354,12 +348,12 @@ TEST_F(UciEngineTest, GoInfiniteThenStopProducesBestmove) { assert_output_contains(output, "bestmove "); } -TEST_F(UciEngineTest, GoWithoutDepthIsInfinite) { +TEST_F(UciEngineTest, GoWithoutArgsIsInfinite) { input.write("go\n"); std::this_thread::sleep_for(milliseconds(50)); - ASSERT_TRUE(output.str().find("bestmove ") == std::string::npos); + assert_output_not_contains(output, "bestmove "); } TEST_F(UciEngineTest, GoDepthKeepsEngineResponsive) { @@ -499,13 +493,20 @@ TEST_F(UciEngineTest, InvalidNegativeMoveTimeUsesDefaultMinThinkTimeInstead) { assert_output_contains(output, "bestmove ", milliseconds(10)); } -TEST_F(UciEngineTest, MoveTimeComputesRightAmountOfTime) { - input.write("go movetime 100\n"); +TEST_F(UciEngineTest, GoMoveTimeRespectsLimit) { + auto start = steady_clock::now(); + input.write("go movetime 200\n"); - assert_output_contains(output, "bestmove ", milliseconds(110)); + assert_output_contains(output, "bestmove "); + auto end = steady_clock::now(); + + auto duration = duration_cast(end - start); + + EXPECT_GE(duration.count(), 190); + EXPECT_LE(duration.count(), 300); } -TEST_F(UciEngineTest, MoveTimeCanBeStoppedWhenTooLong) { +TEST_F(UciEngineTest, GoMoveTimeCanBeStoppedWhenTooLong) { input.write( "go movetime 1000000000\n" "stop\n"); @@ -513,8 +514,112 @@ TEST_F(UciEngineTest, MoveTimeCanBeStoppedWhenTooLong) { assert_output_contains(output, "bestmove ", milliseconds(10)); } -TEST_F(UciEngineTest, MoveTimeTooLongDoesNotDisplayResults) { +TEST_F(UciEngineTest, GoMoveTimeTooLongDoesNotDisplayResults) { input.write("go movetime 1000000000\n"); assert_output_not_contains(output, "bestmove ", milliseconds(50)); } + +TEST_F(UciEngineTest, GoMoveTimeTakesPrecedenceOverOtherWhiteTimeControls) { + input.write( + "position startpos\n" + "go movetime 1000000000 wtime 10 winc\n" + "stop\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoMoveTimeTakesPrecedenceOverOtherBlackTimeControls) { + input.write( + "position startpos moves e2e4\n" + "go movetime 1000000000 btime 10 binc 1 \n" + "stop\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoInfiniteTakesPrecedenceOverMoveTimeControls) { + input.write("go movetime 10 infinite wtime 10 winc 1 btime 10 binc 1 \n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoInfiniteTakesPrecedenceOverDepth) { + input.write("go depth 1 infinite\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(50)); +} + +TEST_F(UciEngineTest, GoWtimeWincUsesCorrectBudgetForWhites) { + Uci::SearchLimits limits; + limits.wtime = 10'000; // 10 seconds + limits.winc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::WHITE); + EXPECT_TRUE(budget.has_value()); + + auto start = steady_clock::now(); + input.write( + "position startpos\n" + "go wtime 10000 winc 100\n"); + + assert_output_contains(output, "bestmove ", milliseconds(*budget * 2)); + auto end = steady_clock::now(); + + auto duration = duration_cast(end - start); + + EXPECT_GE(duration.count(), *budget * 0.9); + EXPECT_LE(duration.count(), *budget * 1.1); +} + +TEST_F(UciEngineTest, GoBtimeBincUsesCorrectBudgetForBlacks) { + Uci::SearchLimits limits; + limits.btime = 10'000; // 10 seconds + limits.binc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::BLACK); + EXPECT_TRUE(budget.has_value()); + + auto start = steady_clock::now(); + input.write( + "position startpos moves e2e4\n" + "go btime 10000 binc 100\n"); + + assert_output_contains(output, "bestmove ", milliseconds(*budget * 2)); + auto end = steady_clock::now(); + + auto duration = duration_cast(end - start); + + EXPECT_GE(duration.count(), *budget * 0.9); + EXPECT_LE(duration.count(), *budget * 1.1); +} + +TEST_F(UciEngineTest, GoWtimeWincGoesInfiniteWhenItsBlackTurn) { + Uci::SearchLimits limits; + limits.wtime = 10'000; // 10 seconds + limits.winc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::WHITE); + EXPECT_TRUE(budget.has_value()); + + input.write( + "position startpos moves e2e4\n" + "go wtime 10000 winc 100\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(*budget * 2)); +} + +TEST_F(UciEngineTest, GoBtimeBincGoesInfiniteWhenItsWhiteTurn) { + Uci::SearchLimits limits; + limits.btime = 10'000; // 10 seconds + limits.binc = 100; // 100 millisecond increment + + auto budget = limits.think_time_ms(Color::BLACK); + EXPECT_TRUE(budget.has_value()); + + input.write( + "position startpos\n" + "go btime 10000 binc 100\n"); + + assert_output_not_contains(output, "bestmove ", milliseconds(*budget * 2)); +} From b6a62c5e2add6ac736438a61e16dfb88b2a56114 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 12:06:24 +0200 Subject: [PATCH 09/18] fixed clan tidy errors --- include/bitbishop/movegen/legal_moves.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/bitbishop/movegen/legal_moves.hpp b/include/bitbishop/movegen/legal_moves.hpp index e89e46b0..62c10aaa 100644 --- a/include/bitbishop/movegen/legal_moves.hpp +++ b/include/bitbishop/movegen/legal_moves.hpp @@ -19,7 +19,7 @@ #include namespace MoveGen { -enum class Scope { +enum class Scope : std::uint8_t { AllMoves, CapturesOnly, }; From 08d53dc8a9db1bce5172e29c4ba12617a7a842aa Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 12:51:28 +0200 Subject: [PATCH 10/18] fixed coverage --- .../bitbishop/interface/test_search_limits.cpp | 10 ++++++++++ .../interface/test_uci_command_channel.cpp | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/tests/bitbishop/interface/test_search_limits.cpp b/tests/bitbishop/interface/test_search_limits.cpp index ece882f4..01b3d1f8 100644 --- a/tests/bitbishop/interface/test_search_limits.cpp +++ b/tests/bitbishop/interface/test_search_limits.cpp @@ -47,6 +47,16 @@ TEST(SearchLimitsTest, ThinkTimeUsesSideSpecificClockAndIncrement) { EXPECT_EQ(limits.think_time_ms(Color::BLACK), 530); } +TEST(SearchLimitsTest, ThinkTimeUsesDefaultRemainingTimeIfInvalidLowerEqZero) { + Uci::SearchLimits limits{ + .wtime = -10'000, + .btime = 0, + }; + + EXPECT_GT(limits.think_time_ms(Color::WHITE), 0); + EXPECT_GT(limits.think_time_ms(Color::BLACK), 0); +} + struct SearchLimitsFromUciTestCase { std::string test_name; std::vector command_line; diff --git a/tests/bitbishop/interface/test_uci_command_channel.cpp b/tests/bitbishop/interface/test_uci_command_channel.cpp index 0e0b234c..0fb2df87 100644 --- a/tests/bitbishop/interface/test_uci_command_channel.cpp +++ b/tests/bitbishop/interface/test_uci_command_channel.cpp @@ -76,3 +76,21 @@ TEST(UciCommandChannelTest, ReceivesCommandProducedAfterStart) { producer.join(); channel.stop(); } + +TEST(UciCommandChannelTest, StopDoesNotHangWhenReaderIsBlockedByInputStream) { + BlockingIStream input; // needs input.close() to signal eof + Uci::UciCommandChannel channel(input); + + channel.start(); + // The thread is now stuck in std::getline + // Calling stop() should return immediately, not wait for a newline + + auto start_time = std::chrono::steady_clock::now(); + channel.stop(); + auto end_time = std::chrono::steady_clock::now(); + + auto duration = std::chrono::duration_cast(end_time - start_time); + + // If it took 0ms, it means we successfully detached instead of joining a hung thread. + EXPECT_LT(duration.count(), 5); +} From 5f9250927ece24ccb298615652e1fbf31f084c17 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 12:54:48 +0200 Subject: [PATCH 11/18] small refactor --- tests/bitbishop/interface/test_search_worker.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index 749137b3..b9afd356 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -57,8 +57,8 @@ TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { ASSERT_FALSE(reports.empty()); EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); - EXPECT_GE(elapsed.count(), 50); - EXPECT_LT(elapsed.count(), 55); + EXPECT_GE(elapsed.count(), *limits.movetime); + EXPECT_LT(elapsed.count(), *limits.movetime + 5); EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); From 7fb084af36444944ebe1daf7d178638abe049e25 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 12:56:36 +0200 Subject: [PATCH 12/18] fixed test --- tests/bitbishop/interface/test_uci_command_channel.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bitbishop/interface/test_uci_command_channel.cpp b/tests/bitbishop/interface/test_uci_command_channel.cpp index 0fb2df87..49ce10bc 100644 --- a/tests/bitbishop/interface/test_uci_command_channel.cpp +++ b/tests/bitbishop/interface/test_uci_command_channel.cpp @@ -92,5 +92,5 @@ TEST(UciCommandChannelTest, StopDoesNotHangWhenReaderIsBlockedByInputStream) { auto duration = std::chrono::duration_cast(end_time - start_time); // If it took 0ms, it means we successfully detached instead of joining a hung thread. - EXPECT_LT(duration.count(), 5); + EXPECT_EQ(duration.count(), 0); } From d0c9ec70d6a5bcad1b9f1570b4740986023c0d66 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 13:01:03 +0200 Subject: [PATCH 13/18] removed unecessary comment --- tests/bitbishop/tools/test_time_guard.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/bitbishop/tools/test_time_guard.cpp b/tests/bitbishop/tools/test_time_guard.cpp index 5de6e78e..7c8f23fb 100644 --- a/tests/bitbishop/tools/test_time_guard.cpp +++ b/tests/bitbishop/tools/test_time_guard.cpp @@ -10,8 +10,6 @@ using namespace Tools; TEST(TimeGuardTest, SetsFlagOnTimeout) { std::atomic_bool should_stop{false}; { - // Use a small timeout for speed, but the join in the destructor - // ensures we wait until the thread is actually finished. TimeGuard guard(should_stop, std::chrono::milliseconds(10)); std::this_thread::sleep_for(std::chrono::milliseconds(30)); } From 291bc41c014fc6080706d89b8353a66586408c24 Mon Sep 17 00:00:00 2001 From: baptiste Date: Wed, 29 Apr 2026 13:16:44 +0200 Subject: [PATCH 14/18] updated doc --- docs/commands.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index bae999f2..d895b2ba 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -125,8 +125,16 @@ Implemented behavior (current state): - `depth `: fixed-depth search. - `infinite`: iterative deepening until `stop`. -- If `depth` is not provided, search defaults to infinite mode. -- If `depth` and `infinite` are both present, `infinite` takes precedence. +- `movetime `: iterative deepening search limited by a per-move budget in milliseconds. +- Non-positive `movetime` values are clamped to `1` ms. +- `movetime` takes precedence over `wtime`/`btime`/`winc`/`binc`. +- `infinite` takes precedence over every other limit, including `movetime` and `depth`. +- If both `depth` and a time-based limit are provided, the current implementation runs the timed search path. +- When `movetime` is not provided, time is estimated from the side-to-move clock: + roughly remaining time divided across future moves, with increment added and a safety reserve kept aside. + +- If no argument is provided behind `go`, search defaults to infinite mode. +- If `depth` is not provided and no time control is provided either, search defaults to infinite mode. Response when search ends: @@ -205,6 +213,7 @@ Implemented behavior (current state): - Uses the same limit parser as `go`. - `depth ` runs a fixed-depth benchmark. +- `movetime ` runs a time-limited benchmark using the same semantics as `go`. - If `infinite` is provided, benchmark mode internally converts to `depth 10`. - A bare `bench` command (no limits) is parsed as `infinite`, then converted internally to `depth 10`. - `stop` can still interrupt a running benchmark early. @@ -223,18 +232,3 @@ Not implemented yet. - The engine currently does not expose configurable UCI options. - No `option name ...` lines are emitted in `uci` response. - -### `go` time-control fields - -The following tokens are parsed but not yet enforced by search scheduling: - -- `movetime ` -- `wtime ` -- `btime ` -- `winc ` -- `binc ` - -Today, practical stopping control is: - -- `go depth ` for bounded search -- `go` or `go infinite`, then `stop` for unbounded search From 9d3475dcd6e701030a5d5118640bed9baafa3480 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 29 Apr 2026 14:07:41 +0200 Subject: [PATCH 15/18] replaced std::scope_exit by a manual alternative as not all stdlibs provide this feature --- src/bitbishop/interface/search_worker.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index 99c3aeb2..fbf6a0a8 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include namespace { @@ -96,7 +95,13 @@ void Uci::SearchWorker::push_report(SearchReport report) { void Uci::SearchWorker::run() { using namespace Search; - const auto guard = std::experimental::scope_exit([this] { finished.store(true); }); + // scope_exit function is not yet shipped in all stdlibs (linux is fine, not windows nor macos - 2026-04-29) + // const auto guard = std::experimental::scope_exit([this] { finished.store(true); }); + // Replacing it by a struct with custom destructor. + struct FinishGuard { + std::atomic& finished_ref; + ~FinishGuard() { finished_ref.store(true); } + } guard{finished}; SearchStats stats{}; SearchReport current_best_report{.kind = SearchReportKind::Iteration}; From cd7cf510d1674ced1030e2f93b965157ae50234a Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 29 Apr 2026 15:26:56 +0200 Subject: [PATCH 16/18] tuned tests so time limits are a bit more permissive --- tests/bitbishop/interface/test_search_worker.cpp | 2 +- tests/bitbishop/tools/test_time_guard.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index b9afd356..cab6dbc0 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -58,7 +58,7 @@ TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); EXPECT_GE(elapsed.count(), *limits.movetime); - EXPECT_LT(elapsed.count(), *limits.movetime + 5); + EXPECT_LT(elapsed.count(), *limits.movetime * 1.5); EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); diff --git a/tests/bitbishop/tools/test_time_guard.cpp b/tests/bitbishop/tools/test_time_guard.cpp index 7c8f23fb..739cb71f 100644 --- a/tests/bitbishop/tools/test_time_guard.cpp +++ b/tests/bitbishop/tools/test_time_guard.cpp @@ -35,8 +35,8 @@ TEST(TimeGuardTest, DestructorJoinsQuicklyBeforeTimeout) { auto duration = std::chrono::duration_cast(end - start); - // Destruction should be near-instant - EXPECT_LE(duration.count(), 1.2 * sleep_time.count()); + // Destruction should be quick + EXPECT_LE(duration.count(), 2 * sleep_time.count()); EXPECT_FALSE(should_stop); } From 465fe7de43011a3f30bd5386730745940cff3fc8 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 29 Apr 2026 16:20:21 +0200 Subject: [PATCH 17/18] updated durations limits to be more permissive cause runners are quite slow (this is bad, but don't know how to test time controls without keeping an eye on execution time) --- tests/bitbishop/interface/test_search_worker.cpp | 2 +- tests/bitbishop/interface/test_uci_engine.cpp | 4 ++-- tests/bitbishop/tools/test_time_guard.cpp | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index cab6dbc0..c7b8d0f5 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -58,7 +58,7 @@ TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); EXPECT_GE(elapsed.count(), *limits.movetime); - EXPECT_LT(elapsed.count(), *limits.movetime * 1.5); + EXPECT_LT(elapsed.count(), *limits.movetime * 2); EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index 83212a29..a410e4b9 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -590,8 +590,8 @@ TEST_F(UciEngineTest, GoBtimeBincUsesCorrectBudgetForBlacks) { auto duration = duration_cast(end - start); - EXPECT_GE(duration.count(), *budget * 0.9); - EXPECT_LE(duration.count(), *budget * 1.1); + EXPECT_GE(duration.count(), *budget * 0.5); + EXPECT_LE(duration.count(), *budget * 1.5); } TEST_F(UciEngineTest, GoWtimeWincGoesInfiniteWhenItsBlackTurn) { diff --git a/tests/bitbishop/tools/test_time_guard.cpp b/tests/bitbishop/tools/test_time_guard.cpp index 739cb71f..3236aa21 100644 --- a/tests/bitbishop/tools/test_time_guard.cpp +++ b/tests/bitbishop/tools/test_time_guard.cpp @@ -35,8 +35,8 @@ TEST(TimeGuardTest, DestructorJoinsQuicklyBeforeTimeout) { auto duration = std::chrono::duration_cast(end - start); - // Destruction should be quick - EXPECT_LE(duration.count(), 2 * sleep_time.count()); + // Destruction should be (relatively) quick (depends on the platform tests runs on) + EXPECT_LE(duration.count(), 100); EXPECT_FALSE(should_stop); } From 88a959a2bc7cc7d6f8dac35a3ed5682bed360429 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 29 Apr 2026 16:28:41 +0200 Subject: [PATCH 18/18] going even more permissive for time controls --- tests/bitbishop/interface/test_search_worker.cpp | 2 +- tests/bitbishop/interface/test_uci_engine.cpp | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bitbishop/interface/test_search_worker.cpp b/tests/bitbishop/interface/test_search_worker.cpp index c7b8d0f5..1303b6a5 100644 --- a/tests/bitbishop/interface/test_search_worker.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -58,7 +58,7 @@ TEST(SearchControllerTest, MovetimeStopsSearchAutomatically) { EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); EXPECT_GE(elapsed.count(), *limits.movetime); - EXPECT_LT(elapsed.count(), *limits.movetime * 2); + EXPECT_LT(elapsed.count(), *limits.movetime * 4); // very permissive for slow ci builds and tests EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index a410e4b9..efac7c73 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -503,7 +503,7 @@ TEST_F(UciEngineTest, GoMoveTimeRespectsLimit) { auto duration = duration_cast(end - start); EXPECT_GE(duration.count(), 190); - EXPECT_LE(duration.count(), 300); + EXPECT_LE(duration.count(), 400); // very permissive for slow ci builds and tests } TEST_F(UciEngineTest, GoMoveTimeCanBeStoppedWhenTooLong) { @@ -568,8 +568,8 @@ TEST_F(UciEngineTest, GoWtimeWincUsesCorrectBudgetForWhites) { auto duration = duration_cast(end - start); - EXPECT_GE(duration.count(), *budget * 0.9); - EXPECT_LE(duration.count(), *budget * 1.1); + EXPECT_GE(duration.count(), *budget * 0.5); + EXPECT_LE(duration.count(), *budget * 1.5); } TEST_F(UciEngineTest, GoBtimeBincUsesCorrectBudgetForBlacks) {