diff --git a/README.md b/README.md index 976ccd7e..e6f9b6a7 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ Reckless supports the following UCI options: | UCI_Chess960 | false | Enable Chess960 (Fischer Random) support [false–true] | | Minimal | false | Enable minimal UCI output [false–true] | | MoveOverhead | 100 | Time in milliseconds reserved for overhead during each move [0–2000] | +| Ponder | false | Enable pondering support for `go ponder` [false–true] | | Clear Hash | — | Clear the transposition table | | SyzygyPath | — | Path to Syzygy endgame tablebases | diff --git a/src/search.rs b/src/search.rs index 0259d924..b73187aa 100644 --- a/src/search.rs +++ b/src/search.rs @@ -80,6 +80,13 @@ pub fn start(td: &mut ThreadData, report: Report, thread_count: usize) { // Iterative Deepening for depth in 1..MAX_PLY as i32 { + if td.time_manager.is_ponder() && td.shared.ponderhit.swap(false, Ordering::AcqRel) { + td.time_manager.on_ponderhit(); + td.shared.pondering.store(false, Ordering::Release); + soft_stop_voted = false; + td.shared.soft_stop_votes.store(0, Ordering::Release); + } + if td.id == 0 && let Limits::Depth(maximum) = td.time_manager.limits() && depth > maximum diff --git a/src/thread.rs b/src/thread.rs index 3c7498cc..4f3f470a 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -1,6 +1,6 @@ use std::sync::{ Arc, - atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}, }; use crate::{ @@ -97,6 +97,8 @@ unsafe impl NumaValue for SharedCorrectionHistory {} pub struct SharedContext { pub tt: TranspositionTable, pub status: Status, + pub pondering: AtomicBool, + pub ponderhit: AtomicBool, pub nodes: Counter, pub tb_hits: Counter, pub soft_stop_votes: AtomicUsize, @@ -112,6 +114,8 @@ impl Default for SharedContext { Self { tt: TranspositionTable::default(), status: Status::default(), + pondering: AtomicBool::new(false), + ponderhit: AtomicBool::new(false), nodes: Counter::default(), tb_hits: Counter::default(), soft_stop_votes: AtomicUsize::new(0), @@ -160,7 +164,7 @@ impl ThreadData { id: 0, shared, board: Board::starting_position(), - time_manager: TimeManager::new(Limits::Infinite, 0, 0), + time_manager: TimeManager::new(Limits::Infinite, 0, 0, false), stack: Stack::new(), nnue: Network::default(), root_moves: Vec::new(), diff --git a/src/threadpool.rs b/src/threadpool.rs index 7afbb57e..19d7e6d1 100644 --- a/src/threadpool.rs +++ b/src/threadpool.rs @@ -72,11 +72,15 @@ impl ThreadPool { self.vector = make_thread_data(shared, &self.workers, Board::starting_position().into()); } - pub fn execute_searches(&mut self, time_manager: TimeManager, report: Report, shared: &Arc) { + pub fn execute_searches( + &mut self, time_manager: TimeManager, report: Report, shared: &Arc, pondering: bool, + ) { shared.tt.increment_age(); shared.nodes.reset(); shared.tb_hits.reset(); + shared.pondering.store(pondering, Ordering::Release); + shared.ponderhit.store(false, Ordering::Release); shared.soft_stop_votes.store(0, Ordering::Release); shared.status.set(Status::RUNNING); shared.best_stats.iter().for_each(|x| { diff --git a/src/time.rs b/src/time.rs index a60305c9..ad872313 100644 --- a/src/time.rs +++ b/src/time.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; use crate::thread::ThreadData; @@ -17,13 +18,56 @@ const TIME_OVERHEAD_MS: u64 = 15; #[derive(Clone)] pub struct TimeManager { limits: Limits, + ponder: bool, start_time: Instant, soft_bound: Duration, hard_bound: Duration, } +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::thread::SharedContext; + + use super::*; + + #[test] + fn test_ponderhit_turns_off_pondering_in_timer() { + let mut tm = TimeManager::new(Limits::Time(10_000), 0, 0, true); + assert!(tm.is_ponder()); + tm.on_ponderhit(); + assert!(!tm.is_ponder()); + } + + #[test] + fn test_ponderhit_preserves_elapsed_time() { + let mut tm = TimeManager::new(Limits::Time(10_000), 0, 0, true); + std::thread::sleep(Duration::from_millis(10)); + let before = tm.elapsed(); + tm.on_ponderhit(); + let after = tm.elapsed(); + assert!(after >= before); + } + + #[test] + fn test_soft_limit_ignored_while_pondering() { + let shared = Arc::new(SharedContext::default()); + let mut td = crate::thread::ThreadData::new(shared.clone()); + td.time_manager = TimeManager::new(Limits::Time(1), 0, 0, true); + td.completed_depth = 1; + + shared.pondering.store(true, Ordering::Release); + std::thread::sleep(Duration::from_millis(20)); + assert!(!td.time_manager.soft_limit(&td, || 1.0)); + + shared.pondering.store(false, Ordering::Release); + assert!(td.time_manager.soft_limit(&td, || 1.0)); + } +} + impl TimeManager { - pub fn new(limits: Limits, fullmove_number: usize, move_overhead: u64) -> Self { + pub fn new(limits: Limits, fullmove_number: usize, move_overhead: u64, ponder: bool) -> Self { let soft; let hard; @@ -57,6 +101,7 @@ impl TimeManager { Self { limits, + ponder, start_time: Instant::now(), soft_bound: Duration::from_millis(soft.saturating_sub(TIME_OVERHEAD_MS)), hard_bound: Duration::from_millis(hard.saturating_sub(TIME_OVERHEAD_MS)), @@ -68,6 +113,10 @@ impl TimeManager { } pub fn soft_limit(&self, td: &ThreadData, multiplier: impl Fn() -> f32) -> bool { + if self.ponder && td.shared.pondering.load(Ordering::Acquire) { + return false; + } + match self.limits { Limits::Infinite | Limits::Depth(_) => false, Limits::Nodes(maximum) => td.shared.nodes.aggregate() >= maximum, @@ -81,6 +130,10 @@ impl TimeManager { return false; } + if self.ponder && td.shared.pondering.load(Ordering::Acquire) { + return false; + } + match self.limits { Limits::Infinite | Limits::Depth(_) => false, Limits::Nodes(maximum) => td.shared.nodes.aggregate() > maximum, @@ -91,4 +144,12 @@ impl TimeManager { pub fn limits(&self) -> Limits { self.limits.clone() } + + pub fn is_ponder(&self) -> bool { + self.ponder + } + + pub fn on_ponderhit(&mut self) { + self.ponder = false; + } } diff --git a/src/tools/bench.rs b/src/tools/bench.rs index dc4e1c8b..31155c06 100644 --- a/src/tools/bench.rs +++ b/src/tools/bench.rs @@ -100,13 +100,13 @@ pub fn bench(args: &[&str]) { let now = Instant::now(); let board = Board::from_fen(position).unwrap(); - let time_manager = TimeManager::new(Limits::Depth(depth), 0, 0); + let time_manager = TimeManager::new(Limits::Depth(depth), 0, 0, false); for td in &mut pool.vector { td.board = board.clone(); } - pool.execute_searches(time_manager, Report::None, &shared); + pool.execute_searches(time_manager, Report::None, &shared, false); nodes += shared.nodes.aggregate(); diff --git a/src/uci.rs b/src/uci.rs index 86851d48..052aaee2 100644 --- a/src/uci.rs +++ b/src/uci.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; +use std::sync::atomic::Ordering; use crate::{ board::{Board, NullBoardObserver}, @@ -21,6 +22,7 @@ enum Mode { struct Settings { frc: bool, + ponder: bool, multi_pv: usize, move_overhead: u64, report: Report, @@ -30,6 +32,7 @@ impl Default for Settings { fn default() -> Self { Self { frc: false, + ponder: false, multi_pv: 1, move_overhead: 100, report: Report::Full, @@ -124,6 +127,9 @@ fn spawn_listener(shared: Arc) -> std::sync::mpsc::Receiver println!("readyok"), "stop" => shared.status.set(Status::STOPPED), + "ponderhit" => { + shared.ponderhit.store(true, Ordering::Release); + } "quit" => { shared.status.set(Status::STOPPED); let _ = tx.send("quit".to_string()); @@ -153,6 +159,7 @@ fn uci() { println!("option name Minimal type check default false"); println!("option name Clear Hash type button"); println!("option name UCI_Chess960 type check default false"); + println!("option name Ponder type check default false"); println!("option name MultiPV type spin default 1 min 1 max {MAX_MOVES}"); #[cfg(feature = "syzygy")] @@ -183,11 +190,12 @@ fn reset(threads: &mut ThreadPool, shared: &Arc) { fn go(threads: &mut ThreadPool, settings: &Settings, shared: &Arc, tokens: &[&str]) { let board = &threads.main_thread().board; - let limits = parse_limits(board.side_to_move(), tokens); - let time_manager = TimeManager::new(limits, board.fullmove_number(), settings.move_overhead); + let go_options = parse_go_options(board.side_to_move(), tokens); + let is_ponder = go_options.ponder; + let time_manager = TimeManager::new(go_options.limits, board.fullmove_number(), settings.move_overhead, is_ponder); threads.main_thread().multi_pv = settings.multi_pv; - threads.execute_searches(time_manager, settings.report, shared); + threads.execute_searches(time_manager, settings.report, shared, is_ponder); let min_score = threads.iter().map(|v| v.root_moves[0].score).min().unwrap(); let vote_value = |td: &ThreadData| (td.root_moves[0].score - min_score + 10) * td.completed_depth; @@ -238,7 +246,15 @@ fn go(threads: &mut ThreadPool, settings: &Settings, shared: &Arc threads[best].print_uci_info(threads[best].completed_depth); } - println!("bestmove {}", threads[best].root_moves[0].mv.to_uci(&threads.main_thread().board)); + let best_move = threads[best].root_moves[0].mv; + let mut bestmove_output = format!("bestmove {}", best_move.to_uci(&threads.main_thread().board)); + + if let Some(ponder_move) = extract_ponder_move(threads, best, best_move) { + bestmove_output.push_str(" ponder "); + bestmove_output.push_str(&ponder_move.to_uci(&threads.main_thread().board)); + } + + println!("{bestmove_output}"); crate::misc::dbg_print(); } @@ -282,6 +298,28 @@ fn make_uci_move(board: &mut Board, uci_move: &str) { } } +fn extract_ponder_move(threads: &mut ThreadPool, best: usize, best_move: Move) -> Option { + let root_move = &threads[best].root_moves[0]; + + if let Some(&ponder_move) = root_move.pv.line().first() { + return Some(ponder_move); + } + + let mut board = threads.main_thread().board.clone(); + board.make_move(best_move, &mut NullBoardObserver); + + let hash = board.hash(); + let halfmove_clock = board.halfmove_clock(); + let tt_entry = threads.main_thread().shared.tt.read(hash, halfmove_clock, 0)?; + + if tt_entry.mv.is_null() { + return None; + } + + let is_legal = board.generate_all_moves().iter().any(|entry| entry.mv == tt_entry.mv); + is_legal.then_some(tt_entry.mv) +} + fn set_option(threads: &mut ThreadPool, settings: &mut Settings, shared: &Arc, tokens: &[&str]) { match tokens { ["name", "Minimal", "value", v] => match *v { @@ -314,6 +352,10 @@ fn set_option(threads: &mut ThreadPool, settings: &mut Settings, shared: &Arc { + settings.ponder = v.parse().unwrap_or_default(); + println!("info string set Ponder to {v}"); + } ["name", "MultiPV", "value", v] => { settings.multi_pv = v.parse().unwrap_or_default(); println!("info string set MultiPV to {v}"); @@ -385,53 +427,92 @@ fn eval(td: &mut ThreadData) { println!("\nNNUE evaluation {:+.2} (White side)", final_total); } -fn parse_limits(color: Color, tokens: &[&str]) -> Limits { - if let ["infinite"] = tokens { - return Limits::Infinite; - } +struct GoOptions { + limits: Limits, + ponder: bool, +} +fn parse_go_options(color: Color, tokens: &[&str]) -> GoOptions { + let mut ponder = false; let mut main = None; let mut inc = None; let mut moves = None; - - for chunk in tokens.chunks(2) { - if let [name, value] = *chunk { - let Ok(value) = value.parse::() else { - continue; - }; - - match name { - "depth" if value > 0 => return Limits::Depth(value as i32), - "movetime" if value > 0 => return Limits::Time(value), - "nodes" if value > 0 => return Limits::Nodes(value), - - "wtime" if Color::White == color => main = Some(value), - "btime" if Color::Black == color => main = Some(value), - "winc" if Color::White == color => inc = Some(value), - "binc" if Color::Black == color => inc = Some(value), - "movestogo" => moves = Some(value), - - _ => continue, + let mut direct_limits = None; + + let mut index = 0; + while index < tokens.len() { + match (tokens[index], tokens.get(index + 1).and_then(|v| v.parse::().ok())) { + ("infinite", _) => { + direct_limits = Some(Limits::Infinite); + index += 1; + } + ("ponder", _) => { + ponder = true; + index += 1; + } + ("depth", Some(value)) if value > 0 => { + direct_limits = Some(Limits::Depth(value as i32)); + index += 2; + } + ("movetime", Some(value)) if value > 0 => { + direct_limits = Some(Limits::Time(value)); + index += 2; + } + ("nodes", Some(value)) if value > 0 => { + direct_limits = Some(Limits::Nodes(value)); + index += 2; + } + ("wtime", Some(value)) if Color::White == color => { + main = Some(value); + index += 2; + } + ("btime", Some(value)) if Color::Black == color => { + main = Some(value); + index += 2; + } + ("winc", Some(value)) if Color::White == color => { + inc = Some(value); + index += 2; + } + ("binc", Some(value)) if Color::Black == color => { + inc = Some(value); + index += 2; + } + ("movestogo", Some(value)) => { + moves = Some(value); + index += 2; + } + ("depth" | "movetime" | "nodes" | "wtime" | "btime" | "winc" | "binc" | "movestogo", None) => { + index += 1; + } + _ => { + index += 1; } } } - if main.is_none() && inc.is_none() { - return Limits::Infinite; - } - - let main = main.unwrap_or_default(); - let inc = inc.unwrap_or_default(); + let limits = if let Some(direct_limits) = direct_limits { + direct_limits + } else if main.is_none() && inc.is_none() { + Limits::Infinite + } else { + let main = main.unwrap_or_default(); + let inc = inc.unwrap_or_default(); + + match moves { + Some(moves) => Limits::Cyclic(main, inc, moves), + None => Limits::Fischer(main, inc), + } + }; - match moves { - Some(moves) => Limits::Cyclic(main, inc, moves), - None => Limits::Fischer(main, inc), - } + GoOptions { limits, ponder } } #[cfg(test)] mod tests { use super::*; + use crate::thread::RootMove; + use crate::transposition::{Bound, TtDepth}; fn test_position_helper(tokens: &[&str]) -> Board { let shared = Arc::new(SharedContext::default()); @@ -442,6 +523,10 @@ mod tests { threads.main_thread().board.clone() } + fn find_move(board: &Board, uci: &str) -> Move { + board.generate_all_moves().iter().map(|entry| entry.mv).find(|mv| mv.to_uci(board) == uci).unwrap() + } + #[test] fn test_position_startpos() { let board = test_position_helper(&["startpos"]); @@ -544,4 +629,86 @@ mod tests { let board = test_position_helper(&["moves", "e2e4", "e7e5"]); assert_eq!(board.to_fen(), "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2"); } + + #[test] + fn test_parse_go_options_ponder() { + let options = parse_go_options(Color::White, &["ponder", "wtime", "1000", "btime", "1000", "winc", "50"]); + assert!(options.ponder); + assert!(matches!(options.limits, Limits::Fischer(1000, 50))); + } + + #[test] + fn test_parse_go_options_depth() { + let options = parse_go_options(Color::White, &["depth", "10", "ponder"]); + assert!(options.ponder); + assert!(matches!(options.limits, Limits::Depth(10))); + } + + #[test] + fn test_parse_go_options_ponder_with_movetime() { + let options = parse_go_options(Color::White, &["ponder", "movetime", "1000"]); + assert!(options.ponder); + assert!(matches!(options.limits, Limits::Time(1000))); + } + + #[test] + fn test_parse_go_options_ponder_with_invalid_limit_value() { + let options = parse_go_options(Color::White, &["wtime", "1000", "winc", "nope", "ponder"]); + assert!(options.ponder); + assert!(matches!(options.limits, Limits::Fischer(1000, 0))); + } + + #[test] + fn test_extract_ponder_move_prefers_pv() { + let shared = Arc::new(SharedContext::default()); + let mut threads = ThreadPool::new(shared); + + let best_move = find_move(&threads.main_thread().board, "e2e4"); + let ponder_move = find_move(&threads.main_thread().board, "d2d4"); + + let mut pv = crate::thread::PrincipalVariationTable::default(); + pv.update(0, ponder_move); + threads.main_thread().root_moves = vec![RootMove { mv: best_move, pv, ..Default::default() }]; + + assert_eq!(extract_ponder_move(&mut threads, 0, best_move), Some(ponder_move)); + } + + #[test] + fn test_extract_ponder_move_uses_tt_fallback() { + let shared = Arc::new(SharedContext::default()); + let mut threads = ThreadPool::new(shared); + + let best_move = find_move(&threads.main_thread().board, "e2e4"); + threads.main_thread().root_moves = vec![RootMove { mv: best_move, ..Default::default() }]; + + let mut after_best = threads.main_thread().board.clone(); + after_best.make_move(best_move, &mut NullBoardObserver); + + let reply = find_move(&after_best, "e7e5"); + let hash = after_best.hash(); + + threads.main_thread().shared.tt.write(hash, TtDepth::SOME, 0, 0, Bound::Exact, reply, 0, true, false); + + assert_eq!(extract_ponder_move(&mut threads, 0, best_move), Some(reply)); + } + + #[test] + fn test_extract_ponder_move_rejects_illegal_tt_move() { + let shared = Arc::new(SharedContext::default()); + let mut threads = ThreadPool::new(shared); + + let best_move = find_move(&threads.main_thread().board, "e2e4"); + threads.main_thread().root_moves = vec![RootMove { mv: best_move, ..Default::default() }]; + + let mut after_best = threads.main_thread().board.clone(); + after_best.make_move(best_move, &mut NullBoardObserver); + + // e2e4 is no longer legal for black in the resulting position. + let illegal_reply = best_move; + let hash = after_best.hash(); + + threads.main_thread().shared.tt.write(hash, TtDepth::SOME, 0, 0, Bound::Exact, illegal_reply, 0, true, false); + + assert_eq!(extract_ponder_move(&mut threads, 0, best_move), None); + } }