use burn::tensor::backend::Backend;
use rand::Rng;
use crate::fitness::FitnessFn;
use crate::local_search::{BudgetedEval, LocalSearch, clamp_vec, sanitize_fitness};
use rlevo_core::bounds::Bounds;
#[derive(Debug, Clone)]
pub struct NelderMeadParams {
pub bounds: Bounds,
pub max_iters: usize,
pub alpha: f32,
pub gamma: f32,
pub rho: f32,
pub sigma: f32,
pub initial_step: f32,
pub tolerance: f32,
}
impl NelderMeadParams {
#[must_use]
pub fn default_for(bounds: Bounds) -> Self {
let (lo, hi): (f32, f32) = bounds.into();
Self {
bounds,
max_iters: 200,
alpha: 1.0,
gamma: 2.0,
rho: 0.5,
sigma: 0.5,
initial_step: 0.05 * (hi - lo),
tolerance: 1e-8,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NelderMead;
struct Vertex {
point: Vec<f32>,
fitness: f32,
}
impl NelderMead {
#[allow(clippy::too_many_lines)]
fn refine_impl(
params: &NelderMeadParams,
genome: Vec<f32>,
known: Option<f32>,
fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
) -> (Vec<f32>, f32) {
assert!(
params.max_iters >= 1,
"NelderMeadParams::max_iters must be >= 1 (the input genome is \
always evaluated once to seed the best-so-far tracker)"
);
let mut budget: BudgetedEval<'_> = BudgetedEval::new(fitness_fn, params.max_iters);
let dim: usize = genome.len();
let (lo, hi): (f32, f32) = params.bounds.into();
let in_bounds: bool = genome.iter().all(|&x| x >= lo && x <= hi);
let mut vertex0: Vec<f32> = genome;
clamp_vec(&mut vertex0, params.bounds);
let mut best: Vec<f32> = vertex0.clone();
let initial_fit: f32 = match known {
Some(f) if in_bounds => sanitize_fitness(f),
_ => {
let Some(f) = budget.eval(&vertex0) else {
unreachable!("budget of >= 1 cannot be exhausted before the first eval");
};
f
}
};
let mut best_fit: f32 = initial_fit;
if dim == 0 {
return (best, best_fit);
}
let mut simplex: Vec<Vertex> = Vec::with_capacity(dim + 1);
simplex.push(Vertex {
point: vertex0.clone(),
fitness: initial_fit,
});
for j in 0..dim {
let mut point: Vec<f32> = vertex0.clone();
let forward: f32 = point[j] + params.initial_step;
if forward > hi || forward < lo {
point[j] -= params.initial_step;
} else {
point[j] = forward;
}
clamp_vec(&mut point, params.bounds);
let Some(fitness) = budget.eval(&point) else {
update_best(&mut best, &mut best_fit, &simplex);
return (best, best_fit);
};
if fitness > best_fit {
best_fit = fitness;
best.clone_from(&point);
}
simplex.push(Vertex { point, fitness });
}
let n: usize = simplex.len(); loop {
simplex.sort_by(|a, b| b.fitness.total_cmp(&a.fitness));
let f_best: f32 = simplex[0].fitness;
let f_worst: f32 = simplex[n - 1].fitness;
let f_second_worst: f32 = simplex[n - 2].fitness;
if f_best - f_worst < params.tolerance {
break;
}
if budget.remaining() == 0 {
break;
}
let centroid: Vec<f32> = centroid_excluding_worst(&simplex, dim);
let worst_point: &[f32] = &simplex[n - 1].point;
let reflected: Vec<f32> = affine(
¢roid,
¢roid,
worst_point,
params.alpha,
params.bounds,
);
let Some(f_reflected) = eval_clamped(&mut budget, &reflected, &mut best, &mut best_fit)
else {
break;
};
if f_reflected > f_best {
let expanded: Vec<f32> = affine(
¢roid,
&reflected,
¢roid,
params.gamma,
params.bounds,
);
let Some(f_expanded) =
eval_clamped(&mut budget, &expanded, &mut best, &mut best_fit)
else {
break;
};
if f_expanded > f_reflected {
replace_worst(&mut simplex, expanded, f_expanded);
} else {
replace_worst(&mut simplex, reflected, f_reflected);
}
} else if f_reflected > f_second_worst {
replace_worst(&mut simplex, reflected, f_reflected);
} else {
let (target, target_fit): (&[f32], f32) = if f_reflected > f_worst {
(&reflected, f_reflected)
} else {
(worst_point, f_worst)
};
let contracted: Vec<f32> =
affine(¢roid, target, ¢roid, params.rho, params.bounds);
let Some(f_contracted) =
eval_clamped(&mut budget, &contracted, &mut best, &mut best_fit)
else {
break;
};
if f_contracted > target_fit {
replace_worst(&mut simplex, contracted, f_contracted);
} else {
let best_point: Vec<f32> = simplex[0].point.clone();
for v in simplex.iter_mut().skip(1) {
let mut shrunk: Vec<f32> = Vec::with_capacity(dim);
for (b, c) in best_point.iter().zip(v.point.iter()) {
shrunk.push(b + params.sigma * (c - b));
}
clamp_vec(&mut shrunk, params.bounds);
let Some(f_shrunk) =
eval_clamped(&mut budget, &shrunk, &mut best, &mut best_fit)
else {
return (best, best_fit);
};
v.point = shrunk;
v.fitness = f_shrunk;
}
}
}
}
(best, best_fit)
}
}
impl<B: Backend> LocalSearch<B> for NelderMead {
type Params = NelderMeadParams;
fn refine(
&self,
params: &NelderMeadParams,
genome: Vec<f32>,
fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
_rng: &mut dyn Rng,
) -> (Vec<f32>, f32) {
Self::refine_impl(params, genome, None, fitness_fn)
}
fn refine_with_known_fitness(
&self,
params: &NelderMeadParams,
genome: Vec<f32>,
known_fitness: f32,
fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
_rng: &mut dyn Rng,
) -> (Vec<f32>, f32) {
Self::refine_impl(params, genome, Some(known_fitness), fitness_fn)
}
}
fn eval_clamped(
budget: &mut BudgetedEval<'_>,
point: &Vec<f32>,
best: &mut Vec<f32>,
best_fit: &mut f32,
) -> Option<f32> {
let fitness: f32 = budget.eval(point)?;
if fitness > *best_fit {
*best_fit = fitness;
best.clone_from(point);
}
Some(fitness)
}
fn update_best(best: &mut Vec<f32>, best_fit: &mut f32, simplex: &[Vertex]) {
for v in simplex {
if v.fitness > *best_fit {
*best_fit = v.fitness;
best.clone_from(&v.point);
}
}
}
fn centroid_excluding_worst(simplex: &[Vertex], dim: usize) -> Vec<f32> {
let count: usize = simplex.len() - 1;
#[allow(clippy::cast_precision_loss)]
let inv: f32 = 1.0 / count as f32;
let mut centroid: Vec<f32> = vec![0.0; dim];
for v in &simplex[..count] {
for (c, &p) in centroid.iter_mut().zip(v.point.iter()) {
*c += p;
}
}
for c in &mut centroid {
*c *= inv;
}
centroid
}
fn affine(base: &[f32], a: &[f32], b: &[f32], coeff: f32, bounds: Bounds) -> Vec<f32> {
let mut out: Vec<f32> = Vec::with_capacity(base.len());
for k in 0..base.len() {
out.push(base[k] + coeff * (a[k] - b[k]));
}
clamp_vec(&mut out, bounds);
out
}
fn replace_worst(simplex: &mut [Vertex], point: Vec<f32>, fitness: f32) {
let last: usize = simplex.len() - 1;
simplex[last] = Vertex { point, fitness };
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
type TestBackend = Flex;
struct NegSphere;
impl FitnessFn<Vec<f32>> for NegSphere {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
-x.iter().map(|v| v * v).sum::<f32>()
}
}
struct NegRosenbrock;
impl FitnessFn<Vec<f32>> for NegRosenbrock {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
let a = 1.0 - x[0];
let b = x[1] - x[0] * x[0];
-(a * a + 100.0 * b * b)
}
}
struct Flat;
impl FitnessFn<Vec<f32>> for Flat {
fn evaluate_one(&mut self, _x: &Vec<f32>) -> f32 {
1.0
}
}
struct Counting<'a> {
inner: &'a mut dyn FitnessFn<Vec<f32>>,
calls: usize,
}
impl<'a> Counting<'a> {
fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
Self { inner, calls: 0 }
}
}
impl FitnessFn<Vec<f32>> for Counting<'_> {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
self.calls += 1;
self.inner.evaluate_one(x)
}
}
const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
fn random_start(rng: &mut StdRng, dim: usize, bounds: Bounds) -> Vec<f32> {
let (lo, hi): (f32, f32) = bounds.into();
(0..dim)
.map(|_| lo + (hi - lo) * rng.random::<f32>())
.collect()
}
#[test]
fn sphere_d2_converges_below_threshold() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(1);
let start = random_start(&mut rng, 2, BOUNDS);
let (_g, fit) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
assert!(fit > -1e-6, "sphere D=2 should converge: best={fit}");
}
#[test]
fn sphere_d10_strictly_improves() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(2);
let start = random_start(&mut rng, 10, BOUNDS);
let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
let (_g, fit) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
assert!(fit > start_fit, "expected improvement: {fit} > {start_fit}");
}
#[test]
fn output_len_equals_input_len() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(3);
for dim in [1_usize, 2, 5, 10] {
let start = random_start(&mut rng, dim, BOUNDS);
let (g, _f) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start,
&mut fitness,
&mut rng,
);
assert_eq!(g.len(), dim);
}
}
#[test]
fn returned_fitness_matches_fresh_eval() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(4);
let start = random_start(&mut rng, 4, BOUNDS);
let (g, fit) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
let fresh = fitness.evaluate_one(&g);
approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
}
#[test]
fn rosenbrock_monotone_non_worsening() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut rng = StdRng::seed_from_u64(5);
for _ in 0..6 {
let start = random_start(&mut rng, 2, BOUNDS);
let mut fitness = NegRosenbrock;
let start_fit = fitness.evaluate_one(&start);
let (_g, fit) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start,
&mut fitness,
&mut rng,
);
assert!(fit >= start_fit, "monotone: {fit} >= {start_fit}");
}
}
#[test]
fn eval_count_never_exceeds_budget() {
let searcher = NelderMead;
let mut params = NelderMeadParams::default_for(BOUNDS);
params.max_iters = 37;
let mut base = Flat;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(6);
let start = vec![1.0_f32, 2.0, 3.0, 4.0];
let (g, _f) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start.clone(),
&mut counting,
&mut rng,
);
assert!(
counting.calls <= params.max_iters,
"evals {} must not exceed budget {}",
counting.calls,
params.max_iters
);
assert_eq!(g.len(), start.len());
}
#[test]
fn degenerate_budget_no_worse_than_input() {
let searcher = NelderMead;
let mut params = NelderMeadParams::default_for(BOUNDS);
params.max_iters = 2;
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(7);
let start = random_start(&mut rng, 5, BOUNDS);
let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
let (g, fit) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
assert_eq!(g.len(), 5, "dimensionality preserved");
assert!(
fit >= start_fit,
"no worse than input: {fit} >= {start_fit}"
);
}
#[test]
fn tolerance_early_stops_before_budget() {
let searcher = NelderMead;
let mut params = NelderMeadParams::default_for(BOUNDS);
params.max_iters = 1000;
let mut base = NegSphere;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(8);
let start = vec![1.0_f32, -0.5];
let (_g, _f) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut counting, &mut rng);
assert!(
counting.calls < params.max_iters,
"tolerance should early-stop: evals {} < budget {}",
counting.calls,
params.max_iters
);
}
#[test]
fn boundary_start_stays_within_bounds() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(9);
let start = vec![BOUNDS.hi(); 4];
let (g, _f) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
for &x in &g {
assert!(
x >= BOUNDS.lo() && x <= BOUNDS.hi(),
"coord {x} out of bounds {BOUNDS:?}"
);
}
}
#[test]
#[allow(clippy::float_cmp)]
fn same_seed_is_bit_identical() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let start = vec![2.0_f32, -3.0, 1.5];
let mut fitness_a = NegSphere;
let mut rng_a = StdRng::seed_from_u64(123);
let (g_a, f_a) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start.clone(),
&mut fitness_a,
&mut rng_a,
);
let mut fitness_b = NegSphere;
let mut rng_b = StdRng::seed_from_u64(123);
let (g_b, f_b) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start,
&mut fitness_b,
&mut rng_b,
);
assert_eq!(g_a, g_b);
assert_eq!(f_a, f_b);
}
#[test]
fn known_fitness_skips_seeding_eval_when_in_bounds() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let start = vec![1.0_f32, 2.0, 3.0];
let refine_evals = {
let mut base = Flat;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(41);
let _ = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start.clone(),
&mut counting,
&mut rng,
);
counting.calls
};
let hint_evals = {
let mut base = Flat;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(41);
let _ = LocalSearch::<TestBackend>::refine_with_known_fitness(
&searcher,
¶ms,
start.clone(),
1.0, &mut counting,
&mut rng,
);
counting.calls
};
assert_eq!(
hint_evals + 1,
refine_evals,
"in-bounds hint must skip exactly the vertex-0 eval ({hint_evals} vs {refine_evals})"
);
}
#[test]
fn out_of_bounds_hint_is_ignored() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let start = vec![100.0_f32, 100.0];
let refine_evals = {
let mut base = NegSphere;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(42);
let _ = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start.clone(),
&mut counting,
&mut rng,
);
counting.calls
};
let (g, fit, hint_evals) = {
let mut base = NegSphere;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(42);
let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
&searcher,
¶ms,
start.clone(),
999.0, &mut counting,
&mut rng,
);
(g, fit, counting.calls)
};
assert_eq!(
hint_evals, refine_evals,
"out-of-bounds hint must fall back to evaluating vertex 0"
);
let mut fresh_fn = NegSphere;
let fresh = fresh_fn.evaluate_one(&g);
approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
assert!(
fit <= 0.0,
"neg-sphere fitness is non-positive; bogus hint leaked: {fit}"
);
}
#[test]
fn nan_hint_does_not_propagate() {
let searcher = NelderMead;
let params = NelderMeadParams::default_for(BOUNDS);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(43);
let start = vec![2.0_f32, -1.0]; let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
&searcher,
¶ms,
start,
f32::NAN,
&mut fitness,
&mut rng,
);
assert!(fit.is_finite(), "NaN hint must be sanitized, got {fit}");
let fresh = fitness.evaluate_one(&g);
approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
}
}