use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use crate::game::{moves::Move, rules::Variant, state::GameState};
use super::SearchState;
pub struct StartCtx {
pub initial: GameState,
pub variant: Variant,
pub level: usize,
pub width: usize,
pub warm_seq: Option<Vec<Move>>,
pub seed_history: Vec<Move>,
pub seed_len: usize,
}
pub trait Method: Sync {
fn id(&self) -> &'static str;
fn parent(&self) -> Option<&'static str> {
None
}
fn label_key(&self) -> &'static str;
fn spawn(&self, ctx: StartCtx, search: Arc<SearchState>);
fn method_desc(&self, ctx: &StartCtx) -> String;
fn checkpoint_kind(&self) -> Option<&'static str>;
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum OptionValue {
Toggle(bool),
Float(f64),
Int(i64),
}
impl OptionValue {
pub fn as_bool(self) -> Option<bool> {
match self {
OptionValue::Toggle(b) => Some(b),
_ => None,
}
}
pub fn as_f64(self) -> Option<f64> {
match self {
OptionValue::Float(f) => Some(f),
OptionValue::Int(i) => Some(i as f64),
OptionValue::Toggle(_) => None,
}
}
pub fn as_int(self) -> Option<i64> {
match self {
OptionValue::Int(i) => Some(i),
_ => None,
}
}
}
#[derive(Default)]
pub struct Registry {
methods: Vec<&'static dyn Method>,
bias: Option<&'static dyn BiasModifier>,
coding: Option<&'static dyn CodingModifier>,
adapt: Option<&'static dyn AdaptModifier>,
perturb: Option<&'static dyn PerturbModifier>,
options: Vec<OptionSpec>,
values: Mutex<HashMap<&'static str, OptionValue>>,
experimental_methods: HashSet<&'static str>,
experimental_options: HashSet<&'static str>,
building_experimental: bool,
}
impl Registry {
pub fn add_method(&mut self, m: &'static dyn Method) {
if self.building_experimental {
self.experimental_methods.insert(m.id());
}
self.methods.push(m);
}
#[allow(dead_code)] pub fn add_bias(&mut self, m: &'static dyn BiasModifier) {
self.bias = Some(m);
}
pub fn add_coding(&mut self, m: &'static dyn CodingModifier) {
self.coding = Some(m);
}
pub fn add_adapt(&mut self, m: &'static dyn AdaptModifier) {
self.adapt = Some(m);
}
pub fn add_perturb(&mut self, m: &'static dyn PerturbModifier) {
self.perturb = Some(m);
}
pub fn add_option(&mut self, spec: OptionSpec) {
self.values
.get_mut()
.unwrap()
.insert(spec.key, spec.kind.default_value());
if self.building_experimental {
self.experimental_options.insert(spec.key);
}
self.options.push(spec);
}
pub fn options(&self) -> &[OptionSpec] {
&self.options
}
pub fn methods(&self) -> &[&'static dyn Method] {
&self.methods
}
pub fn method(&self, id: &str) -> Option<&'static dyn Method> {
self.methods.iter().copied().find(|m| m.id() == id)
}
pub fn bias_modifier(&self) -> Option<&'static dyn BiasModifier> {
self.bias
}
pub fn is_method_experimental(&self, id: &str) -> bool {
self.experimental_methods.contains(id)
}
pub fn is_option_experimental(&self, key: &str) -> bool {
self.experimental_options.contains(key)
}
pub fn method_visible(&self, id: &str) -> bool {
!self.is_method_experimental(id) || experimental_enabled()
}
pub fn option_visible(&self, key: &str) -> bool {
!self.is_option_experimental(key) || experimental_enabled()
}
pub fn set_value(&self, key: &str, val: OptionValue) {
let ok = self
.options
.iter()
.any(|s| s.key == key && s.kind.accepts(val));
if ok {
if let Some(slot) = self.values.lock().unwrap().get_mut(key) {
*slot = val;
}
}
}
pub fn value(&self, key: &str) -> Option<OptionValue> {
self.values.lock().unwrap().get(key).copied()
}
pub fn reset_experimental_values(&self) {
let mut vals = self.values.lock().unwrap();
for spec in &self.options {
if self.experimental_options.contains(spec.key) {
vals.insert(spec.key, spec.kind.default_value());
}
}
}
pub fn value_bool(&self, key: &str, default: bool) -> bool {
self.value(key)
.and_then(OptionValue::as_bool)
.unwrap_or(default)
}
pub fn value_f64(&self, key: &str, default: f64) -> f64 {
self.value(key)
.and_then(OptionValue::as_f64)
.unwrap_or(default)
}
pub fn value_int(&self, key: &str, default: i64) -> i64 {
self.value(key)
.and_then(OptionValue::as_int)
.unwrap_or(default)
}
pub fn sym_on(&self) -> bool {
if self.coding.is_some() {
self.value_bool("symmetry", true)
} else {
true
}
}
pub fn clamp(&self) -> Option<f64> {
if self.adapt.is_some() {
let c = self.value_f64("clamp", 3.0);
(c > 0.0).then_some(c)
} else {
Some(3.0)
}
}
pub fn alpha(&self) -> f64 {
if self.adapt.is_some() {
let a = self.value_f64("alpha", 1.0);
if a > 0.0 {
a
} else {
1.0
}
} else {
1.0
}
}
pub fn crossover_rate(&self) -> f64 {
if self.perturb.is_some() {
let r = self.value_f64("crossover", 0.0);
if (0.0..=1.0).contains(&r) {
r
} else {
0.0
}
} else {
0.0
}
}
pub fn level(&self) -> usize {
self.value_int("level", 3).clamp(1, 6) as usize
}
pub fn width(&self) -> usize {
self.value_int("width", 64).max(1) as usize
}
}
#[allow(dead_code)] pub fn set_option(key: &str, val: OptionValue) {
registry().set_value(key, val);
}
static EXPERIMENTAL: AtomicBool = AtomicBool::new(false);
#[allow(dead_code)] pub fn set_experimental(on: bool) {
EXPERIMENTAL.store(on, Ordering::Relaxed);
}
pub fn experimental_enabled() -> bool {
EXPERIMENTAL.load(Ordering::Relaxed)
}
pub trait BiasModifier: Sync {
fn active(&self) -> bool {
true
}
fn biases(&self, state: &GameState, moves: &[Move], out: &mut Vec<f64>);
}
pub trait CodingModifier: Sync {}
pub trait AdaptModifier: Sync {}
pub trait PerturbModifier: Sync {}
#[derive(Clone, Copy, Debug)]
pub enum OptionKind {
Toggle {
default: bool,
},
Float {
default: f64,
min: f64,
max: f64,
step: f64,
},
Int {
default: i64,
min: i64,
max: i64,
},
}
impl OptionKind {
fn default_value(self) -> OptionValue {
match self {
OptionKind::Toggle { default } => OptionValue::Toggle(default),
OptionKind::Float { default, .. } => OptionValue::Float(default),
OptionKind::Int { default, .. } => OptionValue::Int(default),
}
}
fn accepts(self, val: OptionValue) -> bool {
matches!(
(self, val),
(OptionKind::Toggle { .. }, OptionValue::Toggle(_))
| (
OptionKind::Float { .. },
OptionValue::Float(_) | OptionValue::Int(_)
)
| (OptionKind::Int { .. }, OptionValue::Int(_))
)
}
}
#[derive(Clone, Copy, Debug)]
pub enum Scope {
NrpaFamily,
Methods(&'static [&'static str]),
}
impl Scope {
pub fn applies_to(self, method_id: &str) -> bool {
match self {
Scope::NrpaFamily => matches!(method_id, "nrpa" | "perturbation"),
Scope::Methods(ids) => ids.contains(&method_id),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct OptionSpec {
pub key: &'static str,
pub label_key: &'static str,
pub help_key: &'static str,
pub help: &'static str,
pub kind: OptionKind,
pub scope: Scope,
}
impl OptionSpec {
pub fn cli_flag(&self) -> String {
match self.kind {
OptionKind::Toggle { default: true } => format!("no-{}", self.key),
_ => self.key.to_owned(),
}
}
}
pub trait Plugin: Sync {
fn id(&self) -> &'static str;
fn deps(&self) -> &'static [&'static str] {
&[]
}
fn experimental(&self) -> bool {
false
}
fn register(&self, reg: &mut Registry);
}
fn all_plugins() -> Vec<&'static dyn Plugin> {
use crate::search::{beam, nrpa, systematic};
#[allow(unused_mut)]
let mut v: Vec<&'static dyn Plugin> = vec![
&nrpa::plugin::NRPA_PLUGIN,
&systematic::SYSTEMATIC_PLUGIN,
&beam::BEAM_PLUGIN,
&nrpa::plugin::SYMMETRY_PLUGIN,
&nrpa::plugin::ADAPT_PLUGIN,
];
#[cfg(not(target_arch = "wasm32"))]
{
v.push(&nrpa::perturbation::PERTURBATION_PLUGIN);
v.push(&nrpa::perturbation::CROSSOVER_PLUGIN);
v.push(&nrpa::macros::MACROS_PLUGIN);
}
#[cfg(all(feature = "neural", not(target_arch = "wasm32")))]
{
use crate::search::neural;
v.push(&neural::plugin::NEURAL_BIAS_PLUGIN);
v.push(&neural::plugin::FEATURE_SPACE_PLUGIN);
v.push(&neural::plugin::PUCT_PLUGIN);
}
v
}
pub fn registry() -> &'static Registry {
static REG: OnceLock<Registry> = OnceLock::new();
REG.get_or_init(|| {
use std::collections::HashSet;
let plugins = all_plugins();
let present: HashSet<&str> = plugins.iter().map(|p| p.id()).collect();
let mut reg = Registry::default();
let mut done: HashSet<&str> = HashSet::new();
let mut remaining: Vec<&'static dyn Plugin> = plugins
.into_iter()
.filter(|p| p.deps().iter().all(|d| present.contains(d)))
.collect();
while !remaining.is_empty() {
let mut progressed = false;
let mut still: Vec<&'static dyn Plugin> = Vec::new();
for p in remaining {
if p.deps().iter().all(|d| done.contains(d)) {
reg.building_experimental = p.experimental();
p.register(&mut reg);
reg.building_experimental = false;
done.insert(p.id());
progressed = true;
} else {
still.push(p);
}
}
remaining = still;
if !progressed {
break; }
}
reg
})
}
#[cfg(test)]
mod tests {
use super::*;
static REG_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn core_methods_registered() {
let reg = registry();
for id in ["nrpa", "perturbation", "systematic", "beam"] {
assert!(reg.method(id).is_some(), "method {id} missing");
}
assert!(
reg.method("neural-nrpa").is_none(),
"no experimental method on a core build"
);
}
#[test]
fn core_option_specs_present_with_defaults() {
let opts = registry().options();
let by = |k: &str| opts.iter().find(|o| o.key == k);
for k in ["level", "width", "clamp", "alpha", "symmetry", "crossover"] {
assert!(by(k).is_some(), "option spec {k} missing");
}
assert!(
matches!(by("clamp").unwrap().kind, OptionKind::Float { default, .. } if default == 3.0)
);
assert!(matches!(
by("symmetry").unwrap().kind,
OptionKind::Toggle { default: true }
));
assert!(
matches!(by("crossover").unwrap().kind, OptionKind::Float { default, .. } if default == 0.0)
);
}
#[test]
fn defaults_match_resolved_hooks() {
let _g = REG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let reg = registry();
assert_eq!(reg.clamp(), Some(3.0));
assert_eq!(reg.alpha(), 1.0);
assert!(reg.sym_on());
assert_eq!(reg.crossover_rate(), 0.0);
assert_eq!(reg.level(), 3);
assert_eq!(reg.width(), 64);
}
#[test]
fn core_surface_is_never_experimental() {
let reg = registry();
for id in ["nrpa", "perturbation", "systematic", "beam"] {
assert!(
!reg.is_method_experimental(id),
"{id} wrongly tagged experimental"
);
assert!(reg.method_visible(id), "{id} should always be visible");
}
for k in ["level", "width", "clamp", "alpha", "symmetry", "crossover"] {
assert!(
!reg.is_option_experimental(k),
"{k} wrongly tagged experimental"
);
}
}
#[cfg(all(feature = "neural", not(target_arch = "wasm32")))]
#[test]
fn experimental_surface_is_tagged_and_gated() {
let _g = REG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let reg = registry();
assert!(
reg.is_method_experimental("puct"),
"puct should be tagged experimental"
);
assert!(
reg.is_option_experimental("macros"),
"macros should be tagged experimental"
);
set_experimental(false);
assert!(!reg.method_visible("puct"));
assert!(!reg.option_visible("macros"));
set_experimental(true);
assert!(reg.method_visible("puct"));
assert!(reg.option_visible("macros"));
set_experimental(false); }
#[test]
fn scope_membership() {
assert!(Scope::NrpaFamily.applies_to("nrpa"));
assert!(Scope::NrpaFamily.applies_to("perturbation"));
assert!(!Scope::NrpaFamily.applies_to("beam"));
assert!(Scope::Methods(&["beam"]).applies_to("beam"));
assert!(!Scope::Methods(&["beam"]).applies_to("nrpa"));
}
#[test]
fn set_value_round_trips_through_hooks() {
let _g = REG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let reg = registry();
reg.set_value("clamp", OptionValue::Float(0.0));
assert_eq!(reg.clamp(), None, "clamp 0 disables clamping");
reg.set_value("symmetry", OptionValue::Toggle(false));
assert!(!reg.sym_on());
reg.set_value("crossover", OptionValue::Float(0.25));
assert_eq!(reg.crossover_rate(), 0.25);
reg.set_value("clamp", OptionValue::Float(3.0));
reg.set_value("symmetry", OptionValue::Toggle(true));
reg.set_value("crossover", OptionValue::Float(0.0));
}
}