// One-shot decision binary for the benchmark playground and the competition,
// the Rust counterpart of approaches/lifetime-objective/leaf-evolution's
// decide.cpp:
//
// decide --board <49 digits> --next <1-7> --rise <1-5>
// [--depth 7] [--chance-samples 7]
// [--cache TOTAL_ENTRIES] [--threads T]
// [--scheduler frontier|root] [--split-plies auto|N]
// [--max-frontier-tasks N] [--max-host-bytes N]
//
// prints "bestmove <column>" (0-6) or "bestmove none" on a terminal board, and
// exits 0. The board is the engine's serializeBoard encoding, row-major from
// the top: 0 empty, 1-7 numbered, 8 solid gray, 9 cracked gray -- the same
// string the D7P protocol carries (docs/d7p-protocol.md).
//
// The policy reads exactly the public state: visible board, visible next disc,
// moves until the next rise. There is no seed, score, level or move number on
// the command line, so there is nothing else it could read. Decisions are
// deterministic for a given board and configuration, which the benchmark
// harness requires.
//
// CENTRAL-FRONTIER PARALLELISM IS VALUE-IDENTICAL, NOT AN APPROXIMATION. A
// coordinator expands a deterministic prefix of the expectimax tree, workers
// claim the remaining public-state continuations from a central registry, and
// the coordinator reduces results in the original column/sample order. This
// exposes thousands of tasks instead of at most seven root columns. Private
// worker tables can change cache hits and logical work, but cached values are
// bit-identical to recomputation, so threads change no root value or action.
// `--scheduler root` retains the earlier coarse-grained implementation as a
// measured fallback. `--cache` remains one aggregate entry budget, partitioned
// across private worker tables, or assigned once to a shared decision table.
// Increasing the thread count cannot multiply the caller's entry request.
use drop7_rs::board::{Board, BOARD_SIZE};
use drop7_rs::engine::State;
use drop7_rs::parallel::{
choose_action_frontier_parallel, choose_action_root_parallel, ParallelConfig, TableScope,
DEFAULT_MAX_FRONTIER_TASKS, DEFAULT_MAX_HOST_BYTES,
};
use drop7_rs::search::{work_bound_for, SearchParams};
use std::env;
fn main() {
let mut board_text = String::new();
let mut next = 0u8;
let mut rise = 0i32;
let mut depth = 7i32;
let mut strata = 7i32;
let mut cache = 1_048_576usize;
let mut threads = 0usize; // 0 = all CPUs visible to this process
let mut scheduler = String::from("frontier");
let mut table_scope = TableScope::Private;
let mut table_from_depth = 1i32;
let mut split_plies = None;
let mut max_frontier_tasks = DEFAULT_MAX_FRONTIER_TASKS;
let mut max_host_bytes = DEFAULT_MAX_HOST_BYTES;
let args: Vec<String> = env::args().collect();
let mut i = 1;
while i < args.len() {
if args[i] == "--help" || args[i] == "-h" {
eprintln!("usage: decide --board BOARD --next 1-7 --rise 1-5 [--depth N] [--chance-samples N] [--cache N] [--tt-scope private|shared] [--tt-from-depth N] [--threads N] [--scheduler frontier|root] [--split-plies auto|N] [--max-frontier-tasks N] [--max-host-bytes N]");
eprintln!("--cache is an aggregate entry budget in both scopes; --tt-from-depth defaults to 1.");
return;
}
if i + 1 >= args.len() {
eprintln!("decide failed: {} needs a value", args[i]);
std::process::exit(2);
}
match args[i].as_str() {
"--board" => board_text = args[i + 1].clone(),
"--next" => next = args[i + 1].parse().expect("--next must be 1-7"),
"--rise" => rise = args[i + 1].parse().expect("--rise must be 1-5"),
"--depth" => depth = args[i + 1].parse().expect("--depth"),
"--chance-samples" => strata = args[i + 1].parse().expect("--chance-samples"),
"--cache" => cache = args[i + 1].parse().expect("--cache"),
"--tt-scope" => {
table_scope = args[i + 1]
.parse()
.expect("--tt-scope must be private or shared")
}
"--tt-from-depth" => table_from_depth = args[i + 1].parse().expect("--tt-from-depth"),
"--threads" => threads = args[i + 1].parse().expect("--threads"),
"--scheduler" => scheduler = args[i + 1].clone(),
"--split-plies" => {
split_plies = if args[i + 1] == "auto" {
None
} else {
Some(
args[i + 1]
.parse()
.expect("--split-plies must be auto or an integer"),
)
}
}
"--max-frontier-tasks" => {
max_frontier_tasks = args[i + 1].parse().expect("--max-frontier-tasks")
}
"--max-host-bytes" => max_host_bytes = args[i + 1].parse().expect("--max-host-bytes"),
other => {
eprintln!("decide failed: unknown option {other}");
std::process::exit(2);
}
}
i += 2;
}
if depth < 1 || strata < 1 {
eprintln!("decide failed: depth and chance samples must be at least 1");
std::process::exit(2);
}
let Some(board) = Board::from_serialized(&board_text) else {
eprintln!("decide failed: --board must be 49 characters of digits 0-9");
std::process::exit(2);
};
if !(1..=BOARD_SIZE as u8).contains(&next) {
eprintln!("decide failed: --next must be 1-7");
std::process::exit(2);
}
if cache == 0 {
eprintln!("decide failed: --cache must be at least 1");
std::process::exit(2);
}
// The public state only: no score, level or move number exists here, and
// the search and leaf provably read none of them.
let state = State {
board,
next_disc: next,
score: 0,
level: 1,
moves_remaining: rise,
moves_played: 0,
game_over: false,
};
let params = SearchParams {
depth,
chance_samples: strata,
terminal_utility: -1_000_000.0,
maximum_work: work_bound_for(depth, strata) + 1,
policy_seed: 0xd707_5eed,
};
let requested_threads = if threads == 0 {
std::thread::available_parallelism().map_or(1, |n| n.get())
} else {
threads
};
// DepthTable rounds to powers of two. Partition the aggregate cache budget
// before constructing ParallelConfig so all private worker tables together
// remain at or below the caller's requested entry count.
let (worker_count, worker_cache) = match table_scope {
TableScope::Private => {
let workers = requested_threads.min(cache);
(workers, cache_entries_per_worker(cache, workers))
}
TableScope::Shared => (requested_threads, cache_entries_per_worker(cache, 1)),
};
let config = ParallelConfig {
threads: worker_count,
table_capacity_per_worker: worker_cache,
table_scope,
table_from_depth,
split_plies,
max_frontier_tasks,
max_host_bytes,
..ParallelConfig::default()
};
let decision = match scheduler.as_str() {
"frontier" => choose_action_frontier_parallel(&state, params, config),
"root" => choose_action_root_parallel(&state, params, config),
_ => {
eprintln!("decide failed: --scheduler must be frontier or root");
std::process::exit(2);
}
};
let decision = match decision {
Ok(decision) => decision,
Err(error) if error.contains("no legal columns") => {
println!("bestmove none");
return;
}
Err(error) => {
eprintln!("decide failed: {error}");
std::process::exit(2);
}
};
println!("bestmove {}", decision.action);
let metrics = decision.metrics;
let allocated_cache_entries = worker_cache
* match table_scope {
TableScope::Private => metrics.worker_threads,
TableScope::Shared => 1,
};
println!(
"info depth {} scheduler {} threads {} tasks {} split {} work {} nodes {} busy {:.4} wall {:.6} cache-entries {} table-bytes {} table-entry-bytes {} tt-scope {} tt-from-depth {} frozen 1",
depth,
scheduler,
metrics.worker_threads,
metrics.frontier_tasks,
metrics.split_plies,
metrics.work,
metrics.nodes,
metrics.worker_busy_fraction,
metrics.wall_seconds,
allocated_cache_entries,
metrics.projected_table_bytes,
metrics.table_entry_bytes,
table_scope.as_str(),
table_from_depth,
);
}
/// Largest power-of-two per-worker table that keeps the aggregate allocation
/// within `total_entries`. The caller guarantees both values are positive and
/// caps `worker_count` at `total_entries`.
fn cache_entries_per_worker(total_entries: usize, worker_count: usize) -> usize {
debug_assert!(total_entries > 0);
debug_assert!(worker_count > 0);
debug_assert!(worker_count <= total_entries);
let share = total_entries / worker_count;
1usize << (usize::BITS - 1 - share.leading_zeros())
}
#[cfg(test)]
mod tests {
use super::cache_entries_per_worker;
#[test]
fn cache_budget_is_shared_across_frontier_workers() {
let total = 16_777_216usize;
let workers = 192usize;
let per_worker = cache_entries_per_worker(total, workers);
assert_eq!(per_worker, 65_536);
assert!(per_worker.is_power_of_two());
assert!(per_worker * workers <= total);
}
#[test]
fn cache_partition_never_exceeds_the_total_budget() {
for total in 1..=257usize {
for workers in 1..=total.min(257) {
let per_worker = cache_entries_per_worker(total, workers);
assert!(per_worker.is_power_of_two());
assert!(per_worker * workers <= total);
}
}
}
}