use std::io::{self, BufRead, Write};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
use sekirei_core::{
board::Board,
color::Color,
nnue::load_weights,
search::{SearchConfig, 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 = "ke.tanabe@gmail.com";
const DEFAULT_HASH_MB: usize = 64;
const DEFAULT_BOOK_FILE: &str = "data/opening_book.jsonl";
const DEFAULT_SPEC_TOP_N: usize = 3;
#[cfg(not(feature = "king_relative_b_small"))]
const NNUE_ARCH: &str = "A-flat-ps";
#[cfg(feature = "king_relative_b_small")]
const NNUE_ARCH: &str = "B-small-king9zone";
#[cfg(not(feature = "king_relative_b_small"))]
const WEIGHT_MAGIC: &str = "SEKIRW01";
#[cfg(feature = "king_relative_b_small")]
const WEIGHT_MAGIC: &str = "SEKIRW02";
fn expected_weight_size() -> usize {
use sekirei_core::nnue::{INPUT, L1, L2};
8 + INPUT * L1 * 2 + L1 * 2 + 2 * L1 * L2 * 4 + L2 * 4 + L2 * 4 + 4
}
fn print_build_info() {
let version = env!("CARGO_PKG_VERSION");
let weight_size_expected = expected_weight_size();
let king_relative_b_small = cfg!(feature = "king_relative_b_small");
let spec_top_n_default = DEFAULT_SPEC_TOP_N;
println!(
"{{\n \"name\": \"sekirei\",\n \"version\": \"{version}\",\n \"nnue_arch\": \"{NNUE_ARCH}\",\n \"weight_magic\": \"{WEIGHT_MAGIC}\",\n \"weight_size_expected\": {weight_size_expected},\n \"king_relative_b_small\": {king_relative_b_small},\n \"spec_top_n_default\": {spec_top_n_default}\n}}"
);
}
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 std::env::args().nth(1).as_deref() == Some("--build-info") {
print_build_info();
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 searcher = make_searcher(hash_mb, spec_top_n);
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 threads: u32 = 0;
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 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
&& !sekirei_core::nnue::weights_active()
{
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);
} 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);
} else if parts.get(1) == Some(&"Threads") {
if let Some(n) = parts.get(3).and_then(|s| s.parse::<usize>().ok()) {
threads = n as u32;
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build_global();
}
} 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();
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,
);
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.pv_list.len() > 1 {
for (i, &(mv, score)) in info.pv_list.iter().enumerate() {
println!(
"info multipv {} depth {} score cp {} nodes {} nps {} time {} hashfull {} pv {}",
i + 1,
info.depth,
score,
info.nodes,
nps,
elapsed_ms,
info.hashfull,
move_to_usi(mv)
);
}
} else if let Some(m) = info.best_move {
println!(
"info depth {} score cp {} nodes {} nps {} time {} hashfull {} pv {}",
info.depth,
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);
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(m) = info.best_move {
println!(
"info depth {} score cp {} nodes {} nps {} time {} hashfull {} pv {}",
info.depth,
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 make_searcher(hash_mb: usize, spec_top_n: usize) -> Arc<SpeculativeSearcher> {
Arc::new(SpeculativeSearcher::new(Tt::new(hash_mb), spec_top_n))
}
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 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());
}
"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 * 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 * 3 / 2).min(byo_safe)
} else {
base * 3 / 2
}
.max(50);
let soft_ms = base * 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,
soft_limit,
multi_pv,
}
}
#[cfg(test)]
mod tests {
use super::*;
use sekirei_core::color::Color;
#[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 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());
}
}