use std::io::{self, BufRead, Write};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use sekirei_core::{
board::Board,
color::Color,
dfpn::{DfpnConfig, DfpnOutcome, DfpnSolver},
lazy_smp::{LazySmpSearcher, LazySmpWorkerInfo},
mcts::{MaterialValue, SharedTreeMcts, SharedTreeMctsConfig},
movegen::generate_legal_moves,
nnue::load_weights,
search::{MATE_SCORE, SearchConfig, SpecSearchInfo, SpeculativeSearcher},
sfen::{board_to_sfen, move_to_usi, parse_position_cmd},
tt::Tt,
};
mod book;
mod invariant;
use book::Book;
use invariant::DiagCtx;
const ENGINE_NAME: &str = "Sekirei";
const ENGINE_AUTHOR: &str = "Kentaro Tanabe";
const DEFAULT_HASH_MB: usize = 64;
const DEFAULT_BOOK_FILE: &str = "data/opening_book.jsonl";
const DEFAULT_SPEC_TOP_N: usize = 3;
#[derive(Clone, Copy, PartialEq, Eq)]
enum SearchMode {
Speculative,
LazySmp,
Dfpn,
SharedMcts,
}
struct SearchResult {
best_move: Option<sekirei_core::mv::Move>,
score: i32,
depth: u32,
nodes: u64,
elapsed: Duration,
hashfull: u32,
pv_list: Vec<(sekirei_core::mv::Move, i32)>,
worker_stats: Vec<LazySmpWorkerInfo>,
shared_mcts_stats: Option<(u32, u32, u32)>,
}
enum SearchBackend {
Speculative(Arc<SpeculativeSearcher>),
LazySmp(Arc<LazySmpSearcher>),
Dfpn(Arc<DfpnBackend>),
SharedMcts(Arc<SharedMctsBackend>),
}
struct DfpnBackend {
solver: DfpnSolver,
abort: Arc<AtomicBool>,
}
struct SharedMctsBackend {
searcher: SharedTreeMcts,
abort: Arc<AtomicBool>,
}
impl SearchBackend {
fn speculative(hash_mb: usize, spec_top_n: usize) -> Self {
Self::Speculative(Arc::new(SpeculativeSearcher::new(
Tt::new(hash_mb),
spec_top_n,
)))
}
fn lazy_smp(hash_mb: usize, workers: usize) -> Self {
Self::LazySmp(Arc::new(LazySmpSearcher::new(Tt::new(hash_mb), workers)))
}
fn dfpn() -> Self {
Self::Dfpn(Arc::new(DfpnBackend {
solver: DfpnSolver,
abort: Arc::new(AtomicBool::new(false)),
}))
}
fn shared_mcts() -> Self {
Self::SharedMcts(Arc::new(SharedMctsBackend {
searcher: SharedTreeMcts::default(),
abort: Arc::new(AtomicBool::new(false)),
}))
}
fn abort_flag(&self) -> Arc<AtomicBool> {
match self {
Self::Speculative(s) => s.abort_flag(),
Self::LazySmp(s) => s.abort_flag(),
Self::Dfpn(s) => Arc::clone(&s.abort),
Self::SharedMcts(s) => Arc::clone(&s.abort),
}
}
fn reset_abort_flag(&self) {
match self {
Self::Speculative(s) => s.reset_abort_flag(),
Self::LazySmp(s) => s.reset_abort_flag(),
Self::Dfpn(s) => s.abort.store(false, Ordering::Relaxed),
Self::SharedMcts(s) => s.abort.store(false, Ordering::Relaxed),
}
}
fn clear_tt(&self) {
match self {
Self::Speculative(s) => s.clear_tt(),
Self::LazySmp(s) => s.clear_tt(),
Self::Dfpn(_) => {}
Self::SharedMcts(_) => {}
}
}
fn probe_tt(&self, hash: u64) -> Option<sekirei_core::mv::Move> {
match self {
Self::Speculative(s) => s.probe_tt(hash),
Self::LazySmp(s) => s.probe_tt(hash),
Self::Dfpn(_) => None,
Self::SharedMcts(_) => None,
}
}
fn search(&self, board: &mut Board, config: SearchConfig) -> SearchResult {
match self {
Self::Speculative(s) => normalize_spec_result(s.search(board, config)),
Self::LazySmp(s) => {
let info = s.search(board, config);
let result = info.result;
SearchResult {
best_move: result.best_move.or_else(|| fallback_legal_move(board)),
score: result.score,
depth: result.depth,
nodes: info.total_nodes,
elapsed: info.elapsed,
hashfull: result.hashfull,
pv_list: Vec::new(),
worker_stats: info.worker_results,
shared_mcts_stats: None,
}
}
Self::Dfpn(s) => {
let started = Instant::now();
let timer = config.time_limit.map(|limit| {
let abort = Arc::clone(&s.abort);
let (cancel_tx, cancel_rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
if cancel_rx.recv_timeout(limit).is_err() {
abort.store(true, Ordering::Relaxed);
}
});
(cancel_tx, handle)
});
let result = s.solver.solve_with_abort(
board,
DfpnConfig {
max_depth: config.max_depth.min(u16::MAX as u32) as u16,
node_limit: config.node_limit.unwrap_or(100_000),
..DfpnConfig::default()
},
&s.abort,
);
if let Some((cancel_tx, handle)) = timer {
let _ = cancel_tx.send(());
let _ = handle.join();
}
let score = match result.outcome {
DfpnOutcome::Proven => MATE_SCORE - config.max_depth as i32,
DfpnOutcome::Disproven | DfpnOutcome::Unknown => 0,
};
SearchResult {
best_move: result.best_move.or_else(|| fallback_legal_move(board)),
score,
depth: config.max_depth,
nodes: result.nodes,
elapsed: started.elapsed(),
hashfull: 0,
pv_list: Vec::new(),
worker_stats: Vec::new(),
shared_mcts_stats: None,
}
}
Self::SharedMcts(s) => {
let started = Instant::now();
let timer = config.time_limit.map(|limit| {
let abort = Arc::clone(&s.abort);
let (cancel_tx, cancel_rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
if cancel_rx.recv_timeout(limit).is_err() {
abort.store(true, Ordering::Relaxed);
}
});
(cancel_tx, handle)
});
let info = s.searcher.search_with_abort(
board,
SharedTreeMctsConfig {
simulations: config.node_limit.unwrap_or(128).min(u32::MAX as u64) as u32,
max_depth: config.max_depth.min(u16::MAX as u32) as u16,
share_transpositions: true,
},
&sekirei_core::mcts::UniformPolicy,
&MaterialValue,
&s.abort,
);
if let Some((cancel_tx, handle)) = timer {
let _ = cancel_tx.send(());
let _ = handle.join();
}
SearchResult {
best_move: info.best_move.or_else(|| fallback_legal_move(board)),
score: info.score,
depth: config.max_depth,
nodes: info.nodes as u64,
elapsed: started.elapsed(),
hashfull: 0,
pv_list: Vec::new(),
worker_stats: Vec::new(),
shared_mcts_stats: Some((
info.simulations,
info.nodes,
info.transposition_hits,
)),
}
}
}
}
}
fn normalize_spec_result(info: SpecSearchInfo) -> SearchResult {
SearchResult {
best_move: info.best_move,
score: info.score,
depth: info.depth,
nodes: info.nodes,
elapsed: info.elapsed,
hashfull: info.hashfull,
pv_list: info.pv_list,
worker_stats: Vec::new(),
shared_mcts_stats: None,
}
}
fn fallback_legal_move(board: &Board) -> Option<sekirei_core::mv::Move> {
let mut probe = board.clone();
generate_legal_moves(&mut probe).into_iter().next()
}
fn score_to_usi(score: i32) -> String {
if score >= MATE_SCORE - 1000 {
format!("mate {}", MATE_SCORE - score)
} else if score <= -MATE_SCORE + 1000 {
format!("mate -{}", MATE_SCORE + score)
} else {
format!("cp {score}")
}
}
fn abort_and_join_inflight_search(
search_abort: &mut Option<Arc<AtomicBool>>,
search_handle: &mut Option<JoinHandle<()>>,
) {
if let Some(a) = search_abort.take() {
a.store(true, Ordering::Relaxed);
}
if let Some(h) = search_handle.take() {
h.join().ok();
}
}
fn main() {
if let Some(arg) = std::env::args().nth(1)
&& matches!(arg.as_str(), "--version" | "-V")
{
println!("Sekirei {}", env!("CARGO_PKG_VERSION"));
return;
}
if let Some(arg) = std::env::args().nth(1)
&& matches!(arg.as_str(), "--help" | "-h")
{
println!(
"Sekirei {}\n\nUSI shogi engine\n\nUsage:\n sekirei [NNUE_WEIGHTS]\n sekirei --version\n sekirei --help\n\nThe engine reads USI commands from stdin.",
env!("CARGO_PKG_VERSION")
);
return;
}
let mut weight_path = String::new();
let mut weight_hash: Option<u64> = None;
if let Some(path) = std::env::args().nth(1) {
match load_weights(Path::new(&path)) {
Ok(()) => eprintln!("info string NNUE weights loaded from {path}"),
Err(e) => eprintln!("info string weight load failed ({path}): {e}"),
}
weight_hash = invariant::hash_file(&path);
weight_path = path;
}
let binary_hash = std::env::current_exe()
.ok()
.and_then(|p| invariant::hash_file(p.to_str()?));
let stdin = io::stdin();
let stdout = io::stdout();
let mut hash_mb = DEFAULT_HASH_MB;
let mut spec_top_n = DEFAULT_SPEC_TOP_N;
let mut threads: u32 = 0;
let mut search_mode = SearchMode::Speculative;
let mut searcher = make_searcher(hash_mb, spec_top_n, threads_for_lazy_smp(0), search_mode);
let mut eval_file: Option<String> = None;
let mut move_overhead_ms: u64 = 50;
let mut multi_pv: u32 = 1;
let mut use_book = true;
let mut book_max_ply: usize = 30;
let mut book_min_confidence: f64 = 0.20;
let mut book_file = DEFAULT_BOOK_FILE.to_string();
let mut book: Option<Book> = None;
let mut book_loaded_path: Option<String> = None;
let mut board = Board::startpos();
let mut current_ply: usize = 0;
let mut game_counter: u64 = 0;
let mut last_position_cmd = String::from("startpos");
let mut search_abort: Option<Arc<AtomicBool>> = None;
let mut search_handle: Option<JoinHandle<()>> = None;
let suppress_bm: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
let mut ponder_go_args: Option<String> = None;
for raw in stdin.lock().lines() {
let Ok(line) = raw else { break };
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let (cmd, rest) = line
.split_once(' ')
.map(|(c, r)| (c, r.trim()))
.unwrap_or((&line, ""));
match cmd {
"usi" => {
println!("id name {ENGINE_NAME}");
println!("id author {ENGINE_AUTHOR}");
println!("option name Hash type spin default {DEFAULT_HASH_MB} min 1 max 2048");
println!("option name Threads type spin default 0 min 0 max 512");
println!(
"option name SearchMode type combo default Speculative var Speculative var LazySMP var Dfpn var SharedMcts"
);
println!(
"option name SpecTopN type spin default {DEFAULT_SPEC_TOP_N} min 0 max 512"
);
println!("option name MoveOverhead type spin default 50 min 0 max 5000");
println!("option name Ponder type check default false");
println!("option name MultiPV type spin default 1 min 1 max 256");
println!("option name EvalFile type string default ");
println!("option name UseBook type check default true");
println!("option name BookMaxPly type spin default 30 min 0 max 200");
println!("option name BookMinConfidence type string default 0.20");
println!("option name BookFile type string default {DEFAULT_BOOK_FILE}");
println!("usiok");
stdout.lock().flush().ok();
}
"isready" => {
if let Some(ref path) = eval_file {
match sekirei_core::nnue::load_weights(Path::new(path)) {
Ok(()) => {
println!("info string NNUE weights loaded from {path}");
board.refresh_acc();
}
Err(e) => println!("info string weight load failed: {e}"),
}
}
if use_book && book_loaded_path.as_deref() != Some(book_file.as_str()) {
match Book::load(&book_file) {
Ok(b) => {
println!(
"info string opening book loaded from {book_file} ({} positions)",
b.len()
);
book = Some(b);
book_loaded_path = Some(book_file.clone());
}
Err(e) => {
println!("info string opening book load failed ({book_file}): {e}");
book_loaded_path = Some(book_file.clone()); }
}
}
println!("readyok");
stdout.lock().flush().ok();
}
"setoption" => {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.get(1) == Some(&"Hash")
&& let Some(mb) = parts.get(3).and_then(|s| s.parse().ok())
{
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
hash_mb = mb;
searcher = make_searcher(
hash_mb,
spec_top_n,
threads_for_lazy_smp(threads),
search_mode,
);
} else if parts.get(1) == Some(&"SpecTopN")
&& let Some(n) = parts.get(3).and_then(|s| s.parse().ok())
{
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
spec_top_n = n;
searcher = make_searcher(
hash_mb,
spec_top_n,
threads_for_lazy_smp(threads),
search_mode,
);
} else if parts.get(1) == Some(&"Threads") {
if let Some(n) = parts.get(3).and_then(|s| s.parse::<usize>().ok()) {
if n == 0 || n > u32::MAX as usize {
println!(
"info string invalid Threads value {}; expected 1..={}",
n,
u32::MAX
);
} else {
threads = n as u32;
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build_global();
if search_mode == SearchMode::LazySmp {
abort_and_join_inflight_search(
&mut search_abort,
&mut search_handle,
);
searcher = make_searcher(
hash_mb,
spec_top_n,
threads_for_lazy_smp(threads),
search_mode,
);
}
}
}
} else if parts.get(1) == Some(&"SearchMode")
&& let Some(mode) = parts.get(3)
{
let new_mode = match *mode {
"LazySMP" => SearchMode::LazySmp,
"Speculative" => SearchMode::Speculative,
"Dfpn" => SearchMode::Dfpn,
"SharedMcts" => SearchMode::SharedMcts,
_ => continue,
};
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
search_mode = new_mode;
searcher = make_searcher(
hash_mb,
spec_top_n,
threads_for_lazy_smp(threads),
search_mode,
);
} else if parts.get(1) == Some(&"MoveOverhead") {
if let Some(n) = parts.get(3).and_then(|s| s.parse().ok()) {
move_overhead_ms = n;
}
} else if parts.get(1) == Some(&"MultiPV") {
if let Some(n) = parts.get(3).and_then(|s| s.parse::<u32>().ok()) {
multi_pv = n.max(1);
}
} else if parts.get(1) == Some(&"EvalFile") {
if let Some(val) = rest.split_once("value ").map(|(_, v)| v.trim())
&& !val.is_empty()
{
eval_file = Some(val.to_string());
}
} else if parts.get(1) == Some(&"UseBook") {
if let Some(v) = parts.get(3) {
use_book = *v == "true";
}
} else if parts.get(1) == Some(&"BookMaxPly") {
if let Some(n) = parts.get(3).and_then(|s| s.parse().ok()) {
book_max_ply = n;
}
} else if parts.get(1) == Some(&"BookMinConfidence") {
if let Some(n) = parts.get(3).and_then(|s| s.parse().ok()) {
book_min_confidence = n;
}
} else if parts.get(1) == Some(&"BookFile")
&& let Some(val) = rest.split_once("value ").map(|(_, v)| v.trim())
&& !val.is_empty()
{
book_file = val.to_string();
}
}
"usinewgame" => {
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
board = Board::startpos();
current_ply = 0;
last_position_cmd = String::from("startpos");
searcher.clear_tt();
game_counter += 1;
}
"position" => match parse_position_cmd(rest) {
Ok(b) => {
board = b;
current_ply = rest
.split_whitespace()
.skip_while(|&t| t != "moves")
.skip(1)
.count();
last_position_cmd = rest.to_string();
invariant::verify_position_replay(
rest,
&board,
&invariant::ReplayDiagCtx {
game_counter,
weight_path: weight_path.clone(),
weight_hash,
binary_hash,
},
);
}
Err(e) => eprintln!("position error: {e}"),
},
"go" => {
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
let pondering = rest.split_whitespace().any(|t| t == "ponder");
if pondering {
ponder_go_args = Some(rest.to_string());
} else {
ponder_go_args = None;
}
suppress_bm.store(false, Ordering::Relaxed);
if !pondering
&& use_book
&& current_ply < book_max_ply
&& let Some(b) = &book
&& let Some(mv) = b.lookup(&board_to_sfen(&board), &board, book_min_confidence)
{
println!("info string book move");
invariant::assert_legal_bestmove(
&board,
mv,
&DiagCtx {
game_counter,
last_position_cmd: last_position_cmd.clone(),
weight_path: weight_path.clone(),
weight_hash,
threads,
board_hash_at_search_start: board.hash(),
accumulator_hash_at_search_start: invariant::hash_accumulator(
&board.acc,
),
},
);
println!("bestmove {}", move_to_usi(mv));
stdout.lock().flush().ok();
continue;
}
let config = parse_go(
rest,
board.side_to_move,
move_overhead_ms,
pondering,
multi_pv,
);
searcher.reset_abort_flag();
let abort = searcher.abort_flag();
search_abort = Some(abort);
let searcher2 = Arc::clone(&searcher);
let mut board2 = board.clone();
let suppress2 = Arc::clone(&suppress_bm);
let diag_ctx = DiagCtx {
game_counter,
last_position_cmd: last_position_cmd.clone(),
weight_path: weight_path.clone(),
weight_hash,
threads,
board_hash_at_search_start: board.hash(),
accumulator_hash_at_search_start: invariant::hash_accumulator(&board.acc),
};
search_handle = Some(std::thread::spawn(move || {
let info = searcher2.search(&mut board2, config);
if suppress2.load(Ordering::Relaxed) {
return; }
let elapsed_ms = info.elapsed.as_millis().max(1) as u64;
let nps = info.nodes.saturating_mul(1000) / elapsed_ms;
if !info.worker_stats.is_empty() {
let summary = info
.worker_stats
.iter()
.enumerate()
.map(|(i, worker)| {
format!(
"w{i}:d{}:n{}:s{}",
worker.depth, worker.nodes, worker.score
)
})
.collect::<Vec<_>>()
.join(",");
println!("info string lazy_smp {summary}");
}
if let Some((simulations, arena_nodes, transposition_hits)) =
info.shared_mcts_stats
{
println!(
"info string shared_mcts simulations {simulations} arena_nodes {arena_nodes} transposition_hits {transposition_hits}"
);
}
if info.pv_list.len() > 1 {
for (i, &(mv, score)) in info.pv_list.iter().enumerate() {
println!(
"info multipv {} depth {} score {} nodes {} nps {} time {} hashfull {} pv {}",
i + 1,
info.depth,
score_to_usi(score),
info.nodes,
nps,
elapsed_ms,
info.hashfull,
move_to_usi(mv)
);
}
} else if let Some(m) = info.best_move {
println!(
"info depth {} score {} nodes {} nps {} time {} hashfull {} pv {}",
info.depth,
score_to_usi(info.score),
info.nodes,
nps,
elapsed_ms,
info.hashfull,
move_to_usi(m)
);
}
let best = info
.best_move
.map(move_to_usi)
.unwrap_or_else(|| "resign".to_string());
let ponder_token = info.best_move.and_then(|m| {
let token = board2.do_move(m);
let pm = searcher2.probe_tt(board2.hash());
board2.undo_move(token);
pm
});
if let Some(mv) = info.best_move {
invariant::assert_legal_bestmove(&board2, mv, &diag_ctx);
}
if let Some(pm) = ponder_token {
println!("bestmove {best} ponder {}", move_to_usi(pm));
} else {
println!("bestmove {best}");
}
io::stdout().lock().flush().ok();
}));
}
"stop" => {
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
}
"ponderhit" => {
suppress_bm.store(true, Ordering::Relaxed);
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
suppress_bm.store(false, Ordering::Relaxed);
if let Some(ref args) = ponder_go_args.take() {
let config =
parse_go(args, board.side_to_move, move_overhead_ms, false, multi_pv);
searcher.reset_abort_flag();
let abort = searcher.abort_flag();
search_abort = Some(abort);
let searcher2 = Arc::clone(&searcher);
let mut board2 = board.clone();
let suppress2 = Arc::clone(&suppress_bm);
let diag_ctx = DiagCtx {
game_counter,
last_position_cmd: last_position_cmd.clone(),
weight_path: weight_path.clone(),
weight_hash,
threads,
board_hash_at_search_start: board.hash(),
accumulator_hash_at_search_start: invariant::hash_accumulator(&board.acc),
};
search_handle = Some(std::thread::spawn(move || {
let info = searcher2.search(&mut board2, config);
if suppress2.load(Ordering::Relaxed) {
return;
}
let elapsed_ms = info.elapsed.as_millis().max(1) as u64;
let nps = info.nodes.saturating_mul(1000) / elapsed_ms;
if let Some((simulations, arena_nodes, transposition_hits)) =
info.shared_mcts_stats
{
println!(
"info string shared_mcts simulations {simulations} arena_nodes {arena_nodes} transposition_hits {transposition_hits}"
);
}
if let Some(m) = info.best_move {
println!(
"info depth {} score {} nodes {} nps {} time {} hashfull {} pv {}",
info.depth,
score_to_usi(info.score),
info.nodes,
nps,
elapsed_ms,
info.hashfull,
move_to_usi(m)
);
}
let best = info
.best_move
.map(move_to_usi)
.unwrap_or_else(|| "resign".to_string());
let ponder_token = info.best_move.and_then(|m| {
let token = board2.do_move(m);
let pm = searcher2.probe_tt(board2.hash());
board2.undo_move(token);
pm
});
if let Some(mv) = info.best_move {
invariant::assert_legal_bestmove(&board2, mv, &diag_ctx);
}
if let Some(pm) = ponder_token {
println!("bestmove {best} ponder {}", move_to_usi(pm));
} else {
println!("bestmove {best}");
}
io::stdout().lock().flush().ok();
}));
}
}
"gameover" => {}
"quit" => {
abort_and_join_inflight_search(&mut search_abort, &mut search_handle);
break;
}
_ => {
eprintln!("unknown command: '{cmd}'");
}
}
}
}
fn threads_for_lazy_smp(threads: u32) -> usize {
threads.max(1) as usize
}
fn make_searcher(
hash_mb: usize,
spec_top_n: usize,
threads: usize,
mode: SearchMode,
) -> Arc<SearchBackend> {
Arc::new(match mode {
SearchMode::Speculative => SearchBackend::speculative(hash_mb, spec_top_n),
SearchMode::LazySmp => SearchBackend::lazy_smp(hash_mb, threads),
SearchMode::Dfpn => SearchBackend::dfpn(),
SearchMode::SharedMcts => SearchBackend::shared_mcts(),
})
}
fn parse_go(
args: &str,
side: Color,
overhead_ms: u64,
pondering: bool,
multi_pv: u32,
) -> SearchConfig {
let mut btime: Option<u64> = None;
let mut wtime: Option<u64> = None;
let mut byoyomi: Option<u64> = None;
let mut binc: Option<u64> = None;
let mut winc: Option<u64> = None;
let mut movestogo: Option<u64> = None;
let mut movetime: Option<u64> = None;
let mut depth: Option<u32> = None;
let mut nodes: Option<u64> = None;
let mut infinite = false;
let tokens: Vec<&str> = args.split_whitespace().collect();
let mut i = 0;
while i < tokens.len() {
match tokens[i] {
"btime" => {
i += 1;
btime = tokens.get(i).and_then(|s| s.parse().ok());
}
"wtime" => {
i += 1;
wtime = tokens.get(i).and_then(|s| s.parse().ok());
}
"byoyomi" => {
i += 1;
byoyomi = tokens.get(i).and_then(|s| s.parse().ok());
}
"binc" => {
i += 1;
binc = tokens.get(i).and_then(|s| s.parse().ok());
}
"winc" => {
i += 1;
winc = tokens.get(i).and_then(|s| s.parse().ok());
}
"movestogo" => {
i += 1;
movestogo = tokens.get(i).and_then(|s| s.parse().ok());
}
"movetime" => {
i += 1;
movetime = tokens.get(i).and_then(|s| s.parse().ok());
}
"depth" => {
i += 1;
depth = tokens.get(i).and_then(|s| s.parse().ok());
}
"nodes" => {
i += 1;
nodes = tokens.get(i).and_then(|s| s.parse().ok());
}
"infinite" => {
infinite = true;
}
_ => {}
}
i += 1;
}
let has_clock = btime.is_some() || wtime.is_some() || byoyomi.is_some() || movetime.is_some();
let (time_limit, soft_limit) = if infinite || pondering {
(None, None)
} else if let Some(mt) = movetime {
(
Some(Duration::from_millis(
mt.saturating_sub(overhead_ms).max(50),
)),
None,
)
} else if depth.is_some() && !has_clock {
(None, None) } else if has_clock {
let our_time = match side {
Color::Black => btime.unwrap_or(0),
Color::White => wtime.unwrap_or(0),
};
let increment = match side {
Color::Black => binc.unwrap_or(0),
Color::White => winc.unwrap_or(0),
};
let byo_ms = byoyomi.unwrap_or(0);
let effective_time = our_time.saturating_add(increment);
let moves_left = movestogo.unwrap_or(30).max(1);
let from_main = effective_time / moves_left;
let from_byo = byo_ms.saturating_mul(13) / 20;
let panic = our_time < 5_000 && byo_ms > 0;
let base = if panic {
from_byo
} else {
from_main.max(from_byo)
};
let base = base.saturating_sub(overhead_ms).max(50);
let byo_safe = byo_ms.saturating_sub(overhead_ms).max(50);
let hard_ms = if byo_ms > 0 {
(base.saturating_mul(3) / 2).min(byo_safe)
} else {
base.saturating_mul(3) / 2
}
.max(50);
let soft_ms = base.saturating_mul(4) / 5;
let hard = Some(Duration::from_millis(hard_ms));
let soft = if !panic {
Some(Duration::from_millis(soft_ms))
} else {
None
};
(hard, soft)
} else {
(None, None) };
SearchConfig {
max_depth: depth.unwrap_or(50),
time_limit,
node_limit: nodes,
soft_limit,
multi_pv,
}
}
#[cfg(test)]
mod tests {
use super::*;
use sekirei_core::color::Color;
#[test]
fn score_to_usi_preserves_cp_and_converts_mates() {
assert_eq!(score_to_usi(137), "cp 137");
assert_eq!(score_to_usi(-892), "cp -892");
assert_eq!(score_to_usi(MATE_SCORE - 1), "mate 1");
assert_eq!(score_to_usi(-(MATE_SCORE - 3)), "mate -3");
}
#[test]
fn score_to_usi_keeps_mate_threshold_consistent() {
assert_eq!(score_to_usi(MATE_SCORE - 1000), "mate 1000");
assert_eq!(score_to_usi(-(MATE_SCORE - 1000)), "mate -1000");
assert_eq!(score_to_usi(MATE_SCORE - 1001), "cp 898999");
}
#[test]
fn parse_go_binc_winc() {
let cfg = parse_go(
"btime 60000 wtime 60000 binc 1000 winc 1000",
Color::Black,
0,
false,
1,
);
assert!(cfg.time_limit.is_some(), "hard limit should be set");
assert!(cfg.soft_limit.is_some(), "soft limit should be set");
let hard = cfg.time_limit.unwrap().as_millis();
let soft = cfg.soft_limit.unwrap().as_millis();
assert!(soft < hard, "soft_limit must be less than hard time_limit");
}
#[test]
fn parse_go_movestogo() {
let cfg = parse_go(
"btime 60000 wtime 60000 movestogo 20",
Color::Black,
0,
false,
1,
);
let hard = cfg.time_limit.unwrap().as_millis();
assert!((hard as i64 - 4500).abs() < 100, "hard={hard}");
}
#[test]
fn parse_go_byoyomi_only() {
let cfg = parse_go("byoyomi 5000", Color::Black, 0, false, 1);
assert!(cfg.time_limit.is_some());
assert!(cfg.soft_limit.is_none(), "panic mode: no soft limit");
let hard = cfg.time_limit.unwrap().as_millis();
assert!(hard <= 5000, "hard={hard} must not exceed byoyomi");
}
#[test]
fn parse_go_soft_less_than_hard() {
let cfg = parse_go("btime 120000 wtime 120000", Color::Black, 0, false, 1);
let hard = cfg.time_limit.unwrap().as_millis();
let soft = cfg.soft_limit.unwrap().as_millis();
assert!(soft < hard, "soft={soft} hard={hard}");
}
#[test]
fn byoyomi_hard_within_overhead() {
let cfg = parse_go("byoyomi 5000", Color::Black, 300, false, 1);
let hard = cfg.time_limit.unwrap().as_millis();
assert!(hard <= 4700, "hard={hard} exceeds byoyomi - overhead");
}
#[test]
fn pondering_no_limits() {
let cfg = parse_go("btime 60000 wtime 60000 ponder", Color::Black, 50, true, 1);
assert!(cfg.time_limit.is_none());
assert!(cfg.soft_limit.is_none());
}
#[test]
fn infinite_go_has_no_limits() {
let cfg = parse_go("infinite", Color::White, 50, false, 1);
assert!(cfg.time_limit.is_none());
assert!(cfg.soft_limit.is_none());
}
#[test]
fn multipv_value_reaches_search_config() {
let cfg = parse_go("depth 2", Color::Black, 0, false, 3);
assert_eq!(cfg.multi_pv, 3);
assert_eq!(cfg.max_depth, 2);
}
#[test]
fn nodes_value_reaches_search_config_without_a_time_limit() {
let cfg = parse_go("nodes 4096", Color::Black, 0, false, 1);
assert_eq!(cfg.node_limit, Some(4096));
assert!(cfg.time_limit.is_none());
assert!(cfg.soft_limit.is_none());
}
#[test]
fn malformed_nodes_value_is_ignored_without_panicking() {
let cfg = parse_go("nodes not-a-number depth 1", Color::Black, 0, false, 1);
assert_eq!(cfg.node_limit, None);
assert_eq!(cfg.max_depth, 1);
}
#[test]
fn malformed_clock_value_is_ignored_without_panicking() {
let cfg = parse_go("btime not-a-number depth 1", Color::Black, 0, false, 1);
assert!(cfg.time_limit.is_none());
assert_eq!(cfg.max_depth, 1);
}
#[test]
fn movetime_overhead_deducted() {
let cfg = parse_go("movetime 1000", Color::Black, 50, false, 1);
let hard = cfg.time_limit.unwrap().as_millis();
assert!(hard <= 950, "hard={hard}");
assert!(cfg.soft_limit.is_none());
}
#[test]
fn oversized_clock_values_do_not_overflow_time_budget_arithmetic() {
let cfg = parse_go(
&format!("btime {} byoyomi {}", u64::MAX, u64::MAX),
Color::Black,
0,
false,
1,
);
let hard = cfg.time_limit.expect("clock input should produce a limit");
assert!(hard <= Duration::from_millis(u64::MAX));
}
}