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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 11 additions & 17 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,16 @@ Implemented behavior (current state):

- `depth <n>`: fixed-depth search.
- `infinite`: iterative deepening until `stop`.
- If `depth` is not provided, search defaults to infinite mode.
- If `depth` and `infinite` are both present, `infinite` takes precedence.
- `movetime <ms>`: iterative deepening search limited by a per-move budget in milliseconds.
- Non-positive `movetime` values are clamped to `1` ms.
- `movetime` takes precedence over `wtime`/`btime`/`winc`/`binc`.
- `infinite` takes precedence over every other limit, including `movetime` and `depth`.
- If both `depth` and a time-based limit are provided, the current implementation runs the timed search path.
- When `movetime` is not provided, time is estimated from the side-to-move clock:
roughly remaining time divided across future moves, with increment added and a safety reserve kept aside.

- If no argument is provided behind `go`, search defaults to infinite mode.
- If `depth` is not provided and no time control is provided either, search defaults to infinite mode.

Response when search ends:

Expand Down Expand Up @@ -205,6 +213,7 @@ Implemented behavior (current state):

- Uses the same limit parser as `go`.
- `depth <n>` runs a fixed-depth benchmark.
- `movetime <ms>` runs a time-limited benchmark using the same semantics as `go`.
- If `infinite` is provided, benchmark mode internally converts to `depth 10`.
- A bare `bench` command (no limits) is parsed as `infinite`, then converted internally to `depth 10`.
- `stop` can still interrupt a running benchmark early.
Expand All @@ -223,18 +232,3 @@ Not implemented yet.

- The engine currently does not expose configurable UCI options.
- No `option name ...` lines are emitted in `uci` response.

### `go` time-control fields

The following tokens are parsed but not yet enforced by search scheduling:

- `movetime <ms>`
- `wtime <ms>`
- `btime <ms>`
- `winc <ms>`
- `binc <ms>`

Today, practical stopping control is:

- `go depth <n>` for bounded search
- `go` or `go infinite`, then `stop` for unbounded search
20 changes: 18 additions & 2 deletions include/bitbishop/interface/search_worker.hpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#pragma once

#include <bitbishop/engine/search.hpp>
#include <atomic>
#include <bitbishop/engine/search.hpp>
#include <bitbishop/moves/position.hpp>
#include <mutex>
#include <optional>
Expand Down Expand Up @@ -30,6 +30,22 @@ struct SearchLimits {
* @return The built SearchLimits object.
*/
static SearchLimits from_uci_cmd(const std::vector<std::string>& line);

/**
* @brief Returns whether any time-based search limit was provided.
*/
[[nodiscard]] bool has_time_limit() const;

/**
* @brief Estimates how much time the current side should spend on this move.
*
* `movetime` takes precedence when present. Otherwise the estimate is derived
* from the side-to-move remaining clock and increment.
*
* @param side_to_move Side that is currently searching.
* @return Think time budget in milliseconds, or std::nullopt if no clock is available.
*/
[[nodiscard]] std::optional<int> think_time_ms(Color side_to_move) const;
};

/**
Expand Down Expand Up @@ -86,7 +102,7 @@ class SearchWorker {
* @brief Starts the search process with given parameters.
*
* Initializes the search controller with a new worker thread and parameters.
* If no depth is specified, switches to infinite search mode.
* If no search limit is specified, switches to infinite search mode.
*/
void start();

Expand Down
2 changes: 1 addition & 1 deletion include/bitbishop/movegen/legal_moves.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#include <vector>

namespace MoveGen {
enum class Scope {
enum class Scope : std::uint8_t {
AllMoves,
CapturesOnly,
};
Expand Down
72 changes: 72 additions & 0 deletions include/bitbishop/tools/time_guard.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#pragma once

#include <atomic>
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <thread>

namespace Tools {

/**
* @class TimeGuard
* @brief An RAII-based asynchronous countdown timer used to signal a timeout via an atomic flag.
*
* This class spawns a background thread that waits for a specified duration. If the duration
* elapses without the object being destroyed or cancelled, it sets the provided @p stop_flag to true.
*
* @note This is designed for search algorithms where a search must
* be terminated after a certain amount of time.
*/
class TimeGuard {
public:
/**
* @brief Constructs the timer and immediately starts the countdown in a background thread.
*
* @param stop_flag Reference to an atomic boolean. This flag is monitored for external
* cancellation and is set to @c true by this class upon timeout.
* @param timeout The maximum duration to wait before setting the flag.
*/
TimeGuard(std::atomic<bool>& stop_flag, std::chrono::steady_clock::duration timeout);

/**
* @brief Destructor. Safely cancels the timer and joins the background thread.
*
* This ensures that no "zombie" threads are left running. If the timer has not yet
* expired, it is signaled to wake up and exit immediately.
*/
~TimeGuard();

// Deleted copy and move operations to prevent accidental thread ownership issues.
TimeGuard(const TimeGuard&) = delete;
TimeGuard& operator=(const TimeGuard&) = delete;
TimeGuard(TimeGuard&&) = delete;
TimeGuard& operator=(TimeGuard&&) = delete;

private:
/**
* @brief The main loop for the worker thread.
*
* Uses a @c std::condition_variable::wait_for to sleep efficiently. It wakes up if:
* 1. The @p timeout expires (sets @p stop_flag to @c true).
* 2. The @p cancelled flag is set via the destructor/cancel().
* 3. The @p stop_flag is set to @c true by external logic.
*/
void start();

/**
* @brief Internal helper to signal the worker thread to stop and clean up resources.
*
* Thread-safe. It notifies the condition variable and joins the worker thread.
*/
void cancel();

std::atomic<bool>& stop_flag;
std::chrono::steady_clock::duration timeout;
std::mutex mutex;
std::condition_variable cv;
bool cancelled = false;
std::thread worker;
};

} // namespace Tools
116 changes: 83 additions & 33 deletions src/bitbishop/interface/search_worker.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
#include <algorithm>
#include <bitbishop/interface/search_worker.hpp>
#include <bitbishop/tools/time_guard.hpp>
#include <limits>

namespace {

constexpr int DEFAULT_MOVES_TO_GO = 20;
constexpr int MIN_THINK_TIME_MS = 1;
constexpr int SAFETY_BUFFER_MS = 50;
constexpr int MAX_DEPTH = 50;

[[nodiscard]] int estimate_clock_think_time_ms(int remaining_ms, int increment_ms) {
remaining_ms = std::max(remaining_ms, 0);
increment_ms = std::max(increment_ms, 0);

if (remaining_ms <= MIN_THINK_TIME_MS) {
return MIN_THINK_TIME_MS;
}

const int reserve_ms = std::min(remaining_ms - MIN_THINK_TIME_MS, std::max(SAFETY_BUFFER_MS, remaining_ms / 20));
const int spendable_ms = std::max(MIN_THINK_TIME_MS, remaining_ms - reserve_ms);
const int base_ms = spendable_ms / DEFAULT_MOVES_TO_GO;
const int increment_bonus_ms = (increment_ms * 3) / 4;

return std::clamp(base_ms + increment_bonus_ms, MIN_THINK_TIME_MS, spendable_ms);
}

} // namespace

Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector<std::string>& line) {
SearchLimits limits;
Expand Down Expand Up @@ -29,14 +57,31 @@ Uci::SearchLimits Uci::SearchLimits::from_uci_cmd(const std::vector<std::string>
}
}

const bool has_time_control = limits.movetime || limits.wtime || limits.btime || limits.winc || limits.binc;
if (!limits.depth && !has_time_control && !limits.infinite) {
if (!limits.depth && !limits.has_time_limit() && !limits.infinite) {
limits.infinite = true;
}

return limits;
}

bool Uci::SearchLimits::has_time_limit() const {
return movetime.has_value() || wtime.has_value() || btime.has_value() || winc.has_value() || binc.has_value();
}

std::optional<int> Uci::SearchLimits::think_time_ms(Color side_to_move) const {
if (movetime.has_value()) {
return std::max(*movetime, MIN_THINK_TIME_MS);
}

const std::optional<int>& remaining_opt = (side_to_move == Color::WHITE) ? wtime : btime;
if (!remaining_opt.has_value()) {
return std::nullopt;
}

const std::optional<int>& increment_opt = (side_to_move == Color::WHITE) ? winc : binc;
return estimate_clock_think_time_ms(*remaining_opt, increment_opt.value_or(0));
}

Uci::SearchWorker::SearchWorker(Board board, SearchLimits limits)
: board(board), position(Position(this->board)), limits(limits) {}

Expand All @@ -49,47 +94,52 @@ void Uci::SearchWorker::push_report(SearchReport report) {

void Uci::SearchWorker::run() {
using namespace Search;

// scope_exit function is not yet shipped in all stdlibs (linux is fine, not windows nor macos - 2026-04-29)
// const auto guard = std::experimental::scope_exit([this] { finished.store(true); });
// Replacing it by a struct with custom destructor.
struct FinishGuard {
std::atomic<bool>& finished_ref;
~FinishGuard() { finished_ref.store(true); }
} guard{finished};

SearchStats stats{};
BestMove best;
BestMove last_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, stats, &stop_flag);
SearchReport current_best_report{.kind = SearchReportKind::Iteration};

const auto side = board.get_side_to_move();
const auto think_time = limits.infinite ? std::nullopt : limits.think_time_ms(side);
std::optional<Tools::TimeGuard> timeguard;
if (think_time) {
timeguard.emplace(stop_flag, std::chrono::milliseconds(*think_time));
}

auto perform_search_at_depth = [&](int depth) {
auto result = negamax(position, 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,
});
current_best_report.best = result;
current_best_report.depth = depth;
current_best_report.stats = stats;
push_report(current_best_report);
return true;
}
return false;
};

if (limits.depth && !limits.infinite && !think_time) {
// Case: Fixed depth search (e.g., "go depth 10")
perform_search_at_depth(*limits.depth);
} else {
// Case: Iterative deepening (Infinite or Time-limited)
for (int depth = 1; depth <= MAX_DEPTH && !stop_flag.load(); ++depth) {
if (!perform_search_at_depth(depth)) {
break;
}
}
}

const BestMove& final = (last_best.move) ? last_best : best;
push_report(SearchReport{
.kind = SearchReportKind::Finish,
.best = final,
.depth = limits.depth.value_or(0),
.stats = stats,
});
current_best_report.kind = SearchReportKind::Finish;
push_report(current_best_report);
}

void Uci::SearchWorker::start() {
Expand Down
34 changes: 34 additions & 0 deletions src/bitbishop/tools/time_guard.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#include <bitbishop/tools/time_guard.hpp>

namespace Tools {

TimeGuard::TimeGuard(std::atomic<bool>& stop_flag, std::chrono::steady_clock::duration timeout)
: stop_flag(stop_flag), timeout(timeout) {
worker = std::thread(&TimeGuard::start, this);
}

TimeGuard::~TimeGuard() { cancel(); }

void TimeGuard::start() {
std::unique_lock<std::mutex> lock(mutex);

const bool was_interrupted = cv.wait_for(lock, timeout, [this] { return cancelled || this->stop_flag.load(); });

if (!was_interrupted) {
this->stop_flag.store(true);
}
}

void TimeGuard::cancel() {
{
std::lock_guard<std::mutex> lock(mutex);
cancelled = true;
}
cv.notify_one();

if (worker.joinable()) {
worker.join();
}
}

} // namespace Tools
2 changes: 1 addition & 1 deletion tests/bitbishop/helpers/async.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ void assert_output_contains(const std::stringstream& output, const std::string&

void assert_output_not_contains(const std::stringstream& output, const std::string& token,
std::chrono::milliseconds timeout = std::chrono::milliseconds(500)) {
ASSERT_FALSE(wait_for([&] { return output.str().find(token) == std::string::npos; }))
ASSERT_TRUE(wait_for([&] { return output.str().find(token) == std::string::npos; }))
<< "Expected output not to contain: " << token << "\nActual output:\n"
<< output.str();
}
Loading
Loading