diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3ac2a6d8..a01fcfe8 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -11,7 +11,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; -use std::time::Instant; +use std::time::{Duration, Instant}; use crate::chess::core::Move; use crate::chess::game::{self, Game}; @@ -47,6 +47,12 @@ struct SearchThread { infinite: bool, /// The position the search ran from. spec: PositionSpec, + /// Whether the search is currently pondering (running on the opponent's + /// clock). + pondering: bool, + /// Time budget to apply when a `ponderhit` switches the search to the + /// engine's own clock. + ponder_budget: Option, } /// The Engine connects everything together and handles commands sent by UCI @@ -64,6 +70,8 @@ pub struct Engine { /// Number of search threads. Only single-threaded search is implemented so /// far. threads: usize, + /// Number of principal variations to report (the UCI `MultiPV` option). + multipv: usize, /// Tree kept from the previous search for reuse. reusable: Option, /// UCI commands will be read from this stream. @@ -85,6 +93,7 @@ impl Engine { debug: false, hash_size_mb: 16, threads: 1, + multipv: 1, reusable: None, input, out: Arc::new(Mutex::new(out)), @@ -137,6 +146,10 @@ impl Engine { } self.go(&go); } + Command::PonderHit => self.ponder_hit(), + // The engine does not require registration; acknowledge and + // move on. + Command::Register => {} Command::Stop => self.stop_search(), Command::Quit => { // Let a search with finite limits run to completion so @@ -177,7 +190,10 @@ impl Engine { self.send(&format!("id author {}", env!("CARGO_PKG_AUTHORS")))?; self.send("option name Hash type spin default 16 min 1 max 1048576")?; self.send("option name Threads type spin default 1 min 1 max 1")?; + self.send("option name MultiPV type spin default 1 min 1 max 218")?; + self.send("option name Ponder type check default false")?; self.send("option name SyzygyTablebase type string default ")?; + self.send("option name Clear Hash type button")?; self.send("uciok")?; Ok(()) } @@ -202,6 +218,15 @@ impl Engine { } self.threads = 1; } + (uci::EngineOption::MultiPv, uci::OptionValue::Integer(lines)) => { + self.multipv = lines.clamp(1, 218); + } + // Ponder capability is declared for GUIs; the engine reacts to + // `go ponder`, so the value itself needs no state. + (uci::EngineOption::Ponder, uci::OptionValue::Bool(_)) => {} + (uci::EngineOption::ClearHash, uci::OptionValue::Button) => { + self.reusable = None; + } (uci::EngineOption::SyzygyTablebase, uci::OptionValue::String(path)) => { self.set_tablebase(&path)?; } @@ -310,17 +335,31 @@ impl Engine { fn go(&mut self, go: &uci::Go) { self.stop_search(); - let limits = self.limits(go); + let mut limits = self.limits(go); + // Pondering runs on the opponent's clock: search without a time limit + // until a `ponderhit` applies the budget or a `stop` ends it. + let ponder_budget = if go.ponder { + limits.move_time.take() + } else { + None + }; + if go.ponder { + limits.infinite = true; + } let infinite = limits.infinite; + let tree = self.reuse_tree(); - let node_budget = mcts::node_budget(self.hash_size_mb); + let config = mcts::Config { + node_budget: mcts::node_budget(self.hash_size_mb), + multipv: self.multipv, + }; let stop = Arc::new(AtomicBool::new(false)); let game = self.game.clone(); let out = Arc::clone(&self.out); let search_stop = Arc::clone(&stop); let handle = thread::spawn(move || { - let result = mcts::search(&game, tree, node_budget, &limits, &search_stop, |info| { + let result = mcts::search(&game, tree, &config, &limits, &search_stop, |info| { let mut out = out.lock().expect("output lock poisoned"); let _ = writeln!(out, "{info}"); let _ = out.flush(); @@ -341,9 +380,30 @@ impl Engine { handle, infinite, spec: self.spec.clone(), + pondering: go.ponder, + ponder_budget, }); } + /// Handles `ponderhit`: the pondered move was played, so the search now + /// runs on the engine's own clock. A timer thread stops it once the + /// move-time budget elapses; without a budget it continues until a + /// `stop`. + fn ponder_hit(&mut self) { + if let Some(search) = &mut self.search + && search.pondering + { + search.pondering = false; + if let Some(budget) = search.ponder_budget.take() { + let stop = Arc::clone(&search.stop); + thread::spawn(move || { + thread::sleep(budget); + stop.store(true, Ordering::Relaxed); + }); + } + } + } + /// Returns the tree to seed the next search: the previous search's subtree /// re-rooted at the current position when the game continued along the same /// line, otherwise a fresh tree. @@ -408,6 +468,7 @@ impl Engine { self.send(&format!("info string debug {}", self.debug))?; self.send(&format!("info string option Hash {}", self.hash_size_mb))?; self.send(&format!("info string option Threads {}", self.threads))?; + self.send(&format!("info string option MultiPV {}", self.multipv))?; self.send(&format!( "info string option SyzygyTablebase {}", self.game.tablebase().map_or_else( @@ -473,14 +534,11 @@ pub fn openbench() { ..mcts::Limits::default() }; let stop = AtomicBool::new(false); - let result = mcts::search( - &game, - Tree::new(), - mcts::node_budget(16), - &limits, - &stop, - |_| {}, - ); + let config = mcts::Config { + node_budget: mcts::node_budget(16), + multipv: 1, + }; + let result = mcts::search(&game, Tree::new(), &config, &limits, &stop, |_| {}); total_nodes += result.nodes; println!( "info string bench bestmove {} for {fen}", diff --git a/src/engine/uci.rs b/src/engine/uci.rs index 990f8d39..2add48a5 100644 --- a/src/engine/uci.rs +++ b/src/engine/uci.rs @@ -17,6 +17,12 @@ pub(super) enum Command { }, NewGame, Go(Go), + /// The move the engine was pondering on was played; the ponder search + /// should continue on the engine's own clock. + PonderHit, + /// Copy-protection registration. The engine does not require it, so this is + /// ignored. + Register, Stop, Quit, /// This is an extension to the UCI protocol useful for debugging. The @@ -39,6 +45,8 @@ pub(super) struct Go { pub(super) depth: Option, pub(super) nodes: Option, pub(super) infinite: bool, + /// The search runs on the opponent's clock until a `ponderhit` or `stop`. + pub(super) ponder: bool, } #[derive(Debug, PartialEq)] @@ -46,12 +54,18 @@ pub(super) enum EngineOption { Hash, SyzygyTablebase, Threads, + MultiPv, + Ponder, + ClearHash, } #[derive(Debug, PartialEq)] pub(super) enum OptionValue { Integer(usize), String(String), + Bool(bool), + /// A `button`-typed option, which carries no value. + Button, } fn parse_go(parts: &[&str]) -> Command { @@ -75,9 +89,13 @@ fn parse_go(parts: &[&str]) -> Command { i += 1; continue; } - // Tokens without a value (e.g. "ponder") and unsupported ones are - // skipped one at a time so that they can not swallow a keyword that - // follows them. + "ponder" => { + go.ponder = true; + i += 1; + continue; + } + // Unsupported tokens are skipped one at a time so that they can not + // swallow a keyword that follows them. _ => { i += 1; continue; @@ -90,39 +108,47 @@ fn parse_go(parts: &[&str]) -> Command { } fn parse_setoption(parts: &[&str]) -> Command { - if parts.len() > 3 && parts[1] == "name" { - let name_end = parts - .iter() - .position(|&x| x == "value") - .unwrap_or(parts.len()); - let option = parts[2..name_end].join(" "); - let option = match option.as_str() { - "Hash" => EngineOption::Hash, - "SyzygyTablebase" => EngineOption::SyzygyTablebase, - "Threads" => EngineOption::Threads, - _ => return Command::Unknown(parts.join(" ")), - }; - let value = if name_end + 1 < parts.len() { - match option { - EngineOption::Hash | EngineOption::Threads => parts[name_end + 1] - .parse::() - .ok() - .map(OptionValue::Integer), - EngineOption::SyzygyTablebase => { - Some(OptionValue::String(parts[name_end + 1..].join(" "))) - } + let unknown = || Command::Unknown(parts.join(" ")); + if parts.len() < 3 || parts[1] != "name" { + return unknown(); + } + + // `setoption name [value ]`. + let value_pos = parts.iter().position(|&token| token == "value"); + let name = parts[2..value_pos.unwrap_or(parts.len())].join(" "); + let value = value_pos.map(|pos| parts[pos + 1..].join(" ")); + + let option = match name.as_str() { + "Hash" => EngineOption::Hash, + "SyzygyTablebase" => EngineOption::SyzygyTablebase, + "Threads" => EngineOption::Threads, + "MultiPV" => EngineOption::MultiPv, + "Ponder" => EngineOption::Ponder, + "Clear Hash" => EngineOption::ClearHash, + _ => return unknown(), + }; + + // A `button` option carries no value; everything else requires one. + let value = match option { + EngineOption::ClearHash => OptionValue::Button, + EngineOption::Hash | EngineOption::Threads | EngineOption::MultiPv => { + match value.and_then(|value| value.parse().ok()) { + Some(number) => OptionValue::Integer(number), + None => return unknown(), } - } else { - None - }; - if let Some(value) = value { - Command::SetOption { option, value } - } else { - Command::Unknown(parts.join(" ")) } - } else { - Command::Unknown(parts.join(" ")) - } + EngineOption::SyzygyTablebase => match value { + Some(path) => OptionValue::String(path), + None => return unknown(), + }, + EngineOption::Ponder => match value.as_deref() { + Some("true") => OptionValue::Bool(true), + Some("false") => OptionValue::Bool(false), + _ => return unknown(), + }, + }; + + Command::SetOption { option, value } } fn parse_setposition(parts: &[&str]) -> Command { @@ -158,6 +184,8 @@ impl Command { "position" => parse_setposition(&parts), "ucinewgame" => Self::NewGame, "go" => parse_go(&parts), + "ponderhit" => Self::PonderHit, + "register" => Self::Register, "stop" => Self::Stop, "quit" => Self::Quit, "state" => Self::State, @@ -220,6 +248,50 @@ mod tests { ); } + #[test] + fn parse_setoption_extended() { + assert_eq!( + Command::parse("setoption name MultiPV value 4"), + Command::SetOption { + option: EngineOption::MultiPv, + value: OptionValue::Integer(4) + } + ); + assert_eq!( + Command::parse("setoption name Ponder value true"), + Command::SetOption { + option: EngineOption::Ponder, + value: OptionValue::Bool(true) + } + ); + // "Clear Hash" is a button: it has a multi-word name and no value. + assert_eq!( + Command::parse("setoption name Clear Hash"), + Command::SetOption { + option: EngineOption::ClearHash, + value: OptionValue::Button + } + ); + } + + #[test] + fn parse_ponderhit_and_register() { + assert_eq!(Command::parse("ponderhit"), Command::PonderHit); + assert_eq!(Command::parse("register later"), Command::Register); + } + + #[test] + fn parse_go_ponder() { + assert_eq!( + Command::parse("go ponder wtime 1000"), + Command::Go(Go { + wtime: Some(Duration::from_millis(1000)), + ponder: true, + ..Go::default() + }) + ); + } + #[test] fn parse_position() { assert_eq!( @@ -266,13 +338,13 @@ mod tests { }) ); - // "infinite" and "ponder" have no value: the keywords that follow them - // should still be picked up. + // A valueless keyword must not swallow the keyword that follows it. assert_eq!( Command::parse("go ponder wtime 1000 btime 2000"), Command::Go(Go { wtime: Some(Duration::from_millis(1000)), btime: Some(Duration::from_millis(2000)), + ponder: true, ..Go::default() }) ); diff --git a/src/search/mcts.rs b/src/search/mcts.rs index 16e99f33..e61a6a19 100644 --- a/src/search/mcts.rs +++ b/src/search/mcts.rs @@ -61,6 +61,25 @@ pub struct Limits { pub infinite: bool, } +/// Static configuration of a search: how large the tree may grow and how many +/// principal variations to report. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Config { + /// Maximum number of tree nodes (see [`node_budget`]). + pub(crate) node_budget: usize, + /// Number of best lines to report (the UCI `MultiPV` option). + pub(crate) multipv: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + node_budget: usize::MAX, + multipv: 1, + } + } +} + /// Final result of a search. pub(crate) struct SearchResult { /// The move the engine considers best. `None` if the root position has no @@ -80,14 +99,14 @@ pub(crate) struct SearchResult { /// the (grown) search tree. /// /// `tree` seeds the search: pass [`Tree::new`] for a fresh search or a -/// [`Tree::rerooted`] subtree to reuse earlier statistics. `node_budget` caps -/// the tree size to bound memory (see [`node_budget`]). +/// [`Tree::rerooted`] subtree to reuse earlier statistics. `config` controls +/// the tree size budget and the number of reported lines. /// -/// `on_info` is called with UCI `info` lines as the search progresses. +/// `on_info` is called with UCI `info` blocks as the search progresses. pub(crate) fn search( game: &Game, tree: Tree, - node_budget: usize, + config: &Config, limits: &Limits, stop: &AtomicBool, mut on_info: impl FnMut(&str), @@ -102,7 +121,7 @@ pub(crate) fn search( }; } - let mut searcher = Searcher::new(game, tree, node_budget, &root_moves); + let mut searcher = Searcher::new(game, tree, config, &root_moves); let start = Instant::now(); let mut iterations = 0u64; let mut max_depth = 0usize; @@ -115,12 +134,12 @@ pub(crate) fn search( if iterations.is_multiple_of(CHECK_INTERVAL) && last_report.elapsed() >= Duration::from_secs(1) { - on_info(&searcher.info_line(iterations, max_depth, start)); + on_info(&searcher.report(iterations, max_depth, start)); last_report = Instant::now(); } } - on_info(&searcher.info_line(iterations, max_depth, start)); + on_info(&searcher.report(iterations, max_depth, start)); SearchResult { best_move: Some(searcher.best_move()), nodes: iterations, @@ -133,7 +152,7 @@ pub(crate) fn search( /// `limits` and returns the root move visit counts (the policy target). pub(crate) fn policy(game: &Game, limits: &Limits) -> Vec<(Move, u32)> { let stop = AtomicBool::new(false); - search(game, Tree::new(), usize::MAX, limits, &stop, |_| {}).root_visits + search(game, Tree::new(), &Config::default(), limits, &stop, |_| {}).root_visits } fn limits_reached(limits: &Limits, iterations: u64, start: Instant) -> bool { @@ -152,15 +171,14 @@ struct Searcher<'a> { root: &'a Position, history: &'a [Key], tablebase: Option>>, - /// Maximum number of nodes the tree is allowed to grow to. - node_budget: usize, + config: Config, tree: Tree, } impl<'a> Searcher<'a> { /// Creates a searcher, expanding the root node if the seed tree left it /// unexpanded. The caller guarantees the root has at least one legal move. - fn new(game: &'a Game, mut tree: Tree, node_budget: usize, root_moves: &[Move]) -> Self { + fn new(game: &'a Game, mut tree: Tree, config: &Config, root_moves: &[Move]) -> Self { if !tree.node(ROOT_ID).expanded { expand(&mut tree, ROOT_ID, root_moves); } @@ -168,7 +186,7 @@ impl<'a> Searcher<'a> { root: game.position(), history: game.history(), tablebase: game.tablebase(), - node_budget, + config: *config, tree, } } @@ -238,7 +256,7 @@ impl<'a> Searcher<'a> { // Stop growing the tree once it reaches the memory budget; the leaf is // still evaluated, it just is not expanded. - if self.tree.len() < self.node_budget { + if self.tree.len() < self.config.node_budget { expand(&mut self.tree, node_id, &moves); } value_from_centipawns(evaluation::evaluate(position)) @@ -306,7 +324,9 @@ impl<'a> Searcher<'a> { }) } - fn info_line(&self, iterations: u64, max_depth: usize, start: Instant) -> String { + /// Builds the UCI `info` block: one line per reported principal variation + /// (up to `MultiPV`), ordered best first. + fn report(&self, iterations: u64, max_depth: usize, start: Instant) -> String { let elapsed = start.elapsed(); let millis = elapsed.as_millis(); #[allow( @@ -316,44 +336,89 @@ impl<'a> Searcher<'a> { reason = "nps can not realistically overflow or be negative" )] let nps = (iterations as f64 / elapsed.as_secs_f64().max(f64::MIN_POSITIVE)) as u64; - - let score = self.most_visited_child(ROOT_ID).map_or(0, |best| { - centipawns_from_value(self.tree.node(best).mean_value()) + let hashfull = + (self.tree.len() as u64 * 1000 / self.config.node_budget.max(1) as u64).min(1000); + + // Report the most-visited root moves first, as many as MultiPV asks for. + let mut roots: Vec = self.tree.node(ROOT_ID).children.clone(); + roots.sort_by(|&a, &b| { + let (a, b) = (self.tree.node(a), self.tree.node(b)); + b.visits + .cmp(&a.visits) + .then(b.mean_value().total_cmp(&a.mean_value())) }); - let pv = self.principal_variation(); - format!( - "info depth {max_depth} nodes {iterations} nps {nps} time {millis} score cp {score} \ - pv {pv}" - ) + roots + .iter() + .take(self.config.multipv.max(1)) + .enumerate() + .map(|(rank, &child)| { + let pv = self.line(child); + let score = self.score(&pv, self.tree.node(child).mean_value()); + let moves: Vec = pv.iter().map(ToString::to_string).collect(); + format!( + "info depth {} seldepth {max_depth} multipv {} score {score} nodes \ + {iterations} nps {nps} hashfull {hashfull} time {millis} pv {}", + pv.len(), + rank + 1, + moves.join(" ") + ) + }) + .collect::>() + .join("\n") } - /// Builds the principal variation by following the most-visited child from - /// the root while nodes have been visited. - fn principal_variation(&self) -> String { - let mut pv = Vec::new(); - let mut node_id = ROOT_ID; + /// Builds a principal variation starting with `first`, following the + /// most-visited children. + fn line(&self, first: NodeId) -> Vec { + let mut pv = vec![self.tree.node(first).action.expect("child has action")]; + let mut node_id = first; while pv.len() < MAX_PV_LENGTH { - let Some(best) = self.most_visited_child(node_id) else { + let Some(next) = self.most_visited_child(node_id) else { break; }; - if self.tree.node(best).visits == 0 { + if self.tree.node(next).visits == 0 { break; } pv.push( self.tree - .node(best) + .node(next) .action - .expect("non-root nodes have actions") - .to_string(), + .expect("non-root nodes have actions"), ); - node_id = best; + node_id = next; + } + pv + } + + /// Formats the score of a line: `mate N` when the line ends in checkmate, + /// otherwise `cp N` from the root player's perspective. + fn score(&self, pv: &[Move], mean_value: f64) -> String { + self.mate_distance(pv).map_or_else( + || format!("cp {}", centipawns_from_value(mean_value)), + |mate| format!("mate {mate}"), + ) + } + + /// If the line ends in checkmate, returns the mate distance in moves: + /// positive if the root player mates, negative if it is mated. Tablebase + /// wins are not mates and return `None`. + fn mate_distance(&self, pv: &[Move]) -> Option { + let mut position = self.root.clone(); + for next_move in pv { + position.make_move(next_move); } - // Even with zero completed iterations there is a legal first move. - if pv.is_empty() { - pv.push(self.best_move().to_string()); + if !position.generate_moves().is_empty() || !position.in_check() { + return None; } - pv.join(" ") + let plies = i32::try_from(pv.len()).expect("pv length is small"); + // Odd length: the root player delivered mate; even: the root player was + // mated. + Some(if plies % 2 == 1 { + (plies + 1) / 2 + } else { + -(plies / 2) + }) } } @@ -402,7 +467,7 @@ mod tests { fn search_game(game: &Game, limits: &Limits) -> SearchResult { let stop = AtomicBool::new(false); - search(game, Tree::new(), usize::MAX, limits, &stop, |_| {}) + search(game, Tree::new(), &Config::default(), limits, &stop, |_| {}) } fn run(fen: &str, limits: &Limits) -> SearchResult { @@ -453,7 +518,7 @@ mod tests { let result = search( &Game::new(Position::starting()), Tree::new(), - usize::MAX, + &Config::default(), &Limits { infinite: true, ..Limits::default() @@ -508,7 +573,7 @@ mod tests { let first = search( &game, Tree::new(), - usize::MAX, + &Config::default(), &Limits { nodes: Some(2000), ..Limits::default() @@ -533,7 +598,7 @@ mod tests { let second = search( &game, reused, - usize::MAX, + &Config::default(), &Limits { nodes: Some(100), ..Limits::default() diff --git a/tests/integration.rs b/tests/integration.rs index 412a9d4b..e3620f82 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -124,6 +124,46 @@ fn uci_reuses_tree_across_continuing_searches() { ); } +#[test] +fn uci_multipv_reports_multiple_lines() { + let mut cmd = assert_cmd::cargo_bin_cmd!("pabi"); + + drop( + cmd.write_stdin("setoption name MultiPV value 3\nposition startpos\ngo nodes 3000\nquit\n") + .assert() + .success() + .stdout( + contains("multipv 2") + .and(contains("multipv 3")) + .and(contains("seldepth")), + ), + ); +} + +#[test] +fn uci_reports_forced_mate() { + let mut cmd = assert_cmd::cargo_bin_cmd!("pabi"); + + drop( + cmd.write_stdin("position fen 6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1\ngo nodes 5000\nquit\n") + .assert() + .success() + .stdout(contains("score mate 1").and(contains("bestmove a1a8"))), + ); +} + +#[test] +fn uci_ponderhit_produces_bestmove() { + let mut cmd = assert_cmd::cargo_bin_cmd!("pabi"); + + drop( + cmd.write_stdin("position startpos\ngo ponder wtime 300 btime 300\nponderhit\nquit\n") + .assert() + .success() + .stdout(is_match(r"bestmove [a-h][1-8][a-h][1-8]").unwrap()), + ); +} + #[test] fn uci_loads_syzygy_tablebase() { let tablebase_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/syzygy");