From 913dd37880e4dff2d5fb43eecf02b55097d154f0 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 22 Apr 2026 13:08:09 +0200 Subject: [PATCH 01/19] tmp save --- include/bitbishop/engine/search.hpp | 15 +- include/bitbishop/interface/reporter.hpp | 41 ++++ .../bitbishop/interface/search_controller.hpp | 68 +++++- include/bitbishop/interface/uci_engine.hpp | 60 ++++++ src/bitbishop/engine/search.cpp | 14 +- src/bitbishop/interface/search_controller.cpp | 110 ++++++++-- src/bitbishop/interface/uci_engine.cpp | 198 ++++++++++++++---- .../bitbishop/engine/search/test_negamax.cpp | 43 ++-- .../interface/test_search_controller.cpp | 32 +-- tests/bitbishop/interface/test_uci_engine.cpp | 24 +++ 10 files changed, 501 insertions(+), 104 deletions(-) create mode 100644 include/bitbishop/interface/reporter.hpp diff --git a/include/bitbishop/engine/search.hpp b/include/bitbishop/engine/search.hpp index 3b27a203..5bb28b82 100644 --- a/include/bitbishop/engine/search.hpp +++ b/include/bitbishop/engine/search.hpp @@ -9,6 +9,14 @@ class Position; namespace Search { +/** + * @brief Contains statistics about a best move search. + */ +struct SearchStats { + uint64_t negamax_nodes; ///< Number of explored negamax nodes + uint64_t quiescence_nodes; ///< Number of explored quiescence nodes +}; + // We implement negamax with alpha-beta by flipping the window at each ply: // score = -negamax(child, depth-1, -beta, -alpha, ...) // That requires negating `alpha`/`beta`. Negating `INT_MIN` is undefined behaviour in C++, @@ -40,6 +48,7 @@ struct BestMove { * @param position Current position (board + history) * @param alpha Best score the current side can guarantee * @param beta Best score the opponent side can guarantee + * @param stats Statistics about the search process * * @return Score from the perspective of the side to move * @@ -60,7 +69,8 @@ struct BestMove { * positions. * """ */ -[[nodiscard]] int quiesce(Position& position, int alpha, int beta, std::atomic* stop_flag = nullptr); +[[nodiscard]] int quiesce(Position& position, int alpha, int beta, SearchStats& stats, + std::atomic* stop_flag = nullptr); /** * @brief Finds the best achievable move for the side to move assuming an optimal play on both sides. @@ -70,6 +80,7 @@ struct BestMove { * @param alpha Lower bound, aka. minimum score we already guaranteed to get. * @param beta Upper bound, aka. maximum score the opponent is willing to let us have. * @param ply Number of half-moves from root used for mate distance + * @param stats Statistics about the search process * * @return Move and score in a BestMove object * @@ -81,7 +92,7 @@ struct BestMove { * @see https://www.chessprogramming.org/Alpha-Beta * @see https://www.dogeystamp.com/chess2/ */ -[[nodiscard]] BestMove negamax(Position& position, std::size_t depth, int alpha, int beta, int ply, +[[nodiscard]] BestMove negamax(Position& position, std::size_t depth, int alpha, int beta, int ply, SearchStats& stats, std::atomic* stop_flag = nullptr); } // namespace Search diff --git a/include/bitbishop/interface/reporter.hpp b/include/bitbishop/interface/reporter.hpp new file mode 100644 index 00000000..2b659dce --- /dev/null +++ b/include/bitbishop/interface/reporter.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +struct SearchReporter { + virtual ~SearchReporter() = default; + + virtual void on_iteration(const Search::BestMove& best, int depth, const Search::SearchStats& stats) {} + + virtual void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) = 0; +}; + +struct UciReporter : SearchReporter { + std::ostream& out_stream; + + UciReporter(std::ostream& out) : out_stream(out) {} + + void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override { + const std::string best_move_str = (best.move) ? (*best.move).to_uci() : "0000"; + out_stream << "bestmove " << best_move_str << "\n"; + out_stream << std::flush; + } +}; + +struct BenchReporter : SearchReporter { + std::ostream& out_stream; + std::chrono::steady_clock::time_point start; + + BenchReporter(std::ostream& out) : out_stream(out), start(std::chrono::steady_clock::now()) {} + + void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override { + auto end = std::chrono::steady_clock::now(); + double seconds = std::chrono::duration(end - start).count(); + + uint64_t total = stats.negamax_nodes + stats.quiescence_nodes; + uint64_t nps = (seconds > 0.0) ? static_cast(static_cast(total) / seconds) : 0; + + out_stream << "bench nodes " << total << " time " << seconds << "s" << " nps " << nps << "\n" << std::flush; + } +}; diff --git a/include/bitbishop/interface/search_controller.hpp b/include/bitbishop/interface/search_controller.hpp index d235ece8..02d8e0e2 100644 --- a/include/bitbishop/interface/search_controller.hpp +++ b/include/bitbishop/interface/search_controller.hpp @@ -1,11 +1,12 @@ #pragma once #include +#include #include +#include #include -#include -#include #include +#include namespace Uci { @@ -20,29 +21,65 @@ struct SearchLimits { std::optional wtime, btime; ///< White/black time limits (in milliseconds) std::optional winc, binc; ///< White/black increment limits (in milliseconds) bool infinite = false; ///< Flag for infinite search mode + + /** + * @brief Parses arguments from a search uci command into a SearchLimits object. + * + * UCI command is like: `go depth 2` or `go movetime 5000`, etc... + * + * @return The built SearchLimits object. + */ + static SearchLimits from_uci_cmd(std::vector& line); +}; + +/** + * @brief Represents an event generated by a search worker. + * + * Search workers only publish data. Reporting/printing is done by the UCI control thread. + */ +enum class SearchReportKind { + Iteration, + Finish, +}; + +/** + * @brief Structured search report event consumed by the UCI controller. + */ +struct SearchReport { + SearchReportKind kind = SearchReportKind::Finish; + Search::BestMove best; + int depth = 0; + Search::SearchStats stats{}; }; /** * @brief Manages the search process for UCI commands. * - * This class handles the execution of search operations based on UCI parameters. - * It uses a background thread to perform the search and communicates results through a callback. + * This class handles the execution of search operations based on UCI parameters. It uses a background thread to + * perform the search and publishes structured reports for the control thread to consume. */ class SearchWorker { std::thread worker; ///< Worker thread for search operations std::atomic stop_flag{false}; ///< Flag used to forward the stop order to the worker(s) + std::atomic finished{true}; ///< Indicates whether worker thread has completed Board board; ///< Current chess board Position position; ///< Game position associated to the current chess board SearchLimits limits; ///< Current search parameters - std::ostream* out; ///< Output stream for UCI communication + std::mutex reports_mutex; ///< Synchronizes report queue access + std::vector reports; ///< FIFO queue of generated search reports /** * @brief Executes the search algorithm in a background thread. */ void run(); + /** + * @brief Pushes one report event into the queue. + */ + void push_report(SearchReport report); + public: - SearchWorker(Board board, SearchLimits limits, std::ostream& ostream = std::cout); + SearchWorker(Board board, SearchLimits limits); ~SearchWorker(); /** @@ -66,7 +103,26 @@ class SearchWorker { * Thread interruption finishes early the best move search and may not be able to return a best move. * An intermediate state may be returned instead. */ + void request_stop(); + + /** + * @brief Returns whether the current worker run has finished. + */ + [[nodiscard]] bool is_finished() const { return finished.load(); } + + /** + * @brief Requests a stop then waits for completion. + * + * This is a blocking helper. + */ void stop(); + + /** + * @brief Moves all pending search reports out of the worker queue. + * + * This is thread-safe and intended to be called by the control thread. + */ + [[nodiscard]] std::vector drain_reports(); }; } // namespace Uci diff --git a/include/bitbishop/interface/uci_engine.hpp b/include/bitbishop/interface/uci_engine.hpp index 7455122d..22800fd1 100644 --- a/include/bitbishop/interface/uci_engine.hpp +++ b/include/bitbishop/interface/uci_engine.hpp @@ -1,12 +1,20 @@ #pragma once +#include #include #include +#include +#include +#include +#include #include #include +#include +#include #include #include #include +#include #include namespace Uci { @@ -28,9 +36,14 @@ namespace Uci { * game state management, and search control. */ class UciEngine { + struct InputState; + Board board; ///< Current chess board Position position; ///< Game position associated to the current chess board std::unique_ptr search_worker_ptr; ///< Manages the search process + std::unique_ptr search_reporter_ptr; ///< Formats search output on the control thread + std::thread input_thread; ///< Dedicated input reader thread + std::shared_ptr input_state; ///< Shared input queue state between control and reader threads bool is_running; std::istream &in_stream; ///< Input stream for UCI commands @@ -135,6 +148,46 @@ class UciEngine { */ void reset_search_worker(); + /** + * @brief Requests a stop to the current search worker without waiting. + */ + void request_stop_search_worker(); + + /** + * @brief Finalizes current search worker and reporter if the worker has finished. + */ + void finalize_search_worker_if_done(); + + /** + * @brief Drains pending reports from the current worker and forwards them to the reporter. + */ + void emit_search_reports(); + + /** + * @brief Pumps search reports and cleanup from the control loop. + */ + void poll_search_worker(); + + /** + * @brief Reads commands from input stream in a dedicated thread. + */ + static void input_reader_loop(std::istream& input_stream, std::shared_ptr input_state); + + /** + * @brief Starts the input reader thread. + */ + void start_input_reader(); + + /** + * @brief Stops input reader resources at loop shutdown. + */ + void stop_input_reader_loop(); + + /** + * @brief Pops one pending command or times out. + */ + bool wait_and_pop_command(std::vector& line, std::chrono::milliseconds timeout); + /** * @brief Displays the current board state as well as usefull information. * @@ -156,6 +209,13 @@ class UciEngine { * Should be used before the UCI loop starts. */ void send_startup_msg(); + + /** + * @brief Runs a benchmark. + * + * @param line The input command tokens containing the benchmark information + */ + void handle_bench(std::vector &line); }; } // namespace Uci diff --git a/src/bitbishop/engine/search.cpp b/src/bitbishop/engine/search.cpp index 16ff80f9..630d9d0a 100644 --- a/src/bitbishop/engine/search.cpp +++ b/src/bitbishop/engine/search.cpp @@ -4,7 +4,9 @@ #include // https://www.chessprogramming.org/Quiescence_Search -int Search::quiesce(Position& position, int alpha, int beta, std::atomic* stop_flag) { +int Search::quiesce(Position& position, int alpha, int beta, SearchStats& stats, std::atomic* stop_flag) { + stats.quiescence_nodes++; + if (stop_flag != nullptr && stop_flag->load()) { return alpha; } @@ -47,7 +49,7 @@ int Search::quiesce(Position& position, int alpha, int beta, std::atomic* // Quiescence window flip: child is searched with (-beta, -alpha) and the returned score is negated. // This relies on `ALPHA_INIT` not being `INT_MIN` (see `include/bitbishop/engine/search.hpp`). - int score = -quiesce(position, -beta, -alpha, stop_flag); + int score = -quiesce(position, -beta, -alpha, stats, stop_flag); position.revert_move(); if (stop_flag != nullptr && stop_flag->load()) { @@ -64,7 +66,9 @@ int Search::quiesce(Position& position, int alpha, int beta, std::atomic* } Search::BestMove Search::negamax(Position& position, std::size_t depth, int alpha, int beta, int ply, - std::atomic* stop_flag) { + SearchStats& stats, std::atomic* stop_flag) { + stats.negamax_nodes++; + const Board& board = position.get_board(); BestMove best; @@ -81,7 +85,7 @@ Search::BestMove Search::negamax(Position& position, std::size_t depth, int alph } if (depth == 0) { - best.score = quiesce(position, alpha, beta, stop_flag); + best.score = quiesce(position, alpha, beta, stats, stop_flag); return best; } @@ -115,7 +119,7 @@ Search::BestMove Search::negamax(Position& position, std::size_t depth, int alph position.apply_move(move); // Negamax window flip: child is searched with (-beta, -alpha) and the returned score is negated. // This relies on `ALPHA_INIT` not being `INT_MIN` (see `include/bitbishop/engine/search.hpp`). - int score = -negamax(position, depth - 1, -beta, -alpha, ply + 1, stop_flag).score; + int score = -negamax(position, depth - 1, -beta, -alpha, ply + 1, stats, stop_flag).score; position.revert_move(); if (stop_flag != nullptr && stop_flag->load()) { diff --git a/src/bitbishop/interface/search_controller.cpp b/src/bitbishop/interface/search_controller.cpp index 45055cec..0d56e75e 100644 --- a/src/bitbishop/interface/search_controller.cpp +++ b/src/bitbishop/interface/search_controller.cpp @@ -1,41 +1,104 @@ #include -Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits, std::ostream& ostream) - : board(board), position(Position(this->board)), limits(limits), out(&ostream) {} +Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(std::vector& line) { + SearchLimits limits; + + for (std::size_t i = 1; i < line.size(); ++i) { + const auto& tok = line[i]; + + auto read = [&](std::optional& target) { + if (i + 1 < line.size()) { + target = std::stoi(line[++i]); + } + }; + + if (tok == "depth") { + read(limits.depth); + } else if (tok == "movetime") { + read(limits.movetime); + } else if (tok == "wtime") { + read(limits.wtime); + } else if (tok == "btime") { + read(limits.btime); + } else if (tok == "winc") { + read(limits.winc); + } else if (tok == "binc") { + read(limits.binc); + } else if (tok == "infinite") { + limits.infinite = true; + } + } + + if (!limits.depth) { + limits.infinite = true; // Only depth and infinite limits are supported for now + } + + return limits; +} + +Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits) + : board(board), position(Position(this->board)), limits(limits) {} Uci::SearchWorker::~SearchWorker() { stop(); } +void Uci::SearchWorker::push_report(SearchReport report) { + std::lock_guard lock(reports_mutex); + reports.push_back(std::move(report)); +} + void Uci::SearchWorker::run() { using namespace Search; + struct FinishGuard { + std::atomic& finished_ref; + ~FinishGuard() { finished_ref.store(true); } + } guard{finished}; + SearchStats stats{}; BestMove best; BestMove last_best; - try { - if (limits.infinite) { - for (int current_depth = 1; !stop_flag.load(); ++current_depth) { - best = negamax(position, current_depth, ALPHA_INIT, BETA_INIT, 0, &stop_flag); - if (!stop_flag.load()) { - last_best = best; - } + + 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, + }); } - } else if (limits.depth) { - best = negamax(position, *limits.depth, ALPHA_INIT, BETA_INIT, 0, &stop_flag); } - } catch (const std::exception& e) { - (*out) << "info string exception " << e.what() << "\n"; - } catch (...) { - (*out) << "info string unknown exception\n"; + } 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, + }); + } } const BestMove& final = (last_best.move) ? last_best : best; - const std::string best_move_str = (final.move) ? (*final.move).to_uci() : "0000"; - (*out) << "bestmove " << best_move_str << "\n"; - (*out) << std::flush; + push_report(SearchReport{ + .kind = SearchReportKind::Finish, + .best = final, + .depth = limits.depth.value_or(0), + .stats = stats, + }); } void Uci::SearchWorker::start() { stop(); stop_flag.store(false); + finished.store(false); + { + std::lock_guard lock(reports_mutex); + reports.clear(); + } worker = std::thread(&SearchWorker::run, this); } @@ -45,7 +108,16 @@ void Uci::SearchWorker::wait() { } } +void Uci::SearchWorker::request_stop() { stop_flag.store(true); } + void Uci::SearchWorker::stop() { - stop_flag.store(true); + request_stop(); wait(); } + +std::vector Uci::SearchWorker::drain_reports() { + std::lock_guard lock(reports_mutex); + std::vector drained; + drained.swap(reports); + return drained; +} diff --git a/src/bitbishop/interface/uci_engine.cpp b/src/bitbishop/interface/uci_engine.cpp index 7e4e3c5b..88e07b01 100644 --- a/src/bitbishop/interface/uci_engine.cpp +++ b/src/bitbishop/interface/uci_engine.cpp @@ -3,6 +3,14 @@ #include #include +struct Uci::UciEngine::InputState { + std::mutex commands_mutex; + std::condition_variable commands_cv; + std::deque> pending_commands; + std::atomic input_eof{false}; + std::atomic stop_input_reader{false}; +}; + [[nodiscard]] std::vector Uci::split(const std::string &str) { std::vector tokens; std::istringstream token_stream{str}; @@ -14,23 +22,41 @@ } Uci::UciEngine::UciEngine(std::istream &input, std::ostream &output) - : is_running(true), - board(Board::StartingPosition()), + : board(Board::StartingPosition()), position(Position(this->board)), + search_worker_ptr(nullptr), + search_reporter_ptr(nullptr), + input_thread(), + input_state(nullptr), + is_running(true), in_stream(input), - out_stream(output), - search_worker_ptr(nullptr) {} + out_stream(output) {} void Uci::UciEngine::loop() { send_startup_msg(); + start_input_reader(); - std::string input_str; - std::vector line; - while (is_running && std::getline(in_stream, input_str)) { - line = split(input_str); - dispatch(line); + while (is_running) { + poll_search_worker(); + + std::vector line; + if (wait_and_pop_command(line, std::chrono::milliseconds(5))) { + dispatch(line); + continue; + } + + if (input_state && input_state->input_eof.load()) { + request_stop_search_worker(); + poll_search_worker(); + if (!search_worker_ptr) { + is_running = false; + } + } } -}; + + reset_search_worker(); + stop_input_reader_loop(); +} void Uci::UciEngine::dispatch(std::vector &line) { if (line.empty()) { @@ -55,6 +81,8 @@ void Uci::UciEngine::dispatch(std::vector &line) { handle_display(); } else if (line.front() == "help") { handle_help(); + } else if (line.front() == "bench") { + handle_bench(line); } // unknown lines are discarded silently following uci rules }; @@ -118,58 +146,129 @@ void Uci::UciEngine::handle_position(std::vector &line) { void Uci::UciEngine::handle_go(std::vector &line) { reset_search_worker(); - SearchLimits limits; - - for (std::size_t i = 1; i < line.size(); ++i) { - const auto &tok = line[i]; - - auto read = [&](std::optional &target) { - if (i + 1 < line.size()) { - target = std::stoi(line[++i]); - } - }; - - if (tok == "depth") { - read(limits.depth); - } else if (tok == "movetime") { - read(limits.movetime); - } else if (tok == "wtime") { - read(limits.wtime); - } else if (tok == "btime") { - read(limits.btime); - } else if (tok == "winc") { - read(limits.winc); - } else if (tok == "binc") { - read(limits.binc); - } else if (tok == "infinite") { - limits.infinite = true; - } - } - - if (!limits.depth) { - limits.infinite = true; // Only depth and infinite limits are supported for now - } + SearchLimits limits = SearchLimits::from_uci_cmd(line); - search_worker_ptr = std::make_unique(board, limits, out_stream); + search_reporter_ptr = std::make_unique(out_stream); + search_worker_ptr = std::make_unique(board, limits); assert(search_worker_ptr != nullptr); search_worker_ptr->start(); } -void Uci::UciEngine::handle_stop() { reset_search_worker(); } +void Uci::UciEngine::handle_stop() { request_stop_search_worker(); } void Uci::UciEngine::handle_quit() { - reset_search_worker(); + request_stop_search_worker(); is_running = false; } void Uci::UciEngine::reset_search_worker() { if (search_worker_ptr) { search_worker_ptr->stop(); + emit_search_reports(); search_worker_ptr.reset(); } + + search_reporter_ptr.reset(); assert(search_worker_ptr == nullptr); } +void Uci::UciEngine::request_stop_search_worker() { + if (search_worker_ptr) { + search_worker_ptr->request_stop(); + } +} + +void Uci::UciEngine::finalize_search_worker_if_done() { + if (!search_worker_ptr || !search_worker_ptr->is_finished()) { + return; + } + + search_worker_ptr->wait(); + emit_search_reports(); + search_worker_ptr.reset(); + search_reporter_ptr.reset(); +} + +void Uci::UciEngine::emit_search_reports() { + if (!search_worker_ptr || !search_reporter_ptr) { + return; + } + + const auto reports = search_worker_ptr->drain_reports(); + for (const SearchReport &report : reports) { + if (report.kind == SearchReportKind::Iteration) { + search_reporter_ptr->on_iteration(report.best, report.depth, report.stats); + } else if (report.kind == SearchReportKind::Finish) { + search_reporter_ptr->on_finish(report.best, report.stats); + } + } +} + +void Uci::UciEngine::poll_search_worker() { + emit_search_reports(); + finalize_search_worker_if_done(); +} + +void Uci::UciEngine::input_reader_loop(std::istream &input_stream, std::shared_ptr state) { + std::string input_str; + while (!state->stop_input_reader.load() && std::getline(input_stream, input_str)) { + std::vector line = split(input_str); + { + std::lock_guard lock(state->commands_mutex); + state->pending_commands.push_back(std::move(line)); + } + state->commands_cv.notify_one(); + } + + state->input_eof.store(true); + state->commands_cv.notify_all(); +} + +void Uci::UciEngine::start_input_reader() { + input_state = std::make_shared(); + input_thread = std::thread(&UciEngine::input_reader_loop, std::ref(in_stream), input_state); +} + +void Uci::UciEngine::stop_input_reader_loop() { + if (!input_state) { + return; + } + + input_state->stop_input_reader.store(true); + input_state->commands_cv.notify_all(); + + if (!input_thread.joinable()) { + input_state.reset(); + return; + } + + if (input_state->input_eof.load()) { + input_thread.join(); + } else { + input_thread.detach(); + } + + input_state.reset(); +} + +bool Uci::UciEngine::wait_and_pop_command(std::vector &line, std::chrono::milliseconds timeout) { + if (!input_state) { + return false; + } + + std::unique_lock lock(input_state->commands_mutex); + input_state->commands_cv.wait_for( + lock, timeout, [state = input_state] { return !state->pending_commands.empty() || state->input_eof.load(); }); + + if (input_state->pending_commands.empty()) { + return false; + } + + line = std::move(input_state->pending_commands.front()); + input_state->pending_commands.pop_front(); + return true; +} + void Uci::UciEngine::handle_display() { out_stream << "\n"; out_stream << board << "\n"; @@ -194,3 +293,14 @@ For any further information, visit its GitHub repository: https://github.com/Har void Uci::UciEngine::send_startup_msg() { out_stream << BITBISHOP_PROJECT_NAME << " " << BITBISHOP_VERSION << " " << "by Hardcode3 (Baptiste Penot).\n"; } + +void Uci::UciEngine::handle_bench(std::vector &line) { + reset_search_worker(); + + SearchLimits limits = SearchLimits::from_uci_cmd(line); + + search_reporter_ptr = std::make_unique(out_stream); + search_worker_ptr = std::make_unique(board, limits); + assert(search_worker_ptr != nullptr); + search_worker_ptr->start(); +} diff --git a/tests/bitbishop/engine/search/test_negamax.cpp b/tests/bitbishop/engine/search/test_negamax.cpp index 654b74cd..59c4456f 100644 --- a/tests/bitbishop/engine/search/test_negamax.cpp +++ b/tests/bitbishop/engine/search/test_negamax.cpp @@ -13,8 +13,9 @@ using namespace Squares; TEST(NegaMaxTest, EmptyBoardThrows) { Board board = Board::Empty(); Position pos(board); + SearchStats stats; - EXPECT_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0), std::bad_optional_access); + EXPECT_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0, stats), std::bad_optional_access); } TEST(NegaMaxTest, EmptyBoardWithBothKingsDontThrow) { @@ -25,15 +26,17 @@ TEST(NegaMaxTest, EmptyBoardWithBothKingsDontThrow) { board.set_side_to_move(Color::WHITE); Position pos(board); - EXPECT_NO_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0)); + SearchStats stats; + EXPECT_NO_THROW(std::ignore = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0, stats)); } TEST(NegaMaxTest, FindsScolarsMateInOne) { // White to move, Queen can take on f7 for mate Board board = Board("r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_GT(best.score, Eval::MATE_THRESHOLD); EXPECT_TRUE(best.move.has_value()); @@ -44,8 +47,9 @@ TEST(NegaMaxTest, FindsScolarsMateInOne) { TEST(NegaMaxTest, FindsCornerMateInOne) { Board board("7k/5K2/6Q1/8/8/8/8/8 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_GT(best.score, Eval::MATE_THRESHOLD); EXPECT_TRUE(best.move.has_value()); @@ -56,8 +60,9 @@ TEST(NegaMaxTest, FindsCornerMateInOne) { TEST(NegaMaxTest, FindsStaleMateByWhiteQueenInCorner) { Board board("K7/8/8/8/8/8/5Q2/7k b - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -66,8 +71,9 @@ TEST(NegaMaxTest, FindsStaleMateByWhiteQueenInCorner) { TEST(NegaMaxTest, FindsStaleMateByWhiteAllBlackPiecesBlocked) { Board board("k7/7R/8/7p/b4p1P/5N2/8/RQ5K b - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -76,8 +82,9 @@ TEST(NegaMaxTest, FindsStaleMateByWhiteAllBlackPiecesBlocked) { TEST(NegaMaxTest, KingVsKingIsInsufficientMaterialDraw) { Board board("8/8/8/8/8/8/8/K1k5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -86,8 +93,9 @@ TEST(NegaMaxTest, KingVsKingIsInsufficientMaterialDraw) { TEST(NegaMaxTest, KingVsKingAndBishopIsInsufficientMaterialDraw) { Board board("8/8/8/8/8/8/8/KBk5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -96,8 +104,9 @@ TEST(NegaMaxTest, KingVsKingAndBishopIsInsufficientMaterialDraw) { TEST(NegaMaxTest, KingVsKingAndKnightIsInsufficientMaterialDraw) { Board board("8/8/8/8/8/8/8/KNk5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -106,8 +115,9 @@ TEST(NegaMaxTest, KingVsKingAndKnightIsInsufficientMaterialDraw) { TEST(NegaMaxTest, KingAndBishopVsKingAndSameColorBishopIsInsufficientMaterialDraw) { Board board("8/8/8/3b4/8/3B4/8/K1k5 w - - 0 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); @@ -124,16 +134,17 @@ TEST(NegaMaxTest, KingAndBishopVsKingAndSameColorBishopIsInsufficientMaterialDra TEST(NegaMaxTest, ThreefoldRepetitionIsDraw) { Board board = Board::StartingPosition(); Position pos(board); + SearchStats stats; apply_knight_repetition_cycle(pos); apply_knight_repetition_cycle(pos); EXPECT_TRUE(pos.is_threefold_repetition()); - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); - EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT), 0); + EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT, stats), 0); } /** @@ -143,11 +154,12 @@ TEST(NegaMaxTest, ThreefoldRepetitionIsDraw) { TEST(NegaMaxTest, TwofoldRepetitionDoesNotAutoDraw) { Board board = Board::StartingPosition(); Position pos(board); + SearchStats stats; apply_knight_repetition_cycle(pos); EXPECT_FALSE(pos.is_threefold_repetition()); - BestMove best = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 1, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_TRUE(best.move.has_value()); } @@ -158,10 +170,11 @@ TEST(NegaMaxTest, TwofoldRepetitionDoesNotAutoDraw) { TEST(NegaMaxTest, FiftyMoveRuleIsDraw) { Board board("8/8/8/8/8/8/8/RKk5 w - - 100 1"); Position pos(board); + SearchStats stats; - BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0); + BestMove best = negamax(pos, 2, ALPHA_INIT, BETA_INIT, 0, stats); EXPECT_EQ(best.score, 0); EXPECT_FALSE(best.move.has_value()); - EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT), 0); + EXPECT_EQ(quiesce(pos, ALPHA_INIT, BETA_INIT, stats), 0); } diff --git a/tests/bitbishop/interface/test_search_controller.cpp b/tests/bitbishop/interface/test_search_controller.cpp index de28964e..80ffa81a 100644 --- a/tests/bitbishop/interface/test_search_controller.cpp +++ b/tests/bitbishop/interface/test_search_controller.cpp @@ -1,35 +1,41 @@ #include #include -#include +#include +#include +#include -TEST(SearchControllerTest, StartEmitsABestmoveWithDepth1) { +TEST(SearchControllerTest, StartPublishesFinishReportWithDepth1) { Board board = Board::StartingPosition(); Uci::SearchLimits limits; limits.depth = 1; - std::ostringstream out; - Uci::SearchWorker controller(board, limits, out); + Uci::SearchWorker controller(board, limits); controller.start(); controller.wait(); - const std::string res = out.str(); - EXPECT_GT(res.size(), 0); - EXPECT_NE(res.find("bestmove "), std::string::npos); + 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_TRUE(std::any_of(reports.begin(), reports.end(), + [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); } -TEST(SearchControllerTest, StartEmitsABestmoveWithInfiniteSearch) { +TEST(SearchControllerTest, StartPublishesFinishReportWithInfiniteSearch) { Board board = Board::StartingPosition(); Uci::SearchLimits limits; limits.infinite = true; - std::ostringstream out; - Uci::SearchWorker controller(board, limits, out); + Uci::SearchWorker controller(board, limits); controller.start(); std::this_thread::sleep_for(std::chrono::milliseconds(200)); controller.stop(); - const std::string res = out.str(); - EXPECT_GT(res.size(), 0); - EXPECT_NE(res.find("bestmove "), std::string::npos); + 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_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 0372fd41..3b31cda0 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -393,6 +393,16 @@ TEST_F(UciEngineTest, GoWithoutDepthIsInfinite) { ASSERT_TRUE(output.str().find("bestmove ") == std::string::npos); } +TEST_F(UciEngineTest, GoDepthKeepsEngineResponsive) { + input.write( + "go depth 8\n" + "isready\n" + "stop\n"); + + assert_output_contains(output, "readyok"); + assert_output_contains(output, "bestmove "); +} + TEST_F(UciEngineTest, GoStopGoDoesNotCrash) { input.write( "go infinite\n" @@ -493,3 +503,17 @@ TEST_F(UciEngineTest, HelpMessageIsDisplayed) { assert_output_contains(output, " published under "); assert_output_contains(output, "For any further information, "); } + +TEST_F(UciEngineTest, BenchProducesBenchReport) { + input.write("bench depth 2\n"); + + assert_output_contains(output, "bench nodes "); +} + +TEST_F(UciEngineTest, BenchCanBeStopped) { + input.write( + "bench depth 8\n" + "stop\n"); + + assert_output_contains(output, "bench nodes "); +} From d6e42d4e617e3d221f7f1f5e5e9900069457babe Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 22 Apr 2026 13:41:46 +0200 Subject: [PATCH 02/19] search session owning search worker and reporter --- .../bitbishop/interface/search_session.hpp | 69 +++++++++++++++++ src/bitbishop/interface/search_session.cpp | 77 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 include/bitbishop/interface/search_session.hpp create mode 100644 src/bitbishop/interface/search_session.cpp diff --git a/include/bitbishop/interface/search_session.hpp b/include/bitbishop/interface/search_session.hpp new file mode 100644 index 00000000..3584a559 --- /dev/null +++ b/include/bitbishop/interface/search_session.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +namespace Uci { + +/** + * @brief Owns the lifecycle of one active search and its reporter. + * + * Worker threads only publish events. This class consumes those events on the + * control thread and forwards them to the configured reporter. + */ +class SearchSession { + std::ostream& out_stream; + std::unique_ptr worker; + std::unique_ptr reporter; + + /** + * @brief Emits pending reports from worker to reporter. + */ + void emit_reports(); + + /** + * @brief Finalizes and clears resources if search already finished. + */ + void finalize_if_done(); + + public: + explicit SearchSession(std::ostream& out_stream); + ~SearchSession(); + + SearchSession(const SearchSession&) = delete; + SearchSession& operator=(const SearchSession&) = delete; + + /** + * @brief Starts a regular UCI search. + */ + void start_go(Board board, SearchLimits limits); + + /** + * @brief Starts a benchmark search. + */ + void start_bench(Board board, SearchLimits limits); + + /** + * @brief Requests current search to stop (non-blocking). + */ + void request_stop(); + + /** + * @brief Pumps reports and finalization (non-blocking). + */ + void poll(); + + /** + * @brief Stops and joins current search if any (blocking). + */ + void stop_and_join(); + + /** + * @brief Returns true when no search is active. + */ + [[nodiscard]] bool is_idle() const { return worker == nullptr; } +}; + +} // namespace Uci diff --git a/src/bitbishop/interface/search_session.cpp b/src/bitbishop/interface/search_session.cpp new file mode 100644 index 00000000..2971f554 --- /dev/null +++ b/src/bitbishop/interface/search_session.cpp @@ -0,0 +1,77 @@ +#include + +#include + +Uci::SearchSession::SearchSession(std::ostream& out_stream) + : out_stream(out_stream), worker(nullptr), reporter(nullptr) {} + +Uci::SearchSession::~SearchSession() { stop_and_join(); } + +void Uci::SearchSession::start_go(Board board, SearchLimits limits) { + stop_and_join(); + + reporter = std::make_unique(out_stream); + worker = std::make_unique(board, limits); + assert(worker != nullptr); + worker->start(); +} + +void Uci::SearchSession::start_bench(Board board, SearchLimits limits) { + stop_and_join(); + + if (limits.infinite) { + limits.depth = 10; + limits.infinite = false; + } + + reporter = std::make_unique(out_stream); + worker = std::make_unique(board, limits); + assert(worker != nullptr); + worker->start(); +} + +void Uci::SearchSession::request_stop() { + if (worker) { + worker->request_stop(); + } +} + +void Uci::SearchSession::emit_reports() { + if (!worker || !reporter) { + return; + } + + const auto reports = worker->drain_reports(); + for (const SearchReport& report : reports) { + if (report.kind == SearchReportKind::Iteration) { + reporter->on_iteration(report.best, report.depth, report.stats); + } else if (report.kind == SearchReportKind::Finish) { + reporter->on_finish(report.best, report.stats); + } + } +} + +void Uci::SearchSession::finalize_if_done() { + if (!worker || !worker->is_finished()) { + return; + } + + worker->wait(); + emit_reports(); + worker.reset(); + reporter.reset(); +} + +void Uci::SearchSession::poll() { + emit_reports(); + finalize_if_done(); +} + +void Uci::SearchSession::stop_and_join() { + if (worker) { + worker->stop(); + emit_reports(); + worker.reset(); + } + reporter.reset(); +} From 49d6e4aacaa4f5261ed0893643ed448f4a831db3 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 22 Apr 2026 13:51:13 +0200 Subject: [PATCH 03/19] command channel for the reader thread --- .../interface/uci_command_channel.hpp | 74 +++++++++++++++++++ .../interface/uci_command_channel.cpp | 70 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 include/bitbishop/interface/uci_command_channel.hpp create mode 100644 src/bitbishop/interface/uci_command_channel.cpp diff --git a/include/bitbishop/interface/uci_command_channel.hpp b/include/bitbishop/interface/uci_command_channel.hpp new file mode 100644 index 00000000..913e1106 --- /dev/null +++ b/include/bitbishop/interface/uci_command_channel.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Uci { + +/** + * @brief Thread-safe command line channel for UCI input. + * + * This class owns a reader thread that continuously reads raw lines from an input stream. + * Consuming code can wait and pop lines without dealing with synchronization primitives. + */ +class UciCommandChannel { + struct State { + std::mutex lines_mutex; + std::condition_variable lines_cv; + std::deque pending_lines; + std::atomic eof_reached{false}; + std::atomic stop_requested{false}; + }; + + std::istream& input_stream; + std::thread reader_thread; + std::shared_ptr state; + + /** + * @brief Reader loop running in a dedicated thread. + */ + static void reader_loop(std::istream& input_stream, std::shared_ptr state); + + public: + explicit UciCommandChannel(std::istream& input_stream); + ~UciCommandChannel(); + + UciCommandChannel(const UciCommandChannel&) = delete; + UciCommandChannel& operator=(const UciCommandChannel&) = delete; + + /** + * @brief Starts the reader thread. + */ + void start(); + + /** + * @brief Stops reader resources. + * + * If input is already at EOF, this joins the thread. + * Otherwise, thread is detached because std::getline cannot be forcibly interrupted. + */ + void stop(); + + /** + * @brief Waits for one line and pops it if available. + * + * @param line Destination string receiving one raw input line + * @param timeout Max waiting duration + * @return true if a line was popped, false otherwise + */ + bool wait_and_pop_line(std::string& line, std::chrono::milliseconds timeout); + + /** + * @brief Returns whether input EOF was reached by the reader thread. + */ + [[nodiscard]] bool eof() const; +}; + +} // namespace Uci diff --git a/src/bitbishop/interface/uci_command_channel.cpp b/src/bitbishop/interface/uci_command_channel.cpp new file mode 100644 index 00000000..03537a26 --- /dev/null +++ b/src/bitbishop/interface/uci_command_channel.cpp @@ -0,0 +1,70 @@ +#include + +Uci::UciCommandChannel::UciCommandChannel(std::istream& input_stream) + : input_stream(input_stream), reader_thread(), state(nullptr) {} + +Uci::UciCommandChannel::~UciCommandChannel() { stop(); } + +void Uci::UciCommandChannel::reader_loop(std::istream& input_stream, std::shared_ptr state) { + std::string line; + while (!state->stop_requested.load() && std::getline(input_stream, line)) { + { + std::lock_guard lock(state->lines_mutex); + state->pending_lines.push_back(std::move(line)); + } + state->lines_cv.notify_one(); + } + + state->eof_reached.store(true); + state->lines_cv.notify_all(); +} + +void Uci::UciCommandChannel::start() { + stop(); + + state = std::make_shared(); + reader_thread = std::thread(&UciCommandChannel::reader_loop, std::ref(input_stream), state); +} + +void Uci::UciCommandChannel::stop() { + if (!state) { + return; + } + + state->stop_requested.store(true); + state->lines_cv.notify_all(); + + if (!reader_thread.joinable()) { + state.reset(); + return; + } + + if (state->eof_reached.load()) { + reader_thread.join(); + } else { + reader_thread.detach(); + } + + state.reset(); +} + +bool Uci::UciCommandChannel::wait_and_pop_line(std::string& line, std::chrono::milliseconds timeout) { + if (!state) { + return false; + } + + std::unique_lock lock(state->lines_mutex); + state->lines_cv.wait_for(lock, timeout, [channel_state = state] { + return !channel_state->pending_lines.empty() || channel_state->eof_reached.load(); + }); + + if (state->pending_lines.empty()) { + return false; + } + + line = std::move(state->pending_lines.front()); + state->pending_lines.pop_front(); + return true; +} + +bool Uci::UciCommandChannel::eof() const { return state && state->eof_reached.load(); } From 755a809ad3f47547935d2e1d2a532cb27cc77156 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Wed, 22 Apr 2026 14:30:36 +0200 Subject: [PATCH 04/19] splitted uci engine implementation into a listening thread and a session manager --- .../interface/uci_command_registry.hpp | 34 +++ include/bitbishop/interface/uci_engine.hpp | 74 +----- .../interface/uci_command_registry.cpp | 19 ++ src/bitbishop/interface/uci_engine.cpp | 218 ++++-------------- 4 files changed, 115 insertions(+), 230 deletions(-) create mode 100644 include/bitbishop/interface/uci_command_registry.hpp create mode 100644 src/bitbishop/interface/uci_command_registry.cpp diff --git a/include/bitbishop/interface/uci_command_registry.hpp b/include/bitbishop/interface/uci_command_registry.hpp new file mode 100644 index 00000000..229f63ae --- /dev/null +++ b/include/bitbishop/interface/uci_command_registry.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +namespace Uci { + +/** + * @brief Registry mapping UCI command names to handlers. + */ +class UciCommandRegistry { + public: + using Handler = std::function&)>; + + private: + std::unordered_map handlers; + + public: + /** + * @brief Registers or overrides one command handler. + */ + void register_handler(std::string command, Handler handler); + + /** + * @brief Dispatches the given command line if a handler exists. + * + * @return true when a handler was found and executed, false otherwise. + */ + bool dispatch(std::vector& line) const; +}; + +} // namespace Uci diff --git a/include/bitbishop/interface/uci_engine.hpp b/include/bitbishop/interface/uci_engine.hpp index 22800fd1..9b95b765 100644 --- a/include/bitbishop/interface/uci_engine.hpp +++ b/include/bitbishop/interface/uci_engine.hpp @@ -1,20 +1,14 @@ #pragma once -#include -#include +#include +#include +#include #include -#include -#include -#include -#include #include #include -#include -#include #include #include #include -#include #include namespace Uci { @@ -36,18 +30,14 @@ namespace Uci { * game state management, and search control. */ class UciEngine { - struct InputState; - Board board; ///< Current chess board Position position; ///< Game position associated to the current chess board - std::unique_ptr search_worker_ptr; ///< Manages the search process - std::unique_ptr search_reporter_ptr; ///< Formats search output on the control thread - std::thread input_thread; ///< Dedicated input reader thread - std::shared_ptr input_state; ///< Shared input queue state between control and reader threads + UciCommandChannel command_channel; ///< Command listener (input thread + queue) + SearchSession search_session; ///< Search lifecycle owner (worker + reporter) + UciCommandRegistry command_registry; ///< UCI command -> handler registry bool is_running; - std::istream &in_stream; ///< Input stream for UCI commands - std::ostream &out_stream; ///< Output stream for UCI responses + std::ostream& out_stream; ///< Output stream for UCI responses public: UciEngine() = delete; @@ -97,6 +87,11 @@ class UciEngine { */ void dispatch(std::vector &line); + /** + * @brief Registers all built-in UCI command handlers. + */ + void register_handlers(); + /** * @brief Handles the "uci" command. * @@ -143,51 +138,6 @@ class UciEngine { */ void handle_quit(); - /** - * @brief Stops as soon as possible the search thread and resets it for later computations. - */ - void reset_search_worker(); - - /** - * @brief Requests a stop to the current search worker without waiting. - */ - void request_stop_search_worker(); - - /** - * @brief Finalizes current search worker and reporter if the worker has finished. - */ - void finalize_search_worker_if_done(); - - /** - * @brief Drains pending reports from the current worker and forwards them to the reporter. - */ - void emit_search_reports(); - - /** - * @brief Pumps search reports and cleanup from the control loop. - */ - void poll_search_worker(); - - /** - * @brief Reads commands from input stream in a dedicated thread. - */ - static void input_reader_loop(std::istream& input_stream, std::shared_ptr input_state); - - /** - * @brief Starts the input reader thread. - */ - void start_input_reader(); - - /** - * @brief Stops input reader resources at loop shutdown. - */ - void stop_input_reader_loop(); - - /** - * @brief Pops one pending command or times out. - */ - bool wait_and_pop_command(std::vector& line, std::chrono::milliseconds timeout); - /** * @brief Displays the current board state as well as usefull information. * diff --git a/src/bitbishop/interface/uci_command_registry.cpp b/src/bitbishop/interface/uci_command_registry.cpp new file mode 100644 index 00000000..4ad789d4 --- /dev/null +++ b/src/bitbishop/interface/uci_command_registry.cpp @@ -0,0 +1,19 @@ +#include + +void Uci::UciCommandRegistry::register_handler(std::string command, Handler handler) { + handlers.insert_or_assign(std::move(command), std::move(handler)); +} + +bool Uci::UciCommandRegistry::dispatch(std::vector& line) const { + if (line.empty()) { + return false; + } + + const auto it = handlers.find(line.front()); + if (it == handlers.end()) { + return false; + } + + it->second(line); + return true; +} diff --git a/src/bitbishop/interface/uci_engine.cpp b/src/bitbishop/interface/uci_engine.cpp index 88e07b01..150e2dbc 100644 --- a/src/bitbishop/interface/uci_engine.cpp +++ b/src/bitbishop/interface/uci_engine.cpp @@ -1,15 +1,6 @@ #include #include -#include - -struct Uci::UciEngine::InputState { - std::mutex commands_mutex; - std::condition_variable commands_cv; - std::deque> pending_commands; - std::atomic input_eof{false}; - std::atomic stop_input_reader{false}; -}; [[nodiscard]] std::vector Uci::split(const std::string &str) { std::vector tokens; @@ -24,68 +15,79 @@ struct Uci::UciEngine::InputState { Uci::UciEngine::UciEngine(std::istream &input, std::ostream &output) : board(Board::StartingPosition()), position(Position(this->board)), - search_worker_ptr(nullptr), - search_reporter_ptr(nullptr), - input_thread(), - input_state(nullptr), + command_channel(input), + search_session(output), + command_registry(), is_running(true), - in_stream(input), - out_stream(output) {} + out_stream(output) { + register_handlers(); +} void Uci::UciEngine::loop() { send_startup_msg(); - start_input_reader(); + command_channel.start(); while (is_running) { - poll_search_worker(); + search_session.poll(); - std::vector line; - if (wait_and_pop_command(line, std::chrono::milliseconds(5))) { + std::string raw_line; + if (command_channel.wait_and_pop_line(raw_line, std::chrono::milliseconds(5))) { + std::vector line = split(raw_line); dispatch(line); continue; } - if (input_state && input_state->input_eof.load()) { - request_stop_search_worker(); - poll_search_worker(); - if (!search_worker_ptr) { + if (command_channel.eof()) { + search_session.request_stop(); + search_session.poll(); + if (search_session.is_idle()) { is_running = false; } } } - reset_search_worker(); - stop_input_reader_loop(); + search_session.stop_and_join(); + command_channel.stop(); } void Uci::UciEngine::dispatch(std::vector &line) { - if (line.empty()) { - return; - } + command_registry.dispatch(line); + // unknown lines are discarded silently following uci rules +}; - if (line.front() == "uci") { +void Uci::UciEngine::register_handlers() { + command_registry.register_handler("uci", [this](std::vector& line) { + (void)line; handle_uci(); - } else if (line.front() == "isready") { + }); + command_registry.register_handler("isready", [this](std::vector& line) { + (void)line; out_stream << "readyok\n" << std::flush; - } else if (line.front() == "ucinewgame") { + }); + command_registry.register_handler("ucinewgame", [this](std::vector& line) { + (void)line; handle_new_game(); - } else if (line.front() == "position") { - handle_position(line); - } else if (line.front() == "go") { - handle_go(line); - } else if (line.front() == "stop") { + }); + command_registry.register_handler("position", [this](std::vector& line) { handle_position(line); }); + command_registry.register_handler("go", [this](std::vector& line) { handle_go(line); }); + command_registry.register_handler("stop", [this](std::vector& line) { + (void)line; handle_stop(); - } else if (line.front() == "quit") { + }); + command_registry.register_handler("quit", [this](std::vector& line) { + (void)line; handle_quit(); - } else if (line.front() == "d") { + }); + command_registry.register_handler("d", [this](std::vector& line) { + (void)line; handle_display(); - } else if (line.front() == "help") { + }); + command_registry.register_handler("help", [this](std::vector& line) { + (void)line; handle_help(); - } else if (line.front() == "bench") { - handle_bench(line); - } - // unknown lines are discarded silently following uci rules -}; + }); + command_registry.register_handler("bench", [this](std::vector& line) { handle_bench(line); }); +} void Uci::UciEngine::handle_uci() { out_stream << "id name " << BITBISHOP_PROJECT_NAME << "\n" @@ -144,131 +146,17 @@ void Uci::UciEngine::handle_position(std::vector &line) { } void Uci::UciEngine::handle_go(std::vector &line) { - reset_search_worker(); - SearchLimits limits = SearchLimits::from_uci_cmd(line); - - search_reporter_ptr = std::make_unique(out_stream); - search_worker_ptr = std::make_unique(board, limits); - assert(search_worker_ptr != nullptr); - search_worker_ptr->start(); + search_session.start_go(board, limits); } -void Uci::UciEngine::handle_stop() { request_stop_search_worker(); } +void Uci::UciEngine::handle_stop() { search_session.request_stop(); } void Uci::UciEngine::handle_quit() { - request_stop_search_worker(); + search_session.request_stop(); is_running = false; } -void Uci::UciEngine::reset_search_worker() { - if (search_worker_ptr) { - search_worker_ptr->stop(); - emit_search_reports(); - search_worker_ptr.reset(); - } - - search_reporter_ptr.reset(); - assert(search_worker_ptr == nullptr); -} - -void Uci::UciEngine::request_stop_search_worker() { - if (search_worker_ptr) { - search_worker_ptr->request_stop(); - } -} - -void Uci::UciEngine::finalize_search_worker_if_done() { - if (!search_worker_ptr || !search_worker_ptr->is_finished()) { - return; - } - - search_worker_ptr->wait(); - emit_search_reports(); - search_worker_ptr.reset(); - search_reporter_ptr.reset(); -} - -void Uci::UciEngine::emit_search_reports() { - if (!search_worker_ptr || !search_reporter_ptr) { - return; - } - - const auto reports = search_worker_ptr->drain_reports(); - for (const SearchReport &report : reports) { - if (report.kind == SearchReportKind::Iteration) { - search_reporter_ptr->on_iteration(report.best, report.depth, report.stats); - } else if (report.kind == SearchReportKind::Finish) { - search_reporter_ptr->on_finish(report.best, report.stats); - } - } -} - -void Uci::UciEngine::poll_search_worker() { - emit_search_reports(); - finalize_search_worker_if_done(); -} - -void Uci::UciEngine::input_reader_loop(std::istream &input_stream, std::shared_ptr state) { - std::string input_str; - while (!state->stop_input_reader.load() && std::getline(input_stream, input_str)) { - std::vector line = split(input_str); - { - std::lock_guard lock(state->commands_mutex); - state->pending_commands.push_back(std::move(line)); - } - state->commands_cv.notify_one(); - } - - state->input_eof.store(true); - state->commands_cv.notify_all(); -} - -void Uci::UciEngine::start_input_reader() { - input_state = std::make_shared(); - input_thread = std::thread(&UciEngine::input_reader_loop, std::ref(in_stream), input_state); -} - -void Uci::UciEngine::stop_input_reader_loop() { - if (!input_state) { - return; - } - - input_state->stop_input_reader.store(true); - input_state->commands_cv.notify_all(); - - if (!input_thread.joinable()) { - input_state.reset(); - return; - } - - if (input_state->input_eof.load()) { - input_thread.join(); - } else { - input_thread.detach(); - } - - input_state.reset(); -} - -bool Uci::UciEngine::wait_and_pop_command(std::vector &line, std::chrono::milliseconds timeout) { - if (!input_state) { - return false; - } - - std::unique_lock lock(input_state->commands_mutex); - input_state->commands_cv.wait_for( - lock, timeout, [state = input_state] { return !state->pending_commands.empty() || state->input_eof.load(); }); - - if (input_state->pending_commands.empty()) { - return false; - } - - line = std::move(input_state->pending_commands.front()); - input_state->pending_commands.pop_front(); - return true; -} - void Uci::UciEngine::handle_display() { out_stream << "\n"; out_stream << board << "\n"; @@ -295,12 +183,6 @@ void Uci::UciEngine::send_startup_msg() { } void Uci::UciEngine::handle_bench(std::vector &line) { - reset_search_worker(); - SearchLimits limits = SearchLimits::from_uci_cmd(line); - - search_reporter_ptr = std::make_unique(out_stream); - search_worker_ptr = std::make_unique(board, limits); - assert(search_worker_ptr != nullptr); - search_worker_ptr->start(); + search_session.start_bench(board, limits); } From d060d25d7d0ea19de38fefb914b39180b2b3b48c Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 10:47:15 +0200 Subject: [PATCH 05/19] renamed search reporter file and split implementation in cpp --- include/bitbishop/interface/reporter.hpp | 41 ------- .../bitbishop/interface/search_reporter.hpp | 102 ++++++++++++++++++ src/bitbishop/interface/search_reporter.cpp | 23 ++++ 3 files changed, 125 insertions(+), 41 deletions(-) delete mode 100644 include/bitbishop/interface/reporter.hpp create mode 100644 include/bitbishop/interface/search_reporter.hpp create mode 100644 src/bitbishop/interface/search_reporter.cpp diff --git a/include/bitbishop/interface/reporter.hpp b/include/bitbishop/interface/reporter.hpp deleted file mode 100644 index 2b659dce..00000000 --- a/include/bitbishop/interface/reporter.hpp +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include -#include - -struct SearchReporter { - virtual ~SearchReporter() = default; - - virtual void on_iteration(const Search::BestMove& best, int depth, const Search::SearchStats& stats) {} - - virtual void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) = 0; -}; - -struct UciReporter : SearchReporter { - std::ostream& out_stream; - - UciReporter(std::ostream& out) : out_stream(out) {} - - void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override { - const std::string best_move_str = (best.move) ? (*best.move).to_uci() : "0000"; - out_stream << "bestmove " << best_move_str << "\n"; - out_stream << std::flush; - } -}; - -struct BenchReporter : SearchReporter { - std::ostream& out_stream; - std::chrono::steady_clock::time_point start; - - BenchReporter(std::ostream& out) : out_stream(out), start(std::chrono::steady_clock::now()) {} - - void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override { - auto end = std::chrono::steady_clock::now(); - double seconds = std::chrono::duration(end - start).count(); - - uint64_t total = stats.negamax_nodes + stats.quiescence_nodes; - uint64_t nps = (seconds > 0.0) ? static_cast(static_cast(total) / seconds) : 0; - - out_stream << "bench nodes " << total << " time " << seconds << "s" << " nps " << nps << "\n" << std::flush; - } -}; diff --git a/include/bitbishop/interface/search_reporter.hpp b/include/bitbishop/interface/search_reporter.hpp new file mode 100644 index 00000000..73d3234d --- /dev/null +++ b/include/bitbishop/interface/search_reporter.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include +#include + +/** + * @brief Interface for reporting progress and results of a search. + * + * SearchReporter provides a callback-based mechanism for observing the + * progress of a search as well as its final result. Implementations can + * forward this information to different outputs (e.g., UCI protocol, + * benchmarking logs, GUI, etc.). + */ +struct SearchReporter { + virtual ~SearchReporter() = default; + + /** + * @brief Called after each completed search iteration. + * + * This is typically invoked during iterative deepening to report + * intermediate results. + * + * @param best Current best move found so far. + * @param depth Depth reached in the current iteration. + * @param stats Accumulated search statistics. + */ + virtual void on_iteration(const Search::BestMove& best, int depth, const Search::SearchStats& stats) {} + + /** + * @brief Called once when the search finishes. + * + * Must be implemented by derived classes to handle final reporting. + * + * @param best Final best move found by the search. + * @param stats Final search statistics. + */ + virtual void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) = 0; +}; + +/** + * @brief Reporter that outputs results in UCI (Universal Chess Interface) format. + * + * UciReporter writes the final best move to the provided output stream + * following the UCI protocol specification. Intended for communication + * with chess GUIs or other UCI-compatible tools. + */ +struct UciReporter : SearchReporter { + /** Output stream used for writing UCI messages. */ + std::ostream& out_stream; + + /** + * @brief Constructs a UciReporter. + * + * @param out Output stream where UCI messages will be written. + */ + UciReporter(std::ostream& out); + + /** + * @brief Outputs the final best move in UCI format. + * + * Prints a line of the form: + * "bestmove " + * If no move is available, "0000" is used. + * + * @param best Final best move found by the search. + * @param stats Final search statistics (unused). + */ + void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override; +}; + +/** + * @brief Reporter that outputs benchmarking information. + * + * BenchReporter measures elapsed time from construction to completion + * and reports total nodes searched along with nodes-per-second (NPS). + */ +struct BenchReporter : SearchReporter { + /** Output stream used for writing benchmark results. */ + std::ostream& out_stream; + + /** Start time of the benchmark measurement. */ + std::chrono::steady_clock::time_point start; + + /** + * @brief Constructs a BenchReporter and records the start time. + * + * @param out Output stream where benchmark results will be written. + */ + BenchReporter(std::ostream& out); + + /** + * @brief Outputs benchmark statistics upon search completion. + * + * Computes total nodes searched (negamax + quiescence), elapsed time, + * and nodes per second (NPS), then prints a summary line: + * "bench nodes time s nps " + * + * @param best Final best move found by the search (unused). + * @param stats Final search statistics. + */ + void on_finish(const Search::BestMove& best, const Search::SearchStats& stats) override; +}; diff --git a/src/bitbishop/interface/search_reporter.cpp b/src/bitbishop/interface/search_reporter.cpp new file mode 100644 index 00000000..089bd7ed --- /dev/null +++ b/src/bitbishop/interface/search_reporter.cpp @@ -0,0 +1,23 @@ +#include + +UciReporter::UciReporter(std::ostream& out) : out_stream(out) {} + +void UciReporter::on_finish(const Search::BestMove& best, const Search::SearchStats& stats) { + const std::string best_move_str = (best.move) ? (*best.move).to_uci() : "0000"; + out_stream << "bestmove " << best_move_str << "\n"; + out_stream << std::flush; +} + +BenchReporter::BenchReporter(std::ostream& out) : out_stream(out), start(std::chrono::steady_clock::now()) {} + +void BenchReporter::on_finish(const Search::BestMove& best, const Search::SearchStats& stats) { + auto end = std::chrono::steady_clock::now(); + double seconds = std::chrono::duration(end - start).count(); + + uint64_t total = stats.negamax_nodes + stats.quiescence_nodes; + uint64_t nps = (seconds > 0.0) ? static_cast(static_cast(total) / seconds) : 0; + + out_stream << "bench nodes " << total << " negamax_nodes " << stats.negamax_nodes << " quiescence_nodes " + << stats.quiescence_nodes << " time(s) " << seconds << "s" << " nps " << nps << "\n" + << std::flush; +} From e0350a19e038ed449b984682db25e92fec638217 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 10:47:29 +0200 Subject: [PATCH 06/19] following reporter file renaming --- include/bitbishop/interface/search_session.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/bitbishop/interface/search_session.hpp b/include/bitbishop/interface/search_session.hpp index 3584a559..4f68c5de 100644 --- a/include/bitbishop/interface/search_session.hpp +++ b/include/bitbishop/interface/search_session.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include From 6ad1f0c718a43e32814b12d6632e8252772ef89e Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 11:01:33 +0200 Subject: [PATCH 07/19] fixed linting issues --- .clang-tidy | 4 ++-- include/bitbishop/interface/search_controller.hpp | 2 +- src/bitbishop/interface/search_controller.cpp | 2 +- src/bitbishop/interface/search_session.cpp | 2 +- src/bitbishop/interface/uci_command_channel.cpp | 4 ++-- src/bitbishop/interface/uci_engine.cpp | 5 ++++- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 4636b7e0..dfd97729 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -11,8 +11,8 @@ WarningsAsErrors: "*" CheckOptions: - key: readability-identifier-length.IgnoredVariableNames - value: "^(sq|to|from|bb|us|A[1-8]|B[1-8]|C[1-8]|D[1-8]|E[1-8]|F[1-8]|G[1-8]|H[1-8])$" + value: "^(sq|to|from|bb|us|it|A[1-8]|B[1-8]|C[1-8]|D[1-8]|E[1-8]|F[1-8]|G[1-8]|H[1-8])$" - key: readability-identifier-length.IgnoredParameterNames - value: "^(sq|to|from|bb|us)$" + value: "^(sq|to|from|bb|us|it)$" - key: readability-identifier-length.IgnoredLoopCounterNames value: "^(r|f|i)$" diff --git a/include/bitbishop/interface/search_controller.hpp b/include/bitbishop/interface/search_controller.hpp index 02d8e0e2..6026ba4a 100644 --- a/include/bitbishop/interface/search_controller.hpp +++ b/include/bitbishop/interface/search_controller.hpp @@ -37,7 +37,7 @@ struct SearchLimits { * * Search workers only publish data. Reporting/printing is done by the UCI control thread. */ -enum class SearchReportKind { +enum class SearchReportKind : std::uint8_t { Iteration, Finish, }; diff --git a/src/bitbishop/interface/search_controller.cpp b/src/bitbishop/interface/search_controller.cpp index 0d56e75e..7de95af0 100644 --- a/src/bitbishop/interface/search_controller.cpp +++ b/src/bitbishop/interface/search_controller.cpp @@ -43,7 +43,7 @@ Uci::SearchWorker::~SearchWorker() { stop(); } void Uci::SearchWorker::push_report(SearchReport report) { std::lock_guard lock(reports_mutex); - reports.push_back(std::move(report)); + reports.push_back(report); } void Uci::SearchWorker::run() { diff --git a/src/bitbishop/interface/search_session.cpp b/src/bitbishop/interface/search_session.cpp index 2971f554..d61ee9cb 100644 --- a/src/bitbishop/interface/search_session.cpp +++ b/src/bitbishop/interface/search_session.cpp @@ -20,7 +20,7 @@ void Uci::SearchSession::start_bench(Board board, SearchLimits limits) { stop_and_join(); if (limits.infinite) { - limits.depth = 10; + limits.depth = 10; // NOLINT(readability-magic-numbers) limits.infinite = false; } diff --git a/src/bitbishop/interface/uci_command_channel.cpp b/src/bitbishop/interface/uci_command_channel.cpp index 03537a26..a0ddfb8d 100644 --- a/src/bitbishop/interface/uci_command_channel.cpp +++ b/src/bitbishop/interface/uci_command_channel.cpp @@ -1,10 +1,10 @@ #include -Uci::UciCommandChannel::UciCommandChannel(std::istream& input_stream) - : input_stream(input_stream), reader_thread(), state(nullptr) {} +Uci::UciCommandChannel::UciCommandChannel(std::istream& input_stream) : input_stream(input_stream), state(nullptr) {} Uci::UciCommandChannel::~UciCommandChannel() { stop(); } +// NOLINTNEXTLINE(performance-unnecessary-value-param) void Uci::UciCommandChannel::reader_loop(std::istream& input_stream, std::shared_ptr state) { std::string line; while (!state->stop_requested.load() && std::getline(input_stream, line)) { diff --git a/src/bitbishop/interface/uci_engine.cpp b/src/bitbishop/interface/uci_engine.cpp index 150e2dbc..8c1451c2 100644 --- a/src/bitbishop/interface/uci_engine.cpp +++ b/src/bitbishop/interface/uci_engine.cpp @@ -24,6 +24,8 @@ Uci::UciEngine::UciEngine(std::istream &input, std::ostream &output) } void Uci::UciEngine::loop() { + constexpr const std::chrono::milliseconds LINE_POLL_INTERVAL_MS(5); + send_startup_msg(); command_channel.start(); @@ -31,7 +33,8 @@ void Uci::UciEngine::loop() { search_session.poll(); std::string raw_line; - if (command_channel.wait_and_pop_line(raw_line, std::chrono::milliseconds(5))) { + const bool line_was_popped = command_channel.wait_and_pop_line(raw_line, LINE_POLL_INTERVAL_MS); + if (line_was_popped) { std::vector line = split(raw_line); dispatch(line); continue; From cab7061479fad60cc4838360b72caaa7c08a4585 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 11:03:48 +0200 Subject: [PATCH 08/19] renamed search controller into search worker --- include/bitbishop/interface/search_session.hpp | 2 +- .../{search_controller.hpp => search_worker.hpp} | 0 .../{search_controller.cpp => search_worker.cpp} | 2 +- tests/bitbishop/interface/test_search_limits.cpp | 2 +- ..._search_controller.cpp => test_search_worker.cpp} | 12 +++++++----- 5 files changed, 10 insertions(+), 8 deletions(-) rename include/bitbishop/interface/{search_controller.hpp => search_worker.hpp} (100%) rename src/bitbishop/interface/{search_controller.cpp => search_worker.cpp} (98%) rename tests/bitbishop/interface/{test_search_controller.cpp => test_search_worker.cpp} (71%) diff --git a/include/bitbishop/interface/search_session.hpp b/include/bitbishop/interface/search_session.hpp index 4f68c5de..961d40a9 100644 --- a/include/bitbishop/interface/search_session.hpp +++ b/include/bitbishop/interface/search_session.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include diff --git a/include/bitbishop/interface/search_controller.hpp b/include/bitbishop/interface/search_worker.hpp similarity index 100% rename from include/bitbishop/interface/search_controller.hpp rename to include/bitbishop/interface/search_worker.hpp diff --git a/src/bitbishop/interface/search_controller.cpp b/src/bitbishop/interface/search_worker.cpp similarity index 98% rename from src/bitbishop/interface/search_controller.cpp rename to src/bitbishop/interface/search_worker.cpp index 7de95af0..15631a3e 100644 --- a/src/bitbishop/interface/search_controller.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,4 +1,4 @@ -#include +#include Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(std::vector& line) { SearchLimits limits; diff --git a/tests/bitbishop/interface/test_search_limits.cpp b/tests/bitbishop/interface/test_search_limits.cpp index 0441a528..f3f09a7b 100644 --- a/tests/bitbishop/interface/test_search_limits.cpp +++ b/tests/bitbishop/interface/test_search_limits.cpp @@ -1,6 +1,6 @@ #include -#include +#include TEST(SearchLimitsTest, DefaultsAreEmptyAndNotInfinite) { Uci::SearchLimits limits; diff --git a/tests/bitbishop/interface/test_search_controller.cpp b/tests/bitbishop/interface/test_search_worker.cpp similarity index 71% rename from tests/bitbishop/interface/test_search_controller.cpp rename to tests/bitbishop/interface/test_search_worker.cpp index 80ffa81a..96aa55c3 100644 --- a/tests/bitbishop/interface/test_search_controller.cpp +++ b/tests/bitbishop/interface/test_search_worker.cpp @@ -1,7 +1,7 @@ #include -#include #include +#include #include #include @@ -18,8 +18,9 @@ TEST(SearchControllerTest, StartPublishesFinishReportWithDepth1) { ASSERT_FALSE(reports.empty()); EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); - EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), - [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); + EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { + return report.kind == Uci::SearchReportKind::Iteration; + })); } TEST(SearchControllerTest, StartPublishesFinishReportWithInfiniteSearch) { @@ -36,6 +37,7 @@ TEST(SearchControllerTest, StartPublishesFinishReportWithInfiniteSearch) { ASSERT_FALSE(reports.empty()); EXPECT_EQ(reports.back().kind, Uci::SearchReportKind::Finish); EXPECT_TRUE(reports.back().best.move.has_value()); - EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), - [](const Uci::SearchReport& report) { return report.kind == Uci::SearchReportKind::Iteration; })); + EXPECT_TRUE(std::any_of(reports.begin(), reports.end(), [](const Uci::SearchReport& report) { + return report.kind == Uci::SearchReportKind::Iteration; + })); } From 328f2f8dbe9edaa97af54a5a3e34128ede14385f Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 11:31:09 +0200 Subject: [PATCH 09/19] switched uci line argument to const --- include/bitbishop/interface/search_worker.hpp | 2 +- .../interface/uci_command_registry.hpp | 4 +-- include/bitbishop/interface/uci_engine.hpp | 8 ++--- src/bitbishop/interface/search_worker.cpp | 2 +- .../interface/uci_command_registry.cpp | 2 +- src/bitbishop/interface/uci_engine.cpp | 29 ++++++++++--------- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/include/bitbishop/interface/search_worker.hpp b/include/bitbishop/interface/search_worker.hpp index 6026ba4a..23c7eb28 100644 --- a/include/bitbishop/interface/search_worker.hpp +++ b/include/bitbishop/interface/search_worker.hpp @@ -29,7 +29,7 @@ struct SearchLimits { * * @return The built SearchLimits object. */ - static SearchLimits from_uci_cmd(std::vector& line); + static SearchLimits from_uci_cmd(const std::vector& line); }; /** diff --git a/include/bitbishop/interface/uci_command_registry.hpp b/include/bitbishop/interface/uci_command_registry.hpp index 229f63ae..a8ac6c8c 100644 --- a/include/bitbishop/interface/uci_command_registry.hpp +++ b/include/bitbishop/interface/uci_command_registry.hpp @@ -12,7 +12,7 @@ namespace Uci { */ class UciCommandRegistry { public: - using Handler = std::function&)>; + using Handler = std::function&)>; private: std::unordered_map handlers; @@ -28,7 +28,7 @@ class UciCommandRegistry { * * @return true when a handler was found and executed, false otherwise. */ - bool dispatch(std::vector& line) const; + bool dispatch(const std::vector& line) const; }; } // namespace Uci diff --git a/include/bitbishop/interface/uci_engine.hpp b/include/bitbishop/interface/uci_engine.hpp index 9b95b765..a7f463df 100644 --- a/include/bitbishop/interface/uci_engine.hpp +++ b/include/bitbishop/interface/uci_engine.hpp @@ -85,7 +85,7 @@ class UciEngine { * * @param line The input command tokens to process */ - void dispatch(std::vector &line); + void dispatch(const std::vector &line); /** * @brief Registers all built-in UCI command handlers. @@ -113,7 +113,7 @@ class UciEngine { * * @param line The input command tokens containing the position information */ - void handle_position(std::vector &line); + void handle_position(const std::vector &line); /** * @brief Parses and handles "go" commands. @@ -122,7 +122,7 @@ class UciEngine { * * @param line The input command tokens containing the search parameters */ - void handle_go(std::vector &line); + void handle_go(const std::vector &line); /** * @brief Handles the "stop" command. @@ -165,7 +165,7 @@ class UciEngine { * * @param line The input command tokens containing the benchmark information */ - void handle_bench(std::vector &line); + void handle_bench(const std::vector &line); }; } // namespace Uci diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index 15631a3e..d2c41279 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -1,6 +1,6 @@ #include -Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(std::vector& line) { +Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector& line) { SearchLimits limits; for (std::size_t i = 1; i < line.size(); ++i) { diff --git a/src/bitbishop/interface/uci_command_registry.cpp b/src/bitbishop/interface/uci_command_registry.cpp index 4ad789d4..26a0b95d 100644 --- a/src/bitbishop/interface/uci_command_registry.cpp +++ b/src/bitbishop/interface/uci_command_registry.cpp @@ -4,7 +4,7 @@ void Uci::UciCommandRegistry::register_handler(std::string command, Handler hand handlers.insert_or_assign(std::move(command), std::move(handler)); } -bool Uci::UciCommandRegistry::dispatch(std::vector& line) const { +bool Uci::UciCommandRegistry::dispatch(const std::vector& line) const { if (line.empty()) { return false; } diff --git a/src/bitbishop/interface/uci_engine.cpp b/src/bitbishop/interface/uci_engine.cpp index 8c1451c2..60ca2e2f 100644 --- a/src/bitbishop/interface/uci_engine.cpp +++ b/src/bitbishop/interface/uci_engine.cpp @@ -53,43 +53,44 @@ void Uci::UciEngine::loop() { command_channel.stop(); } -void Uci::UciEngine::dispatch(std::vector &line) { +void Uci::UciEngine::dispatch(const std::vector& line) { command_registry.dispatch(line); // unknown lines are discarded silently following uci rules }; void Uci::UciEngine::register_handlers() { - command_registry.register_handler("uci", [this](std::vector& line) { + command_registry.register_handler("uci", [this](const std::vector& line) { (void)line; handle_uci(); }); - command_registry.register_handler("isready", [this](std::vector& line) { + command_registry.register_handler("isready", [this](const std::vector& line) { (void)line; out_stream << "readyok\n" << std::flush; }); - command_registry.register_handler("ucinewgame", [this](std::vector& line) { + command_registry.register_handler("ucinewgame", [this](const std::vector& line) { (void)line; handle_new_game(); }); - command_registry.register_handler("position", [this](std::vector& line) { handle_position(line); }); - command_registry.register_handler("go", [this](std::vector& line) { handle_go(line); }); - command_registry.register_handler("stop", [this](std::vector& line) { + command_registry.register_handler("position", + [this](const std::vector& line) { handle_position(line); }); + command_registry.register_handler("go", [this](const std::vector& line) { handle_go(line); }); + command_registry.register_handler("stop", [this](const std::vector& line) { (void)line; handle_stop(); }); - command_registry.register_handler("quit", [this](std::vector& line) { + command_registry.register_handler("quit", [this](const std::vector& line) { (void)line; handle_quit(); }); - command_registry.register_handler("d", [this](std::vector& line) { + command_registry.register_handler("d", [this](const std::vector& line) { (void)line; handle_display(); }); - command_registry.register_handler("help", [this](std::vector& line) { + command_registry.register_handler("help", [this](const std::vector& line) { (void)line; handle_help(); }); - command_registry.register_handler("bench", [this](std::vector& line) { handle_bench(line); }); + command_registry.register_handler("bench", [this](const std::vector& line) { handle_bench(line); }); } void Uci::UciEngine::handle_uci() { @@ -104,7 +105,7 @@ void Uci::UciEngine::handle_new_game() { position.reset(); } -void Uci::UciEngine::handle_position(std::vector &line) { +void Uci::UciEngine::handle_position(const std::vector& line) { using namespace Const; if (line.size() < 2) { @@ -148,7 +149,7 @@ void Uci::UciEngine::handle_position(std::vector &line) { } } -void Uci::UciEngine::handle_go(std::vector &line) { +void Uci::UciEngine::handle_go(const std::vector& line) { SearchLimits limits = SearchLimits::from_uci_cmd(line); search_session.start_go(board, limits); } @@ -185,7 +186,7 @@ void Uci::UciEngine::send_startup_msg() { out_stream << BITBISHOP_PROJECT_NAME << " " << BITBISHOP_VERSION << " " << "by Hardcode3 (Baptiste Penot).\n"; } -void Uci::UciEngine::handle_bench(std::vector &line) { +void Uci::UciEngine::handle_bench(const std::vector& line) { SearchLimits limits = SearchLimits::from_uci_cmd(line); search_session.start_bench(board, limits); } From 5e4ad42efa8ac0ba54f47efdc49ee25c020f0205 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 12:16:12 +0200 Subject: [PATCH 10/19] added tests for the uci command registry --- .../interface/uci_command_registry.hpp | 9 ++- .../interface/test_uci_command_registry.cpp | 73 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/bitbishop/interface/test_uci_command_registry.cpp diff --git a/include/bitbishop/interface/uci_command_registry.hpp b/include/bitbishop/interface/uci_command_registry.hpp index a8ac6c8c..757403e5 100644 --- a/include/bitbishop/interface/uci_command_registry.hpp +++ b/include/bitbishop/interface/uci_command_registry.hpp @@ -28,7 +28,14 @@ class UciCommandRegistry { * * @return true when a handler was found and executed, false otherwise. */ - bool dispatch(const std::vector& line) const; + [[nodiscard]] bool dispatch(const std::vector& line) const; + + /** + * @brief Retrieves the number of handlers currently registered. + * + * @return Number of handlers registered in the UciCommandRegistry. + */ + [[nodiscard]] std::size_t get_handlers_count() const noexcept { return handlers.size(); } }; } // namespace Uci diff --git a/tests/bitbishop/interface/test_uci_command_registry.cpp b/tests/bitbishop/interface/test_uci_command_registry.cpp new file mode 100644 index 00000000..8c6135ae --- /dev/null +++ b/tests/bitbishop/interface/test_uci_command_registry.cpp @@ -0,0 +1,73 @@ +#include + +#include + +using namespace Uci; + +TEST(UciCommandRegistryTest, RegistersCorreclyHandlerFunction) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + + EXPECT_EQ(cmd_registry.get_handlers_count(), 0); + + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + + EXPECT_EQ(cmd_registry.get_handlers_count(), 1); +} + +TEST(UciCommandRegistryTest, RegistersMultipleTimesOverridesOldHandler) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + constexpr const std::size_t REGISTER_COUNT = 5; + + EXPECT_EQ(cmd_registry.get_handlers_count(), 0); + + for (std::size_t i = 0; i < REGISTER_COUNT; ++i) { + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + EXPECT_EQ(cmd_registry.get_handlers_count(), 1); + } +} + +TEST(UciCommandRegistryTest, DispatchesCorreclyHandlerFunction) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + + EXPECT_EQ(counter, 0); + + const std::vector test_command{test_command_name, "some", "arguments"}; + cmd_registry.dispatch(test_command); + + EXPECT_EQ(counter, 1); +} + +TEST(UciCommandRegistryTest, DispatchesDoesNothingWithNonExistingCommand) { + UciCommandRegistry cmd_registry; + std::size_t counter = 0; + const std::string test_command_name = "test_cmd"; + + cmd_registry.register_handler(test_command_name, [&counter](const std::vector& line) { + (void)line; + ++counter; + }); + + EXPECT_EQ(counter, 0); + + const std::vector test_command{"unknown", "some", "arguments"}; + cmd_registry.dispatch(test_command); + + EXPECT_EQ(counter, 0); +} From abc5c859050190969a652d7ffca2fe4c1aacbbf2 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 12:16:32 +0200 Subject: [PATCH 11/19] added tests for the search limits command line parsing --- src/bitbishop/interface/search_worker.cpp | 5 +- .../interface/test_search_limits.cpp | 172 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/bitbishop/interface/search_worker.cpp b/src/bitbishop/interface/search_worker.cpp index d2c41279..414be7dc 100644 --- a/src/bitbishop/interface/search_worker.cpp +++ b/src/bitbishop/interface/search_worker.cpp @@ -29,8 +29,9 @@ Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector } } - if (!limits.depth) { - limits.infinite = true; // Only depth and infinite limits are supported for now + const bool has_time_control = limits.movetime || limits.wtime || limits.btime || limits.winc || limits.binc; + if (!limits.depth && !has_time_control && !limits.infinite) { + limits.infinite = true; } return limits; diff --git a/tests/bitbishop/interface/test_search_limits.cpp b/tests/bitbishop/interface/test_search_limits.cpp index f3f09a7b..b2f94ab6 100644 --- a/tests/bitbishop/interface/test_search_limits.cpp +++ b/tests/bitbishop/interface/test_search_limits.cpp @@ -13,3 +13,175 @@ TEST(SearchLimitsTest, DefaultsAreEmptyAndNotInfinite) { EXPECT_FALSE(limits.binc.has_value()); EXPECT_FALSE(limits.infinite); } + +struct SearchLimitsFromUciTestCase { + std::string test_name; + std::vector command_line; + Uci::SearchLimits expected; +}; + +struct SearchLimitsParamName { + template + std::string operator()(const testing::TestParamInfo& info) const { + return info.param.test_name; + } +}; + +class SearchLimitsFromUciTest : public ::testing::TestWithParam {}; + +TEST_P(SearchLimitsFromUciTest, ParsesCorrectly) { + const auto& param = GetParam(); + auto result = Uci::SearchLimits::from_uci_cmd(param.command_line); + + EXPECT_EQ(result.depth, param.expected.depth); + EXPECT_EQ(result.movetime, param.expected.movetime); + EXPECT_EQ(result.wtime, param.expected.wtime); + EXPECT_EQ(result.btime, param.expected.btime); + EXPECT_EQ(result.winc, param.expected.winc); + EXPECT_EQ(result.binc, param.expected.binc); + EXPECT_EQ(result.infinite, param.expected.infinite); +} + +// clang-format off +INSTANTIATE_TEST_SUITE_P( + SearchLimitsFromUci, + SearchLimitsFromUciTest, + ::testing::Values( + // Empty / malformed input -> implicit infinite + SearchLimitsFromUciTestCase{ + "EmptyCommand", + {"go"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = true + } + }, + + // Explicit infinite + SearchLimitsFromUciTestCase{ + "ExplicitInfinite", + {"go", "infinite"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = true + } + }, + + // Depth only -> NOT infinite + SearchLimitsFromUciTestCase{ + "DepthOnly", + {"go", "depth", "10"}, + Uci::SearchLimits{ + .depth = 10, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + }, + + // Explicit infinite overrides depth + SearchLimitsFromUciTestCase{ + "DepthAndInfinite", + {"go", "depth", "8", "infinite"}, + Uci::SearchLimits{ + .depth = 8, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = true + } + }, + + // Explicit infinite overrides time controls + SearchLimitsFromUciTestCase{ + "TimeAndInfinite", + {"go", "wtime", "10000", "btime", "8000", "winc", "100", "binc", "200", "infinite"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = 10'000, + .btime = 8'000, + .winc = 100, + .binc = 200, + .infinite = true + } + }, + + // Movetime only -> NOT infinite + SearchLimitsFromUciTestCase{ + "MovetimeOnly", + {"go", "movetime", "5000"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = 5'000, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + }, + + // Full time controls -> NOT infinite + SearchLimitsFromUciTestCase{ + "TimeControls", + {"go", "wtime", "10000", "btime", "8000", "winc", "100", "binc", "200"}, + Uci::SearchLimits{ + .depth = std::nullopt, + .movetime = std::nullopt, + .wtime = 10000, + .btime = 8000, + .winc = 100, + .binc = 200, + .infinite = false + } + }, + + // Mixed depth + time → depth wins -> NOT infinite + SearchLimitsFromUciTestCase{ + "DepthAndTime", + {"go", "depth", "12", "wtime", "10000"}, + Uci::SearchLimits{ + .depth = 12, + .movetime = std::nullopt, + .wtime = 10000, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + }, + + // Unknown tokens ignored + SearchLimitsFromUciTestCase{ + "UnknownTokensIgnored", + {"go", "foo", "bar", "depth", "6"}, + Uci::SearchLimits{ + .depth = 6, + .movetime = std::nullopt, + .wtime = std::nullopt, + .btime = std::nullopt, + .winc = std::nullopt, + .binc = std::nullopt, + .infinite = false + } + } + ), + SearchLimitsParamName{} +); +// clang-format on From 70c06c99fb9c986c42caaf3ebbca19032ba9b083 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 13:20:18 +0200 Subject: [PATCH 12/19] added search stats defaults --- include/bitbishop/engine/search.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/bitbishop/engine/search.hpp b/include/bitbishop/engine/search.hpp index 5bb28b82..82d23400 100644 --- a/include/bitbishop/engine/search.hpp +++ b/include/bitbishop/engine/search.hpp @@ -13,8 +13,8 @@ namespace Search { * @brief Contains statistics about a best move search. */ struct SearchStats { - uint64_t negamax_nodes; ///< Number of explored negamax nodes - uint64_t quiescence_nodes; ///< Number of explored quiescence nodes + uint64_t negamax_nodes = 0; ///< Number of explored negamax nodes + uint64_t quiescence_nodes = 0; ///< Number of explored quiescence nodes }; // We implement negamax with alpha-beta by flipping the window at each ply: From b97feaac707c9880534e68d3f70d8d0bef76d8be Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 13:20:49 +0200 Subject: [PATCH 13/19] fixed compiler warnings --- src/bitbishop/interface/uci_engine.cpp | 2 +- tests/bitbishop/interface/test_uci_command_registry.cpp | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bitbishop/interface/uci_engine.cpp b/src/bitbishop/interface/uci_engine.cpp index 60ca2e2f..499b9a37 100644 --- a/src/bitbishop/interface/uci_engine.cpp +++ b/src/bitbishop/interface/uci_engine.cpp @@ -54,7 +54,7 @@ void Uci::UciEngine::loop() { } void Uci::UciEngine::dispatch(const std::vector& line) { - command_registry.dispatch(line); + std::ignore = command_registry.dispatch(line); // unknown lines are discarded silently following uci rules }; diff --git a/tests/bitbishop/interface/test_uci_command_registry.cpp b/tests/bitbishop/interface/test_uci_command_registry.cpp index 8c6135ae..9a17563b 100644 --- a/tests/bitbishop/interface/test_uci_command_registry.cpp +++ b/tests/bitbishop/interface/test_uci_command_registry.cpp @@ -49,8 +49,9 @@ TEST(UciCommandRegistryTest, DispatchesCorreclyHandlerFunction) { EXPECT_EQ(counter, 0); const std::vector test_command{test_command_name, "some", "arguments"}; - cmd_registry.dispatch(test_command); + const bool dispatch_result = cmd_registry.dispatch(test_command); + EXPECT_TRUE(dispatch_result); EXPECT_EQ(counter, 1); } @@ -67,7 +68,8 @@ TEST(UciCommandRegistryTest, DispatchesDoesNothingWithNonExistingCommand) { EXPECT_EQ(counter, 0); const std::vector test_command{"unknown", "some", "arguments"}; - cmd_registry.dispatch(test_command); + const bool dispatch_result = cmd_registry.dispatch(test_command); + EXPECT_FALSE(dispatch_result); EXPECT_EQ(counter, 0); } From 44a4cddd15878be59a46d520d83a0e8b2b6c72c7 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 13:21:04 +0200 Subject: [PATCH 14/19] improvements and tests for search reporters --- .../bitbishop/interface/search_reporter.hpp | 16 ++++- src/bitbishop/interface/search_reporter.cpp | 12 +++- .../interface/test_search_reporter.cpp | 66 +++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tests/bitbishop/interface/test_search_reporter.cpp diff --git a/include/bitbishop/interface/search_reporter.hpp b/include/bitbishop/interface/search_reporter.hpp index 73d3234d..50dbc42d 100644 --- a/include/bitbishop/interface/search_reporter.hpp +++ b/include/bitbishop/interface/search_reporter.hpp @@ -2,6 +2,7 @@ #include #include +#include /** * @brief Interface for reporting progress and results of a search. @@ -45,9 +46,11 @@ struct SearchReporter { * with chess GUIs or other UCI-compatible tools. */ struct UciReporter : SearchReporter { + private: /** Output stream used for writing UCI messages. */ std::ostream& out_stream; + public: /** * @brief Constructs a UciReporter. * @@ -75,18 +78,29 @@ struct UciReporter : SearchReporter { * and reports total nodes searched along with nodes-per-second (NPS). */ struct BenchReporter : SearchReporter { + using Clock = std::chrono::steady_clock; + + public: + /** Injectable time source. Mainly for testing. + * Must be declared *before* start si + */ + std::function now; + + private: /** Output stream used for writing benchmark results. */ std::ostream& out_stream; /** Start time of the benchmark measurement. */ - std::chrono::steady_clock::time_point start; + Clock::time_point start; + public: /** * @brief Constructs a BenchReporter and records the start time. * * @param out Output stream where benchmark results will be written. */ BenchReporter(std::ostream& out); + BenchReporter(std::ostream& out, std::function now_fn); /** * @brief Outputs benchmark statistics upon search completion. diff --git a/src/bitbishop/interface/search_reporter.cpp b/src/bitbishop/interface/search_reporter.cpp index 089bd7ed..96f2c0cc 100644 --- a/src/bitbishop/interface/search_reporter.cpp +++ b/src/bitbishop/interface/search_reporter.cpp @@ -1,4 +1,5 @@ #include +#include UciReporter::UciReporter(std::ostream& out) : out_stream(out) {} @@ -8,11 +9,16 @@ void UciReporter::on_finish(const Search::BestMove& best, const Search::SearchSt out_stream << std::flush; } -BenchReporter::BenchReporter(std::ostream& out) : out_stream(out), start(std::chrono::steady_clock::now()) {} +BenchReporter::BenchReporter(std::ostream& out) : BenchReporter(out, Clock::now) {} + +BenchReporter::BenchReporter(std::ostream& out, std::function now_fn) + : out_stream(out), now(std::move(now_fn)), start(now()) {} void BenchReporter::on_finish(const Search::BestMove& best, const Search::SearchStats& stats) { - auto end = std::chrono::steady_clock::now(); - double seconds = std::chrono::duration(end - start).count(); + using Duration = std::chrono::duration; + + auto end = now(); + double seconds = Duration(end - start).count(); uint64_t total = stats.negamax_nodes + stats.quiescence_nodes; uint64_t nps = (seconds > 0.0) ? static_cast(static_cast(total) / seconds) : 0; diff --git a/tests/bitbishop/interface/test_search_reporter.cpp b/tests/bitbishop/interface/test_search_reporter.cpp new file mode 100644 index 00000000..92612131 --- /dev/null +++ b/tests/bitbishop/interface/test_search_reporter.cpp @@ -0,0 +1,66 @@ +#include + +#include +#include +#include + +using namespace Search; + +class SearchReporterTest : public ::testing::Test { + protected: + Move fake_move = Move::from_uci("e2e4"); + + BestMove best_move{.move = fake_move}; + BestMove empty_move{.move = std::nullopt}; + + SearchStats stats{.negamax_nodes = 120, .quiescence_nodes = 80}; + + std::ostringstream out; +}; + +TEST_F(SearchReporterTest, UciOutputsBestMoveWhenPresent) { + UciReporter reporter(out); + + reporter.on_finish(best_move, stats); + + EXPECT_EQ(out.str(), "bestmove " + best_move.move->to_uci() + "\n"); +} + +TEST_F(SearchReporterTest, UciOutputsNullMoveWhenMissing) { + UciReporter reporter(out); + + reporter.on_finish(empty_move, stats); + + EXPECT_EQ(out.str(), "bestmove 0000\n"); +} + +TEST_F(SearchReporterTest, BenchOutputsCorrectNodeAggregation) { + BenchReporter reporter(out); + + reporter.on_finish(best_move, stats); + + const std::string result = out.str(); + + const uint64_t total_nodes = stats.negamax_nodes + stats.quiescence_nodes; + + EXPECT_NE(result.find("bench nodes " + std::to_string(total_nodes)), std::string::npos); + + EXPECT_NE(result.find("negamax_nodes " + std::to_string(stats.negamax_nodes)), std::string::npos); + + EXPECT_NE(result.find("quiescence_nodes " + std::to_string(stats.quiescence_nodes)), std::string::npos); + + EXPECT_NE(result.find("time(s)"), std::string::npos); + EXPECT_NE(result.find("nps"), std::string::npos); +} + +TEST_F(SearchReporterTest, BenchHandlesZeroTimeGracefully) { + const auto start_time = std::chrono::steady_clock::now(); + + BenchReporter reporter(out, [start_time]() { return start_time; }); + reporter.on_finish(best_move, stats); + + const std::string result = out.str(); + + EXPECT_NE(result.find("bench nodes "), std::string::npos); + EXPECT_NE(result.find("nps "), std::string::npos); +} From c36130eabf9992756393484101699f33ceff905c Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 15:45:01 +0200 Subject: [PATCH 15/19] added tests for uci command channel --- .../interface/test_uci_command_channel.cpp | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/bitbishop/interface/test_uci_command_channel.cpp diff --git a/tests/bitbishop/interface/test_uci_command_channel.cpp b/tests/bitbishop/interface/test_uci_command_channel.cpp new file mode 100644 index 00000000..30120fd9 --- /dev/null +++ b/tests/bitbishop/interface/test_uci_command_channel.cpp @@ -0,0 +1,93 @@ +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace { + +template +bool wait_for(Predicate&& pred, std::chrono::milliseconds timeout = 300ms, std::chrono::milliseconds interval = 5ms) { + const auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < timeout) { + if (pred()) { + return true; + } + std::this_thread::sleep_for(interval); + } + return pred(); +} + +} // namespace + +TEST(UciCommandChannelTest, WaitAndPopReturnsFalseWhenNotStarted) { + std::istringstream input("uci\n"); + Uci::UciCommandChannel channel(input); + // UciCommandChannel was not started so nothing is listening for commands + + std::string line; + EXPECT_FALSE(channel.wait_and_pop_line(line, 5ms)); + EXPECT_FALSE(channel.eof()); +} + +TEST(UciCommandChannelTest, ReadsCommandsInOrderAndSignalsEof) { + std::istringstream input("uci\nisready\n"); + Uci::UciCommandChannel channel(input); + + channel.start(); + + std::string line; + ASSERT_TRUE(channel.wait_and_pop_line(line, 100ms)); + EXPECT_EQ(line, "uci"); + + ASSERT_TRUE(channel.wait_and_pop_line(line, 100ms)); + EXPECT_EQ(line, "isready"); + + ASSERT_TRUE(wait_for([&] { return channel.eof(); })); + EXPECT_FALSE(channel.wait_and_pop_line(line, 20ms)); + + channel.stop(); +} + +TEST(UciCommandChannelTest, WaitAndPopTimesOutWhenNoInputYet) { + BlockingIStream input; // needs input.close() to signal eof + Uci::UciCommandChannel channel(input); + + channel.start(); + + std::string line; + EXPECT_FALSE(channel.wait_and_pop_line(line, 20ms)); + EXPECT_FALSE(channel.eof()); + + input.close(); + ASSERT_TRUE(wait_for([&] { return channel.eof(); })); + channel.stop(); +} + +TEST(UciCommandChannelTest, ReceivesCommandProducedAfterStart) { + BlockingIStream input; // needs input.close() to signal eof + Uci::UciCommandChannel channel(input); + + channel.start(); + + std::thread producer([&input] { + std::this_thread::sleep_for(10ms); + input.write("go depth 4\n"); + input.close(); + }); + + std::string line; + ASSERT_TRUE(channel.wait_and_pop_line(line, 200ms)); + EXPECT_EQ(line, "go depth 4"); + + ASSERT_TRUE(wait_for([&] { return channel.eof(); })); + EXPECT_FALSE(channel.wait_and_pop_line(line, 20ms)); + + producer.join(); + channel.stop(); +} From 1837972f158aabcaa86baeb298bb4b867c891e8b Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 17:55:11 +0200 Subject: [PATCH 16/19] small refactor to avoid unreachable branch --- .../interface/uci_command_channel.cpp | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/bitbishop/interface/uci_command_channel.cpp b/src/bitbishop/interface/uci_command_channel.cpp index a0ddfb8d..051b51eb 100644 --- a/src/bitbishop/interface/uci_command_channel.cpp +++ b/src/bitbishop/interface/uci_command_channel.cpp @@ -1,4 +1,5 @@ #include +#include Uci::UciCommandChannel::UciCommandChannel(std::istream& input_stream) : input_stream(input_stream), state(nullptr) {} @@ -22,8 +23,14 @@ void Uci::UciCommandChannel::reader_loop(std::istream& input_stream, std::shared void Uci::UciCommandChannel::start() { stop(); - state = std::make_shared(); - reader_thread = std::thread(&UciCommandChannel::reader_loop, std::ref(input_stream), state); + auto new_state = std::make_shared(); + std::thread new_reader(&UciCommandChannel::reader_loop, std::ref(input_stream), new_state); + + // Move values after being sure that thread construction is successfull + // avoiding successfully built state and failed thread. + // Either state and thread are created successfully, or neither of them is. + state = std::move(new_state); + reader_thread = std::move(new_reader); } void Uci::UciCommandChannel::stop() { @@ -34,15 +41,12 @@ void Uci::UciCommandChannel::stop() { state->stop_requested.store(true); state->lines_cv.notify_all(); - if (!reader_thread.joinable()) { - state.reset(); - return; - } - - if (state->eof_reached.load()) { - reader_thread.join(); - } else { - reader_thread.detach(); + if (reader_thread.joinable()) { + if (state->eof_reached.load()) { + reader_thread.join(); + } else { + reader_thread.detach(); + } } state.reset(); From 30d013f199b736f579460a38a11503f9b86e25e6 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 17:55:22 +0200 Subject: [PATCH 17/19] added bench command documentation --- docs/commands.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 2019ef4d..bae999f2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -191,6 +191,30 @@ help Response: multiline informational text. +### `bench` + +Runs a benchmark search on the current position (non-UCI extension). + +Syntax: + +```text +bench [depth ] [movetime ] [wtime ] [btime ] [winc ] [binc ] [infinite] +``` + +Implemented behavior (current state): + +- Uses the same limit parser as `go`. +- `depth ` runs a fixed-depth benchmark. +- 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. + +Response when benchmark ends: + +```text +bench nodes negamax_nodes quiescence_nodes time(s) s nps +``` + ## Options Support Status ### UCI `setoption` From d28538831d024687cded9a4f3408571d8e5c89a2 Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 17:56:14 +0200 Subject: [PATCH 18/19] dry: separated test functions that waits for async results --- tests/bitbishop/helpers/async.hpp | 46 ++++++++++ .../interface/test_search_session.cpp | 89 +++++++++++++++++++ .../interface/test_uci_command_channel.cpp | 17 +--- tests/bitbishop/interface/test_uci_engine.cpp | 40 +-------- 4 files changed, 137 insertions(+), 55 deletions(-) create mode 100644 tests/bitbishop/helpers/async.hpp create mode 100644 tests/bitbishop/interface/test_search_session.cpp diff --git a/tests/bitbishop/helpers/async.hpp b/tests/bitbishop/helpers/async.hpp new file mode 100644 index 00000000..185a2235 --- /dev/null +++ b/tests/bitbishop/helpers/async.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include +#include + +/** + * @brief Wait until a condition becomes true or timeout expires. + * + * Polls the predicate periodically until it returns true or the timeout + * is reached. Useful for synchronizing with asynchronous engine behavior. + * + * @param pred Condition to evaluate. + * @param timeout Maximum duration to wait. + * @param interval Delay between successive checks. + * @return true if condition became true within timeout, false otherwise. + */ +bool wait_for(std::function pred, std::chrono::milliseconds timeout = std::chrono::milliseconds(500), + std::chrono::milliseconds interval = std::chrono::milliseconds(10)) { + const auto start = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start < timeout) { + if (pred()) { + return true; + } + std::this_thread::sleep_for(interval); + } + return false; +} + +void assert_output_contains(const std::stringstream& output, const std::string& token, + std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { + wait_for([&] { return output.str().find(token) != std::string::npos; }, timeout); + ASSERT_TRUE(wait_for([&] { return output.str().find(token) != std::string::npos; })) + << "Expected output to contain: " << token << "\nActual output:\n" + << output.str(); +} + +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; })) + << "Expected output not to contain: " << token << "\nActual output:\n" + << output.str(); +} diff --git a/tests/bitbishop/interface/test_search_session.cpp b/tests/bitbishop/interface/test_search_session.cpp new file mode 100644 index 00000000..a88aa4b9 --- /dev/null +++ b/tests/bitbishop/interface/test_search_session.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +bool wait_until_idle(Uci::SearchSession& session, std::chrono::milliseconds timeout = 1000ms) { + return wait_for( + [&session] { + session.poll(); + return session.is_idle(); + }, + timeout); +} + +TEST(SearchSessionTest, StartsIdleAndPollIsNoopWhenNoSearchIsRunning) { + std::stringstream output; + Uci::SearchSession session(output); + + EXPECT_TRUE(session.is_idle()); + session.poll(); + EXPECT_TRUE(session.is_idle()); + EXPECT_TRUE(output.str().empty()); +} + +TEST(SearchSessionTest, StartGoDepthOneProducesBestMoveAndBecomesIdle) { + std::stringstream output; + Uci::SearchSession session(output); + + Uci::SearchLimits limits{.depth = 1}; + + session.start_go(Board::StartingPosition(), limits); + + ASSERT_TRUE(wait_until_idle(session)); + + const std::string result = output.str(); + EXPECT_NE(result.find("bestmove "), std::string::npos); +} + +TEST(SearchSessionTest, StartBenchDepthOneProducesBenchSummaryAndBecomesIdle) { + std::stringstream output; + Uci::SearchSession session(output); + + Uci::SearchLimits limits{.depth = 1}; + + session.start_bench(Board::StartingPosition(), limits); + + ASSERT_TRUE(wait_until_idle(session)); + + const std::string result = output.str(); + EXPECT_NE(result.find("bench nodes "), std::string::npos); + EXPECT_NE(result.find("negamax_nodes "), std::string::npos); + EXPECT_NE(result.find("quiescence_nodes "), std::string::npos); + EXPECT_NE(result.find("nps "), std::string::npos); +} + +TEST(SearchSessionTest, RequestStopInterruptsInfiniteSearchAndStillFinalizes) { + std::stringstream output; + Uci::SearchSession session(output); + + Uci::SearchLimits limits{.infinite = true}; + + session.start_go(Board::StartingPosition(), limits); + std::this_thread::sleep_for(20ms); + session.request_stop(); + + ASSERT_TRUE(wait_until_idle(session, 1500ms)); + EXPECT_NE(output.str().find("bestmove "), std::string::npos); +} + +TEST(SearchSessionTest, StartBenchWithInfiniteLimitCompletesWithoutManualStop) { + std::stringstream output; + Uci::SearchSession session(output); + + // Stalemate position keeps the converted fixed-depth bench path very fast. + Board board("K7/8/8/8/8/8/5Q2/7k b - - 0 1"); + + Uci::SearchLimits limits{.infinite = true}; + + session.start_bench(board, limits); + + ASSERT_TRUE(wait_until_idle(session, 1500ms)); + EXPECT_NE(output.str().find("bench nodes "), std::string::npos); +} diff --git a/tests/bitbishop/interface/test_uci_command_channel.cpp b/tests/bitbishop/interface/test_uci_command_channel.cpp index 30120fd9..0e0b234c 100644 --- a/tests/bitbishop/interface/test_uci_command_channel.cpp +++ b/tests/bitbishop/interface/test_uci_command_channel.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -9,22 +10,6 @@ using namespace std::chrono_literals; -namespace { - -template -bool wait_for(Predicate&& pred, std::chrono::milliseconds timeout = 300ms, std::chrono::milliseconds interval = 5ms) { - const auto start = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() - start < timeout) { - if (pred()) { - return true; - } - std::this_thread::sleep_for(interval); - } - return pred(); -} - -} // namespace - TEST(UciCommandChannelTest, WaitAndPopReturnsFalseWhenNotStarted) { std::istringstream input("uci\n"); Uci::UciCommandChannel channel(input); diff --git a/tests/bitbishop/interface/test_uci_engine.cpp b/tests/bitbishop/interface/test_uci_engine.cpp index 3b31cda0..6d3a4347 100644 --- a/tests/bitbishop/interface/test_uci_engine.cpp +++ b/tests/bitbishop/interface/test_uci_engine.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -102,45 +103,6 @@ class UciEngineTest : public ::testing::Test { } }; -/** - * @brief Wait until a condition becomes true or timeout expires. - * - * Polls the predicate periodically until it returns true or the timeout - * is reached. Useful for synchronizing with asynchronous engine behavior. - * - * @param pred Condition to evaluate. - * @param timeout Maximum duration to wait. - * @param interval Delay between successive checks. - * @return true if condition became true within timeout, false otherwise. - */ -bool wait_for(std::function pred, std::chrono::milliseconds timeout = std::chrono::milliseconds(500), - std::chrono::milliseconds interval = std::chrono::milliseconds(10)) { - const auto start = std::chrono::steady_clock::now(); - - while (std::chrono::steady_clock::now() - start < timeout) { - if (pred()) { - return true; - } - std::this_thread::sleep_for(interval); - } - return false; -} - -void assert_output_contains(const std::stringstream& output, const std::string& token, - std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) { - wait_for([&] { return output.str().find(token) != std::string::npos; }, timeout); - ASSERT_TRUE(wait_for([&] { return output.str().find(token) != std::string::npos; })) - << "Expected output to contain: " << token << "\nActual output:\n" - << output.str(); -} - -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; })) - << "Expected output not to contain: " << token << "\nActual output:\n" - << output.str(); -} - TEST_F(UciEngineTest, CommandWithOnlySpacesDoesNothing) { input.write(" \n"); From 9f4fe0ed2f0129003bcd9282c41641aa316e501a Mon Sep 17 00:00:00 2001 From: Baptiste Penot Date: Thu, 23 Apr 2026 18:02:24 +0200 Subject: [PATCH 19/19] docsrtings update --- include/bitbishop/interface/search_reporter.hpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/include/bitbishop/interface/search_reporter.hpp b/include/bitbishop/interface/search_reporter.hpp index 50dbc42d..6f0c41e6 100644 --- a/include/bitbishop/interface/search_reporter.hpp +++ b/include/bitbishop/interface/search_reporter.hpp @@ -18,9 +18,6 @@ struct SearchReporter { /** * @brief Called after each completed search iteration. * - * This is typically invoked during iterative deepening to report - * intermediate results. - * * @param best Current best move found so far. * @param depth Depth reached in the current iteration. * @param stats Accumulated search statistics. @@ -82,7 +79,7 @@ struct BenchReporter : SearchReporter { public: /** Injectable time source. Mainly for testing. - * Must be declared *before* start si + * Must be declared *before* start. */ std::function now; @@ -106,8 +103,7 @@ struct BenchReporter : SearchReporter { * @brief Outputs benchmark statistics upon search completion. * * Computes total nodes searched (negamax + quiescence), elapsed time, - * and nodes per second (NPS), then prints a summary line: - * "bench nodes time s nps " + * and nodes per second (NPS), then prints a summary line (see implementation for details). * * @param best Final best move found by the search (unused). * @param stats Final search statistics.