use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex,
};
#[cfg(not(target_arch = "wasm32"))]
use super::checkpoint;
use super::symmetry::{apply_transform, is_orbit_min, stab_after, transform_line};
use super::SearchState;
#[cfg(not(target_arch = "wasm32"))]
use crate::game::io;
use crate::game::{
board::{Pos, OFFSET},
line::{Dir, Line},
line_index::lines_conflict,
moves::{legal_moves, legal_moves_into, Move},
rules::TouchMode,
state::GameState,
};
const NODE_FLUSH_INTERVAL: u64 = 4096;
const NO_SYMMETRY: u8 = 0b0000_0001;
const CHUNK_BUDGET: u64 = 500_000;
type WorkItem = Vec<Move>;
#[derive(Clone, Copy)]
struct Cand {
mv: Move,
canon: bool,
}
struct Frame {
children: Vec<Move>,
idx: usize,
stab: u8,
legal: Vec<Cand>,
}
fn forbid_of(variant: crate::game::rules::Variant) -> u8 {
let max_overlap = match variant.touch_mode {
TouchMode::Touching => 1,
TouchMode::Disjoint => 0,
};
variant.len() - 1 - max_overlap
}
fn canonical_children_into(out: &mut Vec<Move>, legal: &[Cand], stab: u8, k: i16, n: i16) {
out.clear();
let symmetric = stab != NO_SYMMETRY;
out.extend(
legal
.iter()
.filter(|c| c.canon && (!symmetric || is_orbit_min(&c.mv, stab, k, n)))
.map(|c| c.mv),
);
}
fn root_candidates(out: &mut Vec<Cand>, legal: &[Move], state: &GameState) {
out.clear();
out.extend(legal.iter().map(|&mv| Cand {
mv,
canon: canonical_ok(&mv, state),
}));
}
#[inline]
fn take_buf<T>(pool: &mut Vec<Vec<T>>) -> Vec<T> {
pool.pop().unwrap_or_default()
}
fn child_legal_into(
out: &mut Vec<Cand>,
parent_legal: &[Cand],
state: &GameState,
mv: Move,
forbid: u8,
nu: usize,
) {
out.clear();
for &c in parent_legal {
if c.mv.pos != mv.pos && !lines_conflict(&c.mv.line, &mv.line, forbid) {
let canon = c.canon && mv.pos <= c.mv.pos;
out.push(Cand { mv: c.mv, canon });
}
}
add_windows_through(out, state, mv.pos, forbid, nu);
}
fn add_windows_through(out: &mut Vec<Cand>, state: &GameState, p: Pos, forbid: u8, nu: usize) {
let span = nu as i16 - 1; let full: u16 = (1 << nu) - 1; let strip_mask: u16 = (1 << (2 * nu - 1)) - 1;
for dir in Dir::ALL {
let (dx, dy) = dir.delta();
let mut strip: u16 = 0;
if dy == 0 {
let gy = (p.1 + OFFSET) as usize;
let gx0 = (p.0 + OFFSET - span) as u32; strip = (state.board.row(gy) >> gx0) as u16 & strip_mask;
} else {
for b in 0..(2 * nu - 1) {
let t = b as i16 - span;
if state.board.contains((p.0 + t * dx, p.1 + t * dy)) {
strip |= 1 << b;
}
}
}
for j in 0..nu {
let lo = (nu - 1 - j) as u32;
let zeros = !strip & (full << lo); if zeros == 0 || zeros & (zeros - 1) != 0 {
continue; }
let empty_idx = (zeros.trailing_zeros() - lo) as u8; let origin = (p.0 - j as i16 * dx, p.1 - j as i16 * dy);
let line = Line::new(origin, dir);
if state.line_index.conflicts(&line, forbid) {
continue;
}
let new_pos = (
origin.0 + empty_idx as i16 * dx,
origin.1 + empty_idx as i16 * dy,
);
out.push(Cand {
mv: Move::new(new_pos, line, empty_idx),
canon: true,
});
}
}
}
pub fn run(initial_state: &GameState, search: Arc<SearchState>) {
search.reset();
let n = initial_state.variant.len() as i16;
let k = 2 * n - 1;
let init_stab = initial_stabilizer(initial_state, k, n);
let firsts: Vec<Move> = {
let moves = legal_moves(initial_state);
if init_stab == NO_SYMMETRY {
moves
} else {
moves
.into_iter()
.filter(|m| is_orbit_min(m, init_stab, k, n))
.collect()
}
};
let frontier: Vec<WorkItem> = firsts.into_iter().map(|m| vec![m]).collect();
drive_workers(initial_state, init_stab, frontier, &search, k, n);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn resume(search: Arc<SearchState>, checkpoint: io::Checkpoint) {
search.reset();
search
.best_score
.store(checkpoint.best.len() as u32, Ordering::Relaxed);
*search.best_sequence.write().unwrap() = checkpoint.best;
*search.records.write().unwrap() = checkpoint.records;
search
.nodes_explored
.store(checkpoint.nodes_explored, Ordering::Relaxed);
let initial_state = GameState::new(checkpoint.variant);
let n = initial_state.variant.len() as i16;
let k = 2 * n - 1;
let init_stab = initial_stabilizer(&initial_state, k, n);
drive_workers(
&initial_state,
init_stab,
checkpoint.frontier,
&search,
k,
n,
);
}
#[cfg(not(target_arch = "wasm32"))]
fn worker_cap() -> usize {
num_cpus::get_physical().max(1)
}
#[cfg(target_arch = "wasm32")]
fn worker_cap() -> usize {
usize::MAX
}
fn drive_workers(
initial_state: &GameState,
init_stab: u8,
seed: Vec<WorkItem>,
search: &Arc<SearchState>,
k: i16,
n: i16,
) {
let cap = worker_cap();
if cap < rayon::current_num_threads().max(1) {
if let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(cap).build() {
pool.install(|| run_workers(initial_state, init_stab, seed, search, k, n));
return;
}
}
run_workers(initial_state, init_stab, seed, search, k, n);
}
fn run_workers(
initial_state: &GameState,
init_stab: u8,
seed: Vec<WorkItem>,
search: &Arc<SearchState>,
k: i16,
n: i16,
) {
search.running.store(true, Ordering::Relaxed);
let frontier: Mutex<Vec<WorkItem>> = Mutex::new(seed);
let active = AtomicUsize::new(0);
let workers = rayon::current_num_threads().max(1);
let mut exhausted = false;
while search.running.load(Ordering::Relaxed) {
if frontier.lock().unwrap().is_empty() {
exhausted = true; break;
}
search.checkpoint_requested.store(false, Ordering::Relaxed);
rayon::scope(|s| {
for _ in 0..workers {
s.spawn(|_| worker(&frontier, &active, initial_state, init_stab, search, k, n));
}
});
if search.checkpoint_requested.load(Ordering::Relaxed) {
let snapshot = frontier.lock().unwrap().clone();
save_checkpoint(initial_state, search, &snapshot);
}
}
search.exhausted.store(exhausted, Ordering::Relaxed);
search.running.store(false, Ordering::Relaxed);
search.checkpoint_requested.store(false, Ordering::Relaxed);
}
#[cfg(not(target_arch = "wasm32"))]
fn save_checkpoint(initial: &GameState, search: &SearchState, frontier: &[WorkItem]) {
use std::time::Instant;
let t0 = Instant::now();
let best = search.best_sequence.read().unwrap().clone();
let records = search.records.read().unwrap().clone();
let nodes = search.nodes_explored.load(Ordering::Relaxed);
let serialized = match io::export_checkpoint(
initial.variant,
nodes,
&best,
&records,
frontier,
"systematic",
io::unix_now(),
) {
Ok(s) => s,
Err(e) => {
log::error!("checkpoint serialise failed: {e}");
return;
}
};
if let Err(e) = checkpoint::write("systematic", &serialized) {
log::error!("checkpoint write failed: {e}");
return;
}
log::info!(
"checkpoint saved: {} frontier items, {} bytes, {:.0} ms",
frontier.len(),
serialized.len(),
t0.elapsed().as_secs_f64() * 1e3,
);
}
#[cfg(target_arch = "wasm32")]
fn save_checkpoint(_initial: &GameState, _search: &SearchState, _frontier: &[WorkItem]) {}
fn worker(
frontier: &Mutex<Vec<WorkItem>>,
active: &AtomicUsize,
initial: &GameState,
init_stab: u8,
search: &Arc<SearchState>,
k: i16,
n: i16,
) {
let mut local = 0u64;
loop {
search.wait_if_paused(); if !search.running.load(Ordering::Relaxed)
|| search.checkpoint_requested.load(Ordering::Relaxed)
{
break;
}
let item = {
let mut f = frontier.lock().unwrap();
let it = f.pop();
if it.is_some() {
active.fetch_add(1, Ordering::Relaxed);
}
it
};
match item {
Some(path) => {
explore_item(path, initial, init_stab, search, frontier, &mut local, k, n);
active.fetch_sub(1, Ordering::Release);
}
None => {
if active.load(Ordering::Acquire) == 0 && frontier.lock().unwrap().is_empty() {
break;
}
std::thread::yield_now();
}
}
}
flush_nodes(search, local);
}
#[allow(clippy::too_many_arguments)]
fn explore_item(
item: WorkItem,
initial: &GameState,
init_stab: u8,
search: &Arc<SearchState>,
frontier: &Mutex<Vec<WorkItem>>,
local: &mut u64,
k: i16,
n: i16,
) {
let mut state = initial.clone();
let mut stab = init_stab;
for &mv in &item {
stab = stab_after(stab, &mv, k, n);
if !state.apply(mv) {
return;
}
}
let base_len = state.history.len();
let forbid = forbid_of(initial.variant);
let nu = n as usize;
let mut mpool: Vec<Vec<Move>> = Vec::new(); let mut cpool: Vec<Vec<Cand>> = Vec::new();
*local += 1; let mut root_legal = take_buf(&mut mpool);
legal_moves_into(&state, &mut root_legal); let mut root_cand = take_buf(&mut cpool);
root_candidates(&mut root_cand, &root_legal, &state); let mut root_children = take_buf(&mut mpool);
canonical_children_into(&mut root_children, &root_cand, stab, k, n);
if root_children.is_empty() {
if root_legal.is_empty() {
search.record_best(state.score() as u32, state.history.clone());
}
return;
}
mpool.push(root_legal);
let mut stack = vec![Frame {
children: root_children,
idx: 0,
stab,
legal: root_cand,
}];
let mut budget = CHUNK_BUDGET;
while budget > 0 && search.running.load(Ordering::Relaxed) {
let top = stack.last_mut().unwrap();
if top.idx >= top.children.len() {
let done = stack.pop().unwrap();
mpool.push(done.children); cpool.push(done.legal);
if stack.is_empty() {
return; }
state.undo();
continue;
}
let mv = top.children[top.idx];
top.idx += 1;
let child_stab = stab_after(top.stab, &mv, k, n);
if !state.apply(mv) {
continue;
}
budget -= 1;
*local += 1;
if *local >= NODE_FLUSH_INTERVAL {
flush_nodes(search, *local);
*local = 0;
}
let mut child_lgl = take_buf(&mut cpool);
child_legal_into(&mut child_lgl, &top.legal, &state, mv, forbid, nu);
#[cfg(debug_assertions)]
{
use std::collections::HashSet;
let got: HashSet<Move> = child_lgl.iter().map(|c| c.mv).collect();
let want: HashSet<Move> = legal_moves(&state).into_iter().collect();
debug_assert_eq!(
got, want,
"incremental legal set diverged from full generation"
);
debug_assert_eq!(
got.len(),
child_lgl.len(),
"duplicate in incremental legal set"
);
for c in &child_lgl {
debug_assert_eq!(
c.canon,
canonical_ok(&c.mv, &state),
"incremental canon flag diverged from canonical_ok for {:?}",
c.mv
);
}
}
let mut children = take_buf(&mut mpool);
canonical_children_into(&mut children, &child_lgl, child_stab, k, n);
if children.is_empty() {
if child_lgl.is_empty() {
search.record_best(state.score() as u32, state.history.clone());
}
state.undo();
cpool.push(child_lgl); mpool.push(children);
} else {
stack.push(Frame {
children,
idx: 0,
stab: child_stab,
legal: child_lgl,
});
}
}
let descent: Vec<Move> = state.history[base_len..].to_vec();
let mut f = frontier.lock().unwrap();
for (i, frame) in stack.iter().enumerate() {
for &c in &frame.children[frame.idx..] {
let mut wi = Vec::with_capacity(item.len() + i + 1);
wi.extend_from_slice(&item);
wi.extend_from_slice(&descent[..i]);
wi.push(c);
f.push(wi);
}
}
}
fn initial_stabilizer(state: &GameState, k: i16, n: i16) -> u8 {
let mut stab = 0u8;
for t in 0..8 {
let points_ok = state
.board
.cells
.iter()
.all(|&c| state.board.contains(apply_transform(t, c, k)));
let lines_ok = state.history.iter().all(|mv| {
state
.line_index
.contains(&transform_line(t, &mv.line, k, n))
});
if points_ok && lines_ok {
stab |= 1 << t;
}
}
stab
}
#[inline]
fn flush_nodes(search: &Arc<SearchState>, n: u64) {
if n > 0 {
search.nodes_explored.fetch_add(n, Ordering::Relaxed);
}
}
fn canonical_ok(mv: &Move, state: &GameState) -> bool {
let n = state.variant.len();
let mut deps = [(0i16, 0i16); 8];
let mut ndeps = 0usize;
for c in mv.line.positions(n) {
if c != mv.pos {
deps[ndeps] = c;
ndeps += 1;
}
}
for p in state.history.iter().rev() {
if deps[..ndeps].contains(&p.pos) {
return true; }
if p.pos > mv.pos {
return false; }
}
true
}
#[cfg(test)]
mod bench {
use super::*;
use crate::game::{moves::Move, rules::Variant, state::GameState};
use std::hint::black_box;
use std::time::Instant;
fn root_frame(state: &GameState, stab: u8, k: i16, n: i16) -> Frame {
let raw = legal_moves(state);
let mut legal = Vec::new();
root_candidates(&mut legal, &raw, state);
let mut children = Vec::new();
canonical_children_into(&mut children, &legal, stab, k, n);
Frame {
children,
idx: 0,
stab,
legal,
}
}
#[test]
fn child_legal_matches_full_regen_release() {
use std::collections::HashSet;
let mut state = GameState::new(Variant::T5);
let n = state.variant.len() as i16;
let k = 2 * n - 1;
let init_stab = initial_stabilizer(&state, k, n);
let forbid = forbid_of(state.variant);
let nu = n as usize;
let mut stack: Vec<Frame> = vec![root_frame(&state, init_stab, k, n)];
let mut nodes = 0u64;
const NODE_LIMIT: u64 = 50_000;
let mut incremental = Vec::new();
while nodes < NODE_LIMIT {
let top = stack.last_mut().unwrap();
if top.idx >= top.children.len() {
stack.pop();
if stack.is_empty() {
break;
}
state.undo();
continue;
}
let mv = top.children[top.idx];
top.idx += 1;
let cstab = stab_after(top.stab, &mv, k, n);
state.apply(mv);
nodes += 1;
child_legal_into(
&mut incremental,
&stack.last().unwrap().legal,
&state,
mv,
forbid,
nu,
);
let full: Vec<Move> = legal_moves(&state);
let inc_set: HashSet<Move> = incremental.iter().map(|c| c.mv).collect();
let full_set: HashSet<Move> = full.iter().copied().collect();
assert_eq!(
inc_set,
full_set,
"child_legal diverged from legal_moves at depth {} after {} nodes\n\
extra in incremental: {:?}\n\
missing from incremental: {:?}",
state.history.len(),
nodes,
inc_set.difference(&full_set).collect::<Vec<_>>(),
full_set.difference(&inc_set).collect::<Vec<_>>(),
);
assert_eq!(
incremental.len(),
inc_set.len(),
"duplicate in incremental legal set at depth {}",
state.history.len()
);
for c in &incremental {
assert_eq!(
c.canon,
canonical_ok(&c.mv, &state),
"incremental canon flag diverged at depth {} for {:?}",
state.history.len(),
c.mv,
);
}
let mut children = Vec::new();
canonical_children_into(&mut children, &incremental, cstab, k, n);
if children.is_empty() {
state.undo();
} else {
stack.push(Frame {
children,
idx: 0,
stab: cstab,
legal: std::mem::take(&mut incremental),
});
}
}
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_branching() {
let st = GameState::new(Variant::T5);
let n = st.variant.len() as i16;
let k = 2 * n - 1;
let nu = n as usize;
let forbid = forbid_of(st.variant);
let init_stab = initial_stabilizer(&st, k, n);
let cap: usize = std::env::var("CAP")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(12);
let mut count = vec![0u64; cap + 1];
count[0] = 1; let mut state = st.clone();
let mut stack = vec![root_frame(&state, init_stab, k, n)];
while let Some(top) = stack.last_mut() {
if top.idx >= top.children.len() {
stack.pop();
if !stack.is_empty() {
state.undo();
}
continue;
}
let mv = top.children[top.idx];
top.idx += 1;
let cstab = stab_after(top.stab, &mv, k, n);
state.apply(mv);
let d = state.history.len();
count[d] += 1;
if d < cap {
let mut child_lgl = Vec::new();
child_legal_into(&mut child_lgl, &top.legal, &state, mv, forbid, nu);
let mut children = Vec::new();
canonical_children_into(&mut children, &child_lgl, cstab, k, n);
if children.is_empty() {
state.undo();
} else {
stack.push(Frame {
children,
idx: 0,
stab: cstab,
legal: child_lgl,
});
}
} else {
state.undo(); }
}
println!("BRANCHING: canonical nodes per depth (exhaustive to {cap})");
let mut ratios = Vec::new();
for d in 0..=cap {
let r = if d > 0 && count[d - 1] > 0 {
count[d] as f64 / count[d - 1] as f64
} else {
0.0
};
if d > 0 {
ratios.push(r);
}
println!(" depth {d:>2}: {:>12} nodes ratio x{r:.2}", count[d]);
}
let tail = &ratios[ratios.len().saturating_sub(5)..];
let gmean = tail.iter().map(|r| r.ln()).sum::<f64>() / tail.len() as f64;
let b = gmean.exp();
println!("BRANCHING: steady growth b ≈ {b:.2}");
for d in [80usize, 120, 150, 178] {
println!(" b^{d} ≈ 10^{:.0}", d as f64 * b.log10());
}
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_best_curve() {
use std::time::Duration;
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2));
for i in 1..=15 {
std::thread::sleep(Duration::from_secs(2));
println!(
"{:>3}s: nodes={:>13} best_terminal={}",
i * 2,
search.nodes_explored.load(Ordering::Relaxed),
search.best_score.load(Ordering::Relaxed),
);
}
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
}
#[test]
fn trace_nf_keeps_exactly_one_order_per_trace() {
for seed_len in [3usize, 4, 5, 6] {
let mut start = GameState::new(Variant::T5);
let mut set = Vec::new();
for _ in 0..seed_len {
let ms = legal_moves(&start);
let Some(mv) = ms.into_iter().min_by_key(|m| {
(
m.pos.0,
m.pos.1,
m.line.origin.0,
m.line.origin.1,
m.line_pos,
)
}) else {
break;
};
set.push(mv);
start.apply(mv);
}
let mut valid = 0usize;
let mut canonical = 0usize;
permute(&mut set.clone(), 0, &mut |perm: &[Move]| {
let mut s = GameState::new(Variant::T5);
let mut is_valid = true;
let mut is_canon = true;
for &mv in perm {
if !legal_moves(&s).contains(&mv) {
is_valid = false;
break;
}
if !canonical_ok(&mv, &s) {
is_canon = false;
}
s.apply(mv);
}
if is_valid {
valid += 1;
if is_canon {
canonical += 1;
}
}
});
assert_eq!(
canonical, 1,
"len {seed_len}: {valid} valid orders, {canonical} canonical (want 1)"
);
}
}
fn permute(xs: &mut [Move], i: usize, f: &mut impl FnMut(&[Move])) {
if i + 1 >= xs.len() {
f(xs);
return;
}
for j in i..xs.len() {
xs.swap(i, j);
permute(xs, i + 1, f);
xs.swap(i, j);
}
}
fn mid_game(moves: usize) -> GameState {
let mut state = GameState::new(Variant::T5);
for _ in 0..moves {
let ms = legal_moves(&state);
let Some(mv) = ms.into_iter().min_by_key(|m| {
(
m.pos.0,
m.pos.1,
m.line.origin.0,
m.line.origin.1,
m.line_pos,
)
}) else {
break;
};
state.apply(mv);
}
state
}
#[test]
#[ignore = "timing benchmark, run with --ignored --nocapture"]
fn bench_node_breakdown() {
let parent = mid_game(25);
let n = parent.variant.len() as i16;
let k = 2 * n - 1;
let nu = n as usize;
let forbid = forbid_of(parent.variant);
let parent_legal = legal_moves(&parent);
assert!(!parent_legal.is_empty());
let mut parent_cand = Vec::new();
root_candidates(&mut parent_cand, &parent_legal, &parent);
let mv = parent_legal[0];
let mut state = parent.clone();
state.apply(mv);
let stab = NO_SYMMETRY; let mut child_lgl = Vec::new();
child_legal_into(&mut child_lgl, &parent_cand, &state, mv, forbid, nu);
let iters = 5_000_000u64;
let bench = |name: &str, mut f: Box<dyn FnMut() -> usize>| {
for _ in 0..10_000 {
black_box(f());
}
let t = Instant::now();
let mut acc = 0usize;
for _ in 0..iters {
acc += black_box(f());
}
println!(
" {name:<22} {:.1} ns (acc={acc})",
t.elapsed().as_secs_f64() * 1e9 / iters as f64
);
};
println!(
"BREAKDOWN at depth {} ({} cells, parent_legal={}, child_legal={}):",
state.history.len(),
state.board.len(),
parent_legal.len(),
child_lgl.len(),
);
{
let (pl, st) = (parent_cand.clone(), state.clone());
let mut out = Vec::new();
bench(
"child_legal_into",
Box::new(move || {
child_legal_into(&mut out, &pl, &st, mv, forbid, nu);
out.len()
}),
);
}
{
let st = state.clone();
let mut out = Vec::new();
bench(
"add_windows_through",
Box::new(move || {
out.clear();
add_windows_through(&mut out, &st, mv.pos, forbid, nu);
out.len()
}),
);
}
{
let cl = child_lgl.clone();
let mut out = Vec::new();
bench(
"canonical_children_into",
Box::new(move || {
canonical_children_into(&mut out, &cl, stab, k, n);
out.len()
}),
);
}
{
let (cl, st) = (child_lgl.clone(), state.clone());
bench(
"canonical_ok (per set)",
Box::new(move || cl.iter().filter(|c| canonical_ok(&c.mv, &st)).count()),
);
}
{
let mut s = parent.clone();
bench(
"apply+undo",
Box::new(move || {
s.apply(mv);
s.undo();
s.board.len()
}),
);
}
}
#[test]
#[ignore = "timing benchmark, run with --ignored --nocapture"]
fn bench_single_thread_node() {
const BUDGET: u64 = 60_000_000;
let st = GameState::new(Variant::T5);
let n = st.variant.len() as i16;
let k = 2 * n - 1;
let nu = n as usize;
let forbid = forbid_of(st.variant);
let init_stab = initial_stabilizer(&st, k, n);
let mut state = st.clone();
let mut mpool: Vec<Vec<Move>> = Vec::new();
let mut cpool: Vec<Vec<Cand>> = Vec::new();
let mut root_legal = take_buf(&mut mpool);
legal_moves_into(&state, &mut root_legal);
let mut root_cand = take_buf(&mut cpool);
root_candidates(&mut root_cand, &root_legal, &state);
let mut root = take_buf(&mut mpool);
canonical_children_into(&mut root, &root_cand, init_stab, k, n);
mpool.push(root_legal);
let mut stack = vec![Frame {
children: root,
idx: 0,
stab: init_stab,
legal: root_cand,
}];
let mut nodes = 0u64;
let t0 = Instant::now();
while nodes < BUDGET {
let top = stack.last_mut().unwrap();
if top.idx >= top.children.len() {
let done = stack.pop().unwrap();
mpool.push(done.children);
cpool.push(done.legal);
if stack.is_empty() {
break;
}
state.undo();
continue;
}
let mv = top.children[top.idx];
top.idx += 1;
let cstab = stab_after(top.stab, &mv, k, n);
state.apply(mv);
nodes += 1;
let mut child_lgl = take_buf(&mut cpool);
child_legal_into(&mut child_lgl, &top.legal, &state, mv, forbid, nu);
let mut children = take_buf(&mut mpool);
canonical_children_into(&mut children, &child_lgl, cstab, k, n);
if children.is_empty() {
state.undo();
cpool.push(child_lgl);
mpool.push(children);
} else {
stack.push(Frame {
children,
idx: 0,
stab: cstab,
legal: child_lgl,
});
}
}
let dt = t0.elapsed().as_secs_f64();
println!(
"BENCH single-thread: nodes={nodes} rate={:.0} nodes/s ({:.1} ns/node)",
nodes as f64 / dt,
dt * 1e9 / nodes as f64,
);
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn bench_scaling() {
use std::time::Duration;
let secs = 3.0;
let max = rayon::current_num_threads();
let mut threads: Vec<usize> = [1, 2, 4, 8, 16, 32, 48, 64]
.into_iter()
.filter(|&t| t <= max)
.collect();
if !threads.contains(&max) {
threads.push(max);
}
println!("SCALING ({secs}s per point, host max {max} threads):");
let mut base = 0.0;
for nt in threads {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(nt)
.build()
.unwrap();
let search = SearchState::new();
let st = GameState::new(Variant::T5);
let stopper = search.clone();
let h = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs_f64(secs));
stopper.running.store(false, Ordering::Relaxed);
});
let run_search = search.clone();
pool.install(move || run(&st, run_search));
h.join().unwrap();
let n = search.nodes_explored.load(Ordering::Relaxed);
let rate = n as f64 / secs;
if nt == 1 {
base = rate;
}
println!(
" {nt:>3} thr: {rate:>14.0} nodes/s speedup {:>5.1}x eff {:>4.0}%",
rate / base,
100.0 * rate / base / nt as f64,
);
}
}
#[test]
#[ignore = "timing benchmark, run with --ignored --nocapture"]
fn bench_nodes_per_sec() {
let state = GameState::new(Variant::T5);
let search = SearchState::new();
let s2 = search.clone();
let st = state.clone();
let handle = std::thread::spawn(move || run(&st, s2));
let secs = 3.0;
std::thread::sleep(std::time::Duration::from_secs_f64(secs));
search.running.store(false, Ordering::Relaxed);
handle.join().unwrap();
let n = search.nodes_explored.load(Ordering::Relaxed);
println!(
"BENCH end-to-end: nodes={n} rate={:.0} nodes/s",
n as f64 / secs
);
}
#[test]
fn recorded_best_is_terminal() {
use std::time::Duration;
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2));
std::thread::sleep(Duration::from_secs(12));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let best = search.best_sequence.read().unwrap().clone();
if best.is_empty() {
return; }
let mut state = GameState::new(Variant::T5);
for mv in &best {
state.apply(*mv);
}
assert!(
legal_moves(&state).is_empty(),
"recorded best must be terminal, but {} legal move(s) remain",
legal_moves(&state).len(),
);
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn rayon_spawn_reaches_terminal() {
use crate::game::board::GRID_OVERFLOW;
use std::time::{Duration, Instant};
GRID_OVERFLOW.store(false, Ordering::Relaxed);
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
rayon::spawn(move || run(&st, s2));
let start = Instant::now();
for _ in 0..16 {
std::thread::sleep(Duration::from_millis(500));
println!(
"{:>4.1}s : best={} nodes={} overflow={} threads={}",
start.elapsed().as_secs_f64(),
search.best_score.load(Ordering::Relaxed),
search.nodes_explored.load(Ordering::Relaxed),
GRID_OVERFLOW.load(Ordering::Relaxed),
rayon::current_num_threads(),
);
}
search.running.store(false, Ordering::Relaxed);
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn first_terminal_time() {
use std::time::{Duration, Instant};
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2));
let start = Instant::now();
for _ in 0..20 {
std::thread::sleep(Duration::from_secs(1));
let best = search.best_sequence.read().unwrap().clone();
let terminal = {
let mut s = GameState::new(Variant::T5);
for mv in &best {
s.apply(*mv);
}
legal_moves(&s).is_empty()
};
println!(
"{:>2.0}s : best={} terminal={terminal} nodes={}",
start.elapsed().as_secs_f64(),
best.len(),
search.nodes_explored.load(Ordering::Relaxed),
);
}
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_pruning() {
use std::time::Duration;
for (name, variant) in [("5T", Variant::T5), ("5D", Variant::D5)] {
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(variant);
let h = std::thread::spawn(move || run(&st, s2));
std::thread::sleep(Duration::from_secs(3));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
println!(
"{name}/3s : nodes={:>11} best={}",
search.nodes_explored.load(Ordering::Relaxed),
search.best_score.load(Ordering::Relaxed),
);
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn checkpoint_live() {
use std::time::{Duration, Instant};
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2));
std::thread::sleep(Duration::from_secs(4));
search.checkpoint_requested.store(true, Ordering::Relaxed);
std::thread::sleep(Duration::from_millis(800)); search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let path = checkpoint::path("systematic");
let content = std::fs::read_to_string(&path).expect("checkpoint file");
let t0 = Instant::now();
let cp = io::import_checkpoint(&content).expect("valid checkpoint");
let import_ms = t0.elapsed().as_secs_f64() * 1e3;
let t1 = Instant::now();
let _ = io::export_checkpoint(
cp.variant,
cp.nodes_explored,
&cp.best,
&cp.records,
&cp.frontier,
"systematic",
0,
)
.unwrap();
let export_ms = t1.elapsed().as_secs_f64() * 1e3;
println!(
"CHECKPOINT: {} bytes, {} frontier items, best {}, export {export_ms:.0} ms, import {import_ms:.0} ms",
content.len(),
cp.frontier.len(),
cp.best.len(),
);
assert!(!cp.frontier.is_empty());
assert_eq!(cp.variant, Variant::T5);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn resume_continues() {
use std::time::Duration;
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2));
std::thread::sleep(Duration::from_secs(3));
search.checkpoint_requested.store(true, Ordering::Relaxed);
std::thread::sleep(Duration::from_millis(800));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let best_before = search.best_score.load(Ordering::Relaxed);
let nodes_before = search.nodes_explored.load(Ordering::Relaxed);
let cp = checkpoint::load("systematic").expect("checkpoint on disk");
let frontier_len = cp.frontier.len();
let search2 = SearchState::new();
let r2 = search2.clone();
let h2 = std::thread::spawn(move || super::resume(r2, cp));
std::thread::sleep(Duration::from_secs(2));
search2.running.store(false, Ordering::Relaxed);
h2.join().unwrap();
let best_after = search2.best_score.load(Ordering::Relaxed);
let nodes_after = search2.nodes_explored.load(Ordering::Relaxed);
println!(
"RESUME: frontier {frontier_len} | best {best_before}->{best_after} | nodes {nodes_before}->{nodes_after}"
);
assert!(best_after >= best_before, "best must be restored, not lost");
assert!(
nodes_after > nodes_before,
"node count restored and search continued"
);
}
#[test]
#[ignore = "timing benchmark, run with --ignored --nocapture"]
fn bench_legal_moves() {
let state = mid_game(25);
assert!(!legal_moves(&state).is_empty(), "want a non-terminal node");
let iters = 300_000u64;
for _ in 0..1000 {
black_box(legal_moves(black_box(&state)));
}
let t0 = Instant::now();
let mut acc = 0usize;
for _ in 0..iters {
acc += black_box(legal_moves(black_box(&state))).len();
}
let dt = t0.elapsed().as_secs_f64();
println!(
"BENCH legal_moves: cells={} iters={iters} {:.0} calls/s ({:.1} ns/call) acc={acc}",
state.board.len(),
iters as f64 / dt,
dt * 1e9 / iters as f64,
);
let mut buf = Vec::new();
for _ in 0..1000 {
crate::game::moves::legal_moves_into(&state, &mut buf);
black_box(&buf);
}
let t1 = Instant::now();
let mut acc2 = 0usize;
for _ in 0..iters {
crate::game::moves::legal_moves_into(black_box(&state), &mut buf);
acc2 += black_box(buf.len());
}
let dt2 = t1.elapsed().as_secs_f64();
println!(
"BENCH legal_moves_into: {:.0} calls/s ({:.1} ns/call) acc={acc2}",
iters as f64 / dt2,
dt2 * 1e9 / iters as f64,
);
}
}