use rustc_hash::FxHashMap;
use std::sync::{atomic::Ordering, Arc};
use rand::RngExt;
use super::{
symmetry::{local_code, MoveCoder, SymmetryHashes},
SearchState,
};
use crate::game::{
board::Pos,
moves::{legal_moves_into, Move},
state::GameState,
};
fn nrpa_alpha() -> f64 {
use std::sync::OnceLock;
static A: OnceLock<f64> = OnceLock::new();
*A.get_or_init(|| {
std::env::var("NRPA_ALPHA")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&a: &f64| a > 0.0)
.unwrap_or(1.0)
})
}
fn gnrpa_beta() -> f64 {
use std::sync::OnceLock;
static B: OnceLock<f64> = OnceLock::new();
*B.get_or_init(|| {
std::env::var("GNRPA_BETA")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0)
})
}
fn nrpa_corpus() -> f64 {
use std::sync::OnceLock;
static C: OnceLock<f64> = OnceLock::new();
*C.get_or_init(|| {
std::env::var("NRPA_CORPUS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0)
})
}
#[inline]
fn prior_active() -> bool {
gnrpa_beta() != 0.0 || nrpa_corpus() != 0.0
}
fn corpus_prior() -> &'static Policy {
use std::sync::OnceLock;
static P: OnceLock<Policy> = OnceLock::new();
P.get_or_init(build_corpus_prior)
}
fn build_corpus_prior() -> Policy {
use crate::game::moves::legal_moves;
let mut chosen: Policy = Policy::default();
let mut avail: Policy = Policy::default();
for rec in morpion_solitaire_records::RECORDS.iter() {
let Ok(g) = crate::game::io::import_save(rec.2) else {
continue;
};
if g.variant != crate::game::rules::Variant::T5 {
continue; }
let mut st = GameState::new(g.variant);
for &mv in &g.history {
let lm = legal_moves(&st);
if !lm.contains(&mv) {
break; }
for m in &lm {
*avail.entry(local_code(&st.board, m.pos)).or_insert(0.0) += 1.0;
}
*chosen.entry(local_code(&st.board, mv.pos)).or_insert(0.0) += 1.0;
st.apply(mv);
}
}
let mut prior = Policy::default();
for (&f, &a) in &avail {
let c = chosen.get(&f).copied().unwrap_or(0.0);
prior.insert(f, ((c + 1.0) / (a + 1.0)).ln());
}
prior
}
#[inline]
fn beta(state: &GameState, pos: Pos) -> f64 {
let mut bias = 0.0;
let gb = gnrpa_beta();
if gb != 0.0 {
const NEIGHBOURS: [(i16, i16); 8] = [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, -1),
(-1, 1),
(1, 1),
(-1, -1),
];
let occ = NEIGHBOURS
.iter()
.filter(|&&(dx, dy)| state.board.contains((pos.0 + dx, pos.1 + dy)))
.count();
bias += gb * occ as f64;
}
let cb = nrpa_corpus();
if cb != 0.0 {
bias += cb
* corpus_prior()
.get(&local_code(&state.board, pos))
.copied()
.unwrap_or(0.0);
}
bias
}
fn nrpa_temp() -> f64 {
use std::sync::OnceLock;
static T: OnceLock<f64> = OnceLock::new();
*T.get_or_init(|| {
std::env::var("NRPA_TEMP")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&t| t > 0.0)
.unwrap_or(1.0)
})
}
fn nrpa_clamp() -> Option<f64> {
use std::sync::OnceLock;
const DEFAULT_CLAMP: f64 = 3.0;
static C: OnceLock<Option<f64>> = OnceLock::new();
*C.get_or_init(|| match std::env::var("NRPA_CLAMP") {
Ok(s) => s.parse::<f64>().ok().filter(|&c| c > 0.0), Err(_) => Some(DEFAULT_CLAMP), })
}
fn nrpa_local() -> bool {
use std::sync::OnceLock;
static L: OnceLock<bool> = OnceLock::new();
*L.get_or_init(|| {
std::env::var("NRPA_LOCAL")
.map(|v| v == "1")
.unwrap_or(false)
})
}
fn nrpa_portfolio() -> bool {
use std::sync::OnceLock;
static P: OnceLock<bool> = OnceLock::new();
*P.get_or_init(|| {
std::env::var("NRPA_PORTFOLIO")
.map(|v| v == "1")
.unwrap_or(false)
})
}
thread_local! {
static TEMP_INV: std::cell::Cell<f64> = const { std::cell::Cell::new(1.0) };
static CLAMP_OVERRIDE: std::cell::Cell<Option<f64>> = const { std::cell::Cell::new(None) };
}
fn nrpa_clamp_portfolio() -> bool {
use std::sync::OnceLock;
static P: OnceLock<bool> = OnceLock::new();
*P.get_or_init(|| {
std::env::var("NRPA_CLAMP_PORTFOLIO")
.map(|v| v == "1")
.unwrap_or(false)
})
}
fn nrpa_selfwarm() -> usize {
use std::sync::OnceLock;
static V: OnceLock<usize> = OnceLock::new();
*V.get_or_init(|| {
std::env::var("NRPA_SELFWARM")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
})
}
fn island_clamp(i: usize, runs: usize) -> Option<f64> {
if !nrpa_clamp_portfolio() || runs <= 1 {
return None; }
let frac = i as f64 / (runs - 1) as f64;
Some(2.0 + 3.0 * frac)
}
fn island_inv_temp(i: usize, runs: usize) -> f64 {
let tau = nrpa_temp();
if !nrpa_portfolio() || runs <= 1 {
return 1.0 / tau;
}
let frac = i as f64 / (runs - 1) as f64;
1.0 / (tau * (0.6 + frac))
}
#[inline]
fn move_code(coder: &MoveCoder, scratch: &GameState, mv: &Move, local: bool) -> u64 {
let c = coder.code(mv);
if local {
c ^ local_code(&scratch.board, mv.pos)
} else {
c
}
}
fn iterations_for_level(level: usize) -> usize {
use std::sync::OnceLock;
static OVERRIDE: OnceLock<Option<usize>> = OnceLock::new();
if let Some(n) = *OVERRIDE.get_or_init(|| {
std::env::var("NRPA_ITERS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| n > 0)
}) {
return n;
}
match level {
0..=3 => 500,
4 => 64, _ => 24, }
}
type Policy = FxHashMap<u64, f64>;
pub fn run(initial_state: &GameState, search: Arc<SearchState>, level: usize) {
run_seeded(initial_state, search, level, None);
}
fn run_seeded(
initial_state: &GameState,
search: Arc<SearchState>,
level: usize,
seed: Option<Policy>,
) {
search.reset();
spawn_islands(level, initial_state, &search, seed.as_ref());
}
pub const WARM_ITERS: usize = 10;
pub fn run_warm(
initial_state: &GameState,
search: Arc<SearchState>,
level: usize,
seed_seq: &[Move],
iters: usize,
) {
let policy = build_warm_policy(initial_state, seed_seq, iters);
search.reset();
spawn_islands(level, initial_state, &search, Some(&policy));
}
fn build_warm_policy(initial: &GameState, seq: &[Move], iters: usize) -> Policy {
let base_sym = build_base_sym(initial);
let mut scratch = initial.clone();
let mut policy = Policy::default();
for _ in 0..iters {
adapt(&mut policy, &mut scratch, &base_sym, seq);
}
policy
}
#[cfg(not(target_arch = "wasm32"))]
fn perturb_k_min() -> usize {
use std::sync::OnceLock;
static V: OnceLock<usize> = OnceLock::new();
*V.get_or_init(|| {
std::env::var("PERTURB_KMIN")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&k| k >= 1)
.unwrap_or(8)
})
}
#[cfg(not(target_arch = "wasm32"))]
fn perturb_k_max() -> usize {
use std::sync::OnceLock;
static V: OnceLock<usize> = OnceLock::new();
*V.get_or_init(|| {
std::env::var("PERTURB_KMAX")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&k| k >= 1)
.unwrap_or(70)
})
}
#[cfg(not(target_arch = "wasm32"))]
const PERTURB_SECS_PER_K: f64 = 0.5;
#[cfg(not(target_arch = "wasm32"))]
const PERTURB_MIN_SECS: f64 = 2.0;
#[cfg(not(target_arch = "wasm32"))]
const PERTURB_MAX_SECS: f64 = 30.0;
#[cfg(not(target_arch = "wasm32"))]
fn perturb_warm() -> usize {
use std::sync::OnceLock;
static V: OnceLock<usize> = OnceLock::new();
*V.get_or_init(|| {
std::env::var("PERTURB_WARM")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(WARM_ITERS)
})
}
#[cfg(not(target_arch = "wasm32"))]
fn perturb_window() -> usize {
use std::sync::OnceLock;
static V: OnceLock<usize> = OnceLock::new();
*V.get_or_init(|| {
std::env::var("PERTURB_WINDOW")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10)
})
}
#[cfg(not(target_arch = "wasm32"))]
const PERTURB_ARCHIVE_MAX: usize = 20_000;
#[cfg(not(target_arch = "wasm32"))]
fn game_key(game: &[Move]) -> u64 {
use std::hash::{Hash, Hasher};
let mut keys: Vec<(i16, i16, i16, i16, u8, u8)> = game
.iter()
.map(|m| {
(
m.pos.0,
m.pos.1,
m.line.origin.0,
m.line.origin.1,
m.line.dir as u8,
m.line_pos,
)
})
.collect();
keys.sort_unstable();
let mut h = rustc_hash::FxHasher::default();
keys.hash(&mut h);
h.finish()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn run_perturbation(
search: Arc<SearchState>,
level: usize,
seed: Vec<Move>,
variant: crate::game::rules::Variant,
) {
let archive = if seed.is_empty() {
Vec::new()
} else {
vec![seed]
};
perturbation_search(search, level, variant, archive);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn resume_perturbation(
search: Arc<SearchState>,
level: usize,
variant: crate::game::rules::Variant,
archive: Vec<Vec<Move>>,
) {
perturbation_search(search, level, variant, archive);
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(target_arch = "wasm32"))]
fn random_extension<R: rand::Rng>(
game: &[Move],
cross: &std::collections::HashSet<Pos>,
line_len: u8,
rng: &mut R,
) -> Vec<Move> {
let mut placed: std::collections::HashSet<Pos> = cross.clone();
let mut remaining = game.to_vec();
let mut order = Vec::with_capacity(game.len());
while !remaining.is_empty() {
let playable: Vec<usize> = remaining
.iter()
.enumerate()
.filter(|(_, m)| {
m.line
.positions(line_len)
.all(|c| c == m.pos || placed.contains(&c))
})
.map(|(i, _)| i)
.collect();
if playable.is_empty() {
order.extend_from_slice(&remaining); break;
}
let m = remaining.swap_remove(playable[rng.random_range(0..playable.len())]);
placed.insert(m.pos);
order.push(m);
}
order
}
#[cfg(not(target_arch = "wasm32"))]
fn perturbation_search(
search: Arc<SearchState>,
level: usize,
variant: crate::game::rules::Variant,
mut archive: Vec<Vec<Move>>,
) {
use std::collections::HashSet;
use std::time::{Duration, Instant};
search.reset();
search.running.store(true, Ordering::Relaxed);
let mut tabu: HashSet<u64> = HashSet::new();
let mut max_score = 0usize;
for g in &archive {
tabu.insert(game_key(g));
max_score = max_score.max(g.len());
}
if let Some(best) = archive.iter().max_by_key(|g| g.len()) {
search.record_best(best.len() as u32, best.clone());
}
let cross: HashSet<Pos> = GameState::new(variant)
.board
.cells
.iter()
.copied()
.collect();
let line_len = variant.len();
let mut rng = rand::rng();
let mut total_nodes = 0u64;
while search.running.load(Ordering::Relaxed) {
search.wait_if_paused(); if search.checkpoint_requested.swap(false, Ordering::Relaxed) {
save_perturbation_checkpoint(variant, &archive, &search);
}
let parent: Vec<Move> = if archive.is_empty() {
Vec::new()
} else {
let g = archive[rng.random_range(0..archive.len())].clone();
random_extension(&g, &cross, line_len, &mut rng)
};
let k_max = perturb_k_max()
.min(parent.len().saturating_sub(1))
.max(perturb_k_min());
let k = rng.random_range(perturb_k_min()..=k_max);
let round_secs = (k as f64 * PERTURB_SECS_PER_K).clamp(PERTURB_MIN_SECS, PERTURB_MAX_SECS);
let prefix_len = parent.len().saturating_sub(k);
let mut p = GameState::new(variant);
for &mv in &parent[..prefix_len] {
p.apply(mv);
}
let suffix: Vec<Move> = parent[prefix_len..].to_vec();
let inner = SearchState::new();
let inner2 = inner.clone();
let warm = perturb_warm();
let h = std::thread::spawn(move || run_warm(&p, inner2, level, &suffix, warm));
let t0 = Instant::now();
while t0.elapsed().as_secs_f64() < round_secs && search.running.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(100));
let live = total_nodes + inner.nodes_explored.load(Ordering::Relaxed);
search.nodes_explored.store(live, Ordering::Relaxed);
}
inner.running.store(false, Ordering::Relaxed);
let _ = h.join();
total_nodes += inner.nodes_explored.load(Ordering::Relaxed);
search.nodes_explored.store(total_nodes, Ordering::Relaxed);
let cand = inner.best_sequence.read().unwrap().clone();
if !cand.is_empty() && tabu.insert(game_key(&cand)) {
let score = cand.len();
if score > max_score {
max_score = score;
search.record_best(score as u32, cand.clone());
}
if score + perturb_window() >= max_score {
archive.push(cand);
let cutoff = max_score.saturating_sub(perturb_window());
archive.retain(|g| g.len() >= cutoff); if archive.len() > PERTURB_ARCHIVE_MAX {
if let Some(i) = (0..archive.len()).min_by_key(|&i| archive[i].len()) {
archive.swap_remove(i);
}
}
}
}
}
search.running.store(false, Ordering::Relaxed);
}
#[cfg(not(target_arch = "wasm32"))]
fn save_perturbation_checkpoint(
variant: crate::game::rules::Variant,
archive: &[Vec<Move>],
search: &SearchState,
) {
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 crate::game::io::export_checkpoint(
variant,
nodes,
&best,
&records,
archive,
"perturbation",
crate::game::io::unix_now(),
) {
Ok(s) => s,
Err(e) => {
log::error!("perturbation checkpoint serialise failed: {e}");
return;
}
};
if let Err(e) = crate::search::checkpoint::write("perturbation", &serialized) {
log::error!("perturbation checkpoint write failed: {e}");
}
}
fn spawn_islands(
level: usize,
initial_state: &GameState,
search: &Arc<SearchState>,
seed: Option<&Policy>,
) {
search.running.store(true, Ordering::Relaxed);
let n = iterations_for_level(level);
let base_sym = build_base_sym(initial_state);
let base_sym = &base_sym; let runs = rayon::current_num_threads().max(1);
rayon::scope(|s| {
for i in 0..runs {
s.spawn(move |_| island(i, runs, level, n, initial_state, base_sym, search, seed));
}
});
search.running.store(false, Ordering::Relaxed);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save_checkpoint(variant: crate::game::rules::Variant, search: &SearchState) {
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 crate::game::io::export_checkpoint(
variant,
nodes,
&best,
&records,
&[],
"nrpa",
crate::game::io::unix_now(),
) {
Ok(s) => s,
Err(e) => {
log::error!("nrpa checkpoint serialise failed: {e}");
return;
}
};
if let Err(e) = crate::search::checkpoint::write("nrpa", &serialized) {
log::error!("nrpa checkpoint write failed: {e}");
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn resume(search: Arc<SearchState>, checkpoint: crate::game::io::Checkpoint, level: usize) {
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);
spawn_islands(level, &initial_state, &search, None);
}
#[allow(clippy::too_many_arguments)]
fn island(
idx: usize,
runs: usize,
level: usize,
n: usize,
initial_state: &GameState,
base_sym: &SymmetryHashes,
search: &Arc<SearchState>,
seed: Option<&Policy>,
) {
TEMP_INV.with(|c| c.set(island_inv_temp(idx, runs)));
CLAMP_OVERRIDE.with(|c| c.set(island_clamp(idx, runs)));
let mut scratch = initial_state.clone();
let selfwarm = nrpa_selfwarm();
while search.running.load(Ordering::Relaxed) {
let mut policy = if seed.is_none() && selfwarm > 0 && idx % 2 == 1 {
let best = search.best_sequence.read().unwrap().clone();
if best.len() >= 60 {
build_warm_policy(initial_state, &best, selfwarm)
} else {
Policy::default()
}
} else {
seed.cloned().unwrap_or_default()
};
nrpa(level, n, &mut policy, &mut scratch, base_sym, search);
}
}
fn nrpa(
level: usize,
n: usize,
policy: &mut Policy,
scratch: &mut GameState,
base_sym: &SymmetryHashes,
search: &Arc<SearchState>,
) -> Vec<Move> {
if !search.running.load(Ordering::Relaxed) {
return Vec::new();
}
if level == 0 {
return playout(policy, scratch, base_sym, search);
}
let mut best: Vec<Move> = Vec::new();
for _ in 0..n {
search.wait_if_paused(); if !search.running.load(Ordering::Relaxed) {
break;
}
let seq = nrpa(level - 1, n, policy, scratch, base_sym, search);
if seq.len() > best.len() {
best = seq;
}
adapt(policy, scratch, base_sym, &best);
}
best
}
fn playout(
policy: &Policy,
scratch: &mut GameState,
base_sym: &SymmetryHashes,
search: &Arc<SearchState>,
) -> Vec<Move> {
let mut sym = base_sym.clone();
let start = scratch.history.len();
let mut rng = rand::rng();
let prior = prior_active();
let inv_temp = TEMP_INV.with(|c| c.get());
let local = nrpa_local();
let mut moves: Vec<Move> = Vec::new();
let mut weights: Vec<f64> = Vec::new();
loop {
search.nodes_explored.fetch_add(1, Ordering::Relaxed);
legal_moves_into(scratch, &mut moves);
if moves.is_empty() {
break;
}
let coder = sym.move_coder();
weights.clear();
weights.extend(moves.iter().map(|mv| {
match policy.get(&move_code(&coder, scratch, mv, local)) {
Some(&w) => ((w + beta(scratch, mv.pos)) * inv_temp).exp(),
None if !prior => 1.0,
None => (beta(scratch, mv.pos) * inv_temp).exp(),
}
}));
let total: f64 = weights.iter().sum();
let mut r = rng.random::<f64>() * total;
let mut chosen = moves.len() - 1;
for (i, &w) in weights.iter().enumerate() {
r -= w;
if r <= 0.0 {
chosen = i;
break;
}
}
if !scratch.apply(moves[chosen]) {
break;
}
sym.toggle(moves[chosen].pos);
}
let full = scratch.history.clone();
let score = full.len() as u32;
if score > search.best_score.load(Ordering::Relaxed) {
search.record_best(score, full.clone());
}
let suffix = full[start..].to_vec();
while scratch.history.len() > start {
scratch.undo();
}
suffix
}
fn adapt(
policy: &mut Policy,
scratch: &mut GameState,
base_sym: &SymmetryHashes,
best_seq: &[Move],
) {
let mut sym = base_sym.clone();
let start = scratch.history.len();
let prior = prior_active();
let inv_temp = TEMP_INV.with(|c| c.get());
let local = nrpa_local();
let clamp = match CLAMP_OVERRIDE.with(|c| c.get()) {
Some(c) => Some(c),
None => nrpa_clamp(),
};
let alpha = nrpa_alpha();
let mut moves: Vec<Move> = Vec::new();
let mut codes: Vec<u64> = Vec::new();
let mut exps: Vec<f64> = Vec::new();
for &mv in best_seq {
legal_moves_into(scratch, &mut moves);
if moves.is_empty() {
break;
}
let coder = sym.move_coder();
codes.clear();
codes.extend(moves.iter().map(|m| move_code(&coder, scratch, m, local)));
exps.clear();
exps.extend(
codes
.iter()
.zip(&moves)
.map(|(code, m)| match policy.get(code) {
Some(&w) => ((w + beta(scratch, m.pos)) * inv_temp).exp(),
None if !prior => 1.0,
None => (beta(scratch, m.pos) * inv_temp).exp(),
}),
);
let z: f64 = exps.iter().sum();
*policy
.entry(move_code(&coder, scratch, &mv, local))
.or_insert(0.0) += alpha;
for (&code, &e_m) in codes.iter().zip(&exps) {
let e = policy.entry(code).or_insert(0.0);
*e -= alpha * (e_m / z);
if let Some(c) = clamp {
*e = e.clamp(-c, c);
}
}
if !scratch.apply(mv) {
break;
}
sym.toggle(mv.pos);
}
while scratch.history.len() > start {
scratch.undo();
}
}
fn build_base_sym(state: &GameState) -> SymmetryHashes {
let k = 2 * state.variant.len() as i16 - 1;
let mut s = SymmetryHashes::new(k);
for &cell in &state.board.cells {
s.toggle(cell);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::moves::legal_moves;
use crate::game::{rules::Variant, state::GameState};
use std::time::Duration;
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_warm_start() {
use crate::game::io::import_save;
let envn = |k: &str, d: u64| {
std::env::var(k)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(d)
};
let level = envn("NRPA_LEVEL", 3) as usize;
let secs = envn("NRPA_SECS", 20);
let runs = envn("NRPA_RUNS", 4) as usize;
let warm_iters = envn("WARM_ITERS", 10) as usize;
let saved = morpion_solitaire_records::RECORDS
.iter()
.find(|(n, _, _)| *n == "Rosin 178")
.unwrap()
.2
.to_owned();
let rosin = import_save(&saved).expect("import rosin178");
assert_eq!(rosin.score(), 178);
let seq = rosin.history.clone();
let initial = GameState::new(Variant::T5);
let policy = build_warm_policy(&initial, &seq, warm_iters);
println!(
"warm policy: {} weights (WARM_ITERS={warm_iters})",
policy.len()
);
let run_batch = |seed: Option<Policy>| -> Vec<(u32, bool)> {
(0..runs)
.map(|_| {
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let seed2 = seed.clone();
let h = std::thread::spawn(move || run_seeded(&st, s2, level, seed2));
std::thread::sleep(Duration::from_secs(secs));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let best = search.best_score.load(Ordering::Relaxed);
let replay = *search.best_sequence.read().unwrap() == seq;
(best, replay)
})
.collect()
};
let warm = run_batch(Some(policy));
let cold = run_batch(None);
let report = |label: &str, r: &[(u32, bool)]| {
let bests: Vec<u32> = r.iter().map(|x| x.0).collect();
let mean = bests.iter().map(|&b| b as f64).sum::<f64>() / bests.len() as f64;
let replays = r.iter().filter(|x| x.1).count();
println!(
"{label}: bests={bests:?} mean={mean:.1} max={} replays(==rosin)={replays}/{}",
bests.iter().max().unwrap(),
r.len(),
);
};
report("WARM", &warm);
report("COLD", &cold);
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_perturbation() {
use crate::game::io::import_save;
let envn = |k: &str, d: u64| {
std::env::var(k)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(d)
};
let level = envn("NRPA_LEVEL", 3) as usize;
let secs = envn("NRPA_SECS", 8);
let rounds = envn("ROUNDS", 12) as usize;
let warm_iters = envn("WARM_ITERS", 10) as usize;
let seed_len = envn("SEED_LEN", 0) as usize;
let rosin178 = morpion_solitaire_records::RECORDS
.iter()
.find(|(n, _, _)| *n == "Rosin 178")
.unwrap()
.2;
let rosin = import_save(rosin178).unwrap();
let variant = rosin.variant;
let mut best: Vec<Move> = rosin.history.clone();
if seed_len > 0 && seed_len < best.len() {
best.truncate(seed_len);
}
println!(
"seed={} moves level={level} secs={secs} warm_iters={warm_iters}",
best.len()
);
for round in 0..rounds {
let k = 5 + (round % 8) * 5; let prefix_len = best.len().saturating_sub(k);
let mut p = GameState::new(variant);
for &mv in &best[..prefix_len] {
p.apply(mv);
}
let suffix: Vec<Move> = best[prefix_len..].to_vec();
let search = SearchState::new();
let s2 = search.clone();
let pc = p.clone();
let h = std::thread::spawn(move || run_warm(&pc, s2, level, &suffix, warm_iters));
std::thread::sleep(Duration::from_secs(secs));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let cand = search.best_sequence.read().unwrap().clone();
let valid_len = {
let mut st = GameState::new(variant);
let mut i = 0;
while i < cand.len() && legal_moves(&st).contains(&cand[i]) {
st.apply(cand[i]);
i += 1;
}
i
};
let improved = cand.len() > best.len();
if improved {
best = cand.clone();
}
println!(
"round {round:>2} k={k:>2} prefix={prefix_len:>3}: cand={:>3} valid={:>3} best={:>3} nodes={} {}",
cand.len(), valid_len, best.len(),
search.nodes_explored.load(Ordering::Relaxed),
if improved { "IMPROVED" } else { "" },
);
}
println!("FINAL best={}", best.len());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_perturbation_run() {
use crate::game::io::import_save;
let env = |k: &str, d: u64| {
std::env::var(k)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(d)
};
let level = env("NRPA_LEVEL", 2) as usize;
let secs = env("NRPA_SECS", 60);
let runs = env("NRPA_RUNS", 3) as usize;
let seed_len = env("SEED_LEN", 0) as usize; let seed_name = std::env::var("SEED_RECORD").unwrap_or_else(|_| "akiyama145".to_string());
let rec = import_save(
morpion_solitaire_records::RECORDS
.iter()
.find(|(n, id, _)| *n == seed_name.as_str() || *id == seed_name.as_str())
.unwrap_or_else(|| panic!("record {seed_name:?} not found"))
.2,
)
.unwrap();
let variant = rec.variant;
let mut seed = rec.history.clone();
if seed_len > 0 && seed_len < seed.len() {
seed.truncate(seed_len);
}
println!(
"seed={seed_name} len={} (used {})",
rec.history.len(),
seed.len()
);
let mut bests: Vec<u32> = Vec::with_capacity(runs);
for r in 0..runs {
let search = SearchState::new();
let s2 = search.clone();
let seed2 = seed.clone();
let h = std::thread::spawn(move || run_perturbation(s2, level, seed2, variant));
std::thread::sleep(Duration::from_secs(secs));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let best = search.best_score.load(Ordering::Relaxed);
bests.push(best);
println!(" run {r}: best={best}");
}
let n = bests.len() as f64;
let mean = bests.iter().map(|&b| b as f64).sum::<f64>() / n;
println!(
"PERTURB L{level} seed={seed_len} kmin={} kmax={} window={} {secs}s ×{runs} : mean={mean:.1} min={} max={}",
perturb_k_min(),
perturb_k_max(),
perturb_window(),
bests.iter().min().unwrap(),
bests.iter().max().unwrap(),
);
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_nrpa_averaged() {
let env = |k: &str, d: u64| {
std::env::var(k)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(d)
};
let level = env("NRPA_LEVEL", 3) as usize;
let secs = env("NRPA_SECS", 20);
let runs = env("NRPA_RUNS", 8) as usize;
let beta = gnrpa_beta();
let mut bests: Vec<u32> = Vec::with_capacity(runs);
for r in 0..runs {
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2, level));
std::thread::sleep(Duration::from_secs(secs));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let best = search.best_score.load(Ordering::Relaxed);
bests.push(best);
println!(" run {r}: best={best}");
}
let n = bests.len() as f64;
let mean = bests.iter().map(|&b| b as f64).sum::<f64>() / n;
let std = (bests
.iter()
.map(|&b| (b as f64 - mean).powi(2))
.sum::<f64>()
/ n)
.sqrt();
println!(
"NRPA L{level} β={beta} {secs}s ×{runs} : mean={mean:.1} std={std:.1} min={} max={}",
bests.iter().min().unwrap(),
bests.iter().max().unwrap(),
);
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_nrpa_long() {
use std::time::Instant;
let level: usize = std::env::var("NRPA_LEVEL")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let secs: u64 = std::env::var("NRPA_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(60);
let warm_iters: usize = std::env::var("WARM_ITERS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let seed = (warm_iters > 0).then(|| {
let saved = morpion_solitaire_records::RECORDS
.iter()
.find(|(n, _, _)| *n == "Rosin 178")
.unwrap()
.2
.to_owned();
let seq = crate::game::io::import_save(&saved)
.expect("import")
.history;
build_warm_policy(&GameState::new(Variant::T5), &seq, warm_iters)
});
println!("warm_iters={warm_iters}");
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run_seeded(&st, s2, level, seed));
let start = Instant::now();
for _ in 0..(secs / 5) {
std::thread::sleep(Duration::from_secs(5));
println!(
"NRPA L{level} @ {:>3.0}s : best={} nodes={}",
start.elapsed().as_secs_f64(),
search.best_score.load(Ordering::Relaxed),
search.nodes_explored.load(Ordering::Relaxed),
);
}
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn nrpa_checkpoint_resume() {
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2, 3));
std::thread::sleep(Duration::from_secs(6));
save_checkpoint(Variant::T5, &search);
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
let best_before = search.best_score.load(Ordering::Relaxed);
let cp = crate::search::checkpoint::load("nrpa").expect("checkpoint on disk");
assert_eq!(cp.algo, "nrpa");
assert!(cp.frontier.is_empty(), "NRPA checkpoint has no frontier");
assert_eq!(cp.best.len() as u32, best_before);
let search2 = SearchState::new();
let r2 = search2.clone();
let h2 = std::thread::spawn(move || resume(r2, cp, 3));
std::thread::sleep(Duration::from_secs(6));
search2.running.store(false, Ordering::Relaxed);
h2.join().unwrap();
let best_after = search2.best_score.load(Ordering::Relaxed);
println!("NRPA RESUME: best {best_before} -> {best_after}");
assert!(best_after >= best_before, "resumed best must not regress");
}
#[test]
#[ignore = "measurement, run with --ignored --nocapture"]
fn measure_nrpa() {
for level in [1usize, 2, 3] {
let search = SearchState::new();
let s2 = search.clone();
let st = GameState::new(Variant::T5);
let h = std::thread::spawn(move || run(&st, s2, level));
std::thread::sleep(Duration::from_secs(8));
search.running.store(false, Ordering::Relaxed);
h.join().unwrap();
println!(
"NRPA L{level}: best={} playouts-nodes={}",
search.best_score.load(Ordering::Relaxed),
search.nodes_explored.load(Ordering::Relaxed),
);
}
}
}