//! `NelderMead` — Nelder & Mead 1965 simplex direct-search optimizer. use crate::core::candidate::Candidate; use crate::core::evaluation::Evaluation; use crate::core::objective::Direction; use crate::core::population::Population; use crate::core::problem::Problem; use crate::core::result::OptimizationResult; use crate::operators::real::RealBounds; use crate::traits::Optimizer; /// Configuration for [`NelderMead`]. #[derive(Debug, Clone)] pub struct NelderMeadConfig { /// Number of iterations. pub iterations: usize, /// Reflection coefficient `α` (canonical 1.0). pub reflection: f64, /// Expansion coefficient `γ` (canonical 2.0). pub expansion: f64, /// Contraction coefficient `ρ` (canonical 0.5). pub contraction: f64, /// Shrinkage coefficient `σ` (canonical 0.5). pub shrinkage: f64, /// Initial simplex edge length (added to each axis from the start point). pub initial_step: f64, } impl Default for NelderMeadConfig { fn default() -> Self { Self { iterations: 1_000, reflection: 1.0, expansion: 2.0, contraction: 0.5, shrinkage: 0.5, initial_step: 0.5, } } } /// Classical Nelder-Mead simplex method. /// /// Maintains a simplex of `n+1` vertices in `n`-D, replacing the worst /// vertex each iteration via reflection / expansion / contraction / /// shrinkage relative to the centroid of the rest. /// /// `Vec` decisions only. Single-objective only. Initial simplex is /// built around the midpoint of the configured bounds; every new vertex /// is clamped to those bounds. /// /// # Example /// /// ``` /// use heuropt::prelude::*; /// /// struct Sphere; /// impl Problem for Sphere { /// type Decision = Vec; /// fn objectives(&self) -> ObjectiveSpace { /// ObjectiveSpace::new(vec![Objective::minimize("f")]) /// } /// fn evaluate(&self, x: &Vec) -> Evaluation { /// Evaluation::new(vec![x.iter().map(|v| v * v).sum::()]) /// } /// } /// /// let mut opt = NelderMead::new( /// NelderMeadConfig { /// iterations: 200, /// reflection: 1.0, /// expansion: 2.0, /// contraction: 0.5, /// shrinkage: 0.5, /// initial_step: 1.0, /// }, /// RealBounds::new(vec![(-5.0, 5.0); 3]), /// ); /// let r = opt.run(&Sphere); /// // Nelder-Mead reaches machine precision on Sphere. /// assert!(r.best.unwrap().evaluation.objectives[0] < 1e-10); /// ``` #[derive(Debug, Clone)] pub struct NelderMead { /// Algorithm configuration. pub config: NelderMeadConfig, /// Per-variable bounds — used to seed the simplex midpoint and to clamp /// every reflected/expanded vertex. pub bounds: RealBounds, } impl NelderMead { /// Construct a `NelderMead`. pub fn new(config: NelderMeadConfig, bounds: RealBounds) -> Self { Self { config, bounds } } } impl

Optimizer

for NelderMead where P: Problem> + Sync, { fn run(&mut self, problem: &P) -> OptimizationResult { assert!( self.config.reflection > 0.0, "NelderMead reflection must be > 0" ); assert!( self.config.expansion > 1.0, "NelderMead expansion must be > 1", ); assert!( self.config.contraction > 0.0 && self.config.contraction < 1.0, "NelderMead contraction must be in (0, 1)", ); assert!( self.config.shrinkage > 0.0 && self.config.shrinkage < 1.0, "NelderMead shrinkage must be in (0, 1)", ); assert!( self.config.initial_step > 0.0, "NelderMead initial_step must be > 0", ); let objectives = problem.objectives(); assert!( objectives.is_single_objective(), "NelderMead requires exactly one objective", ); let direction = objectives.objectives[0].direction; let n = self.bounds.bounds.len(); // Seed the simplex: start at the bounds midpoint, then build n // additional vertices by stepping `initial_step` along each axis. let mut vertices: Vec> = Vec::with_capacity(n + 1); let start: Vec = self .bounds .bounds .iter() .map(|&(lo, hi)| 0.5 * (lo + hi)) .collect(); vertices.push(start.clone()); for j in 0..n { let mut v = start.clone(); let (lo, hi) = self.bounds.bounds[j]; let step = self.config.initial_step.min(0.5 * (hi - lo)); v[j] = (v[j] + step).clamp(lo, hi); vertices.push(v); } let mut evals: Vec = vertices.iter().map(|v| problem.evaluate(v)).collect(); let mut evaluations = evals.len(); for _ in 0..self.config.iterations { // Sort vertices best → worst. let mut order: Vec = (0..vertices.len()).collect(); order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction)); let best_idx = order[0]; let worst_idx = order[order.len() - 1]; let second_worst_idx = order[order.len() - 2]; // Centroid of all vertices except the worst. let mut centroid = vec![0.0_f64; n]; for &idx in &order[..order.len() - 1] { for j in 0..n { centroid[j] += vertices[idx][j]; } } for c in centroid.iter_mut() { *c /= (order.len() - 1) as f64; } // Reflection. let reflected = self.reflect(¢roid, &vertices[worst_idx], self.config.reflection); let r_eval = problem.evaluate(&reflected); evaluations += 1; if better(&r_eval, &evals[best_idx], direction) { // Reflection beat the best — try expansion. let expanded = self.reflect(¢roid, &vertices[worst_idx], self.config.expansion); let e_eval = problem.evaluate(&expanded); evaluations += 1; if better(&e_eval, &r_eval, direction) { vertices[worst_idx] = expanded; evals[worst_idx] = e_eval; } else { vertices[worst_idx] = reflected; evals[worst_idx] = r_eval; } } else if better(&r_eval, &evals[second_worst_idx], direction) { // Reflection at least beat the second-worst — accept. vertices[worst_idx] = reflected; evals[worst_idx] = r_eval; } else { // Reflection didn't help — try contraction. let contraction_target = if better(&r_eval, &evals[worst_idx], direction) { // Outside contraction (between centroid and reflected). self.contract(¢roid, &reflected, self.config.contraction) } else { // Inside contraction (between centroid and worst). self.contract(¢roid, &vertices[worst_idx], self.config.contraction) }; let c_eval = problem.evaluate(&contraction_target); evaluations += 1; if better(&c_eval, &evals[worst_idx], direction) { vertices[worst_idx] = contraction_target; evals[worst_idx] = c_eval; } else { // Shrink: move every non-best vertex toward the best. let best_pt = vertices[best_idx].clone(); for &idx in &order { if idx == best_idx { continue; } #[allow(clippy::needless_range_loop)] // body indexes both vertices and best_pt. for j in 0..n { vertices[idx][j] = best_pt[j] + self.config.shrinkage * (vertices[idx][j] - best_pt[j]); } // Clamp to bounds. for (j, x) in vertices[idx].iter_mut().enumerate() { let (lo, hi) = self.bounds.bounds[j]; *x = x.clamp(lo, hi); } evals[idx] = problem.evaluate(&vertices[idx]); evaluations += 1; } } } } // Find the best vertex. let mut best_idx = 0; for i in 1..vertices.len() { if better(&evals[i], &evals[best_idx], direction) { best_idx = i; } } let best = Candidate::new(vertices[best_idx].clone(), evals[best_idx].clone()); let population = Population::new(vec![best.clone()]); let front = vec![best.clone()]; OptimizationResult::new( population, front, Some(best), evaluations, self.config.iterations, ) } } impl NelderMead { fn reflect(&self, centroid: &[f64], worst: &[f64], coefficient: f64) -> Vec { let n = centroid.len(); let mut out = Vec::with_capacity(n); for j in 0..n { let v = centroid[j] + coefficient * (centroid[j] - worst[j]); let (lo, hi) = self.bounds.bounds[j]; out.push(v.clamp(lo, hi)); } out } fn contract(&self, centroid: &[f64], target: &[f64], coefficient: f64) -> Vec { let n = centroid.len(); let mut out = Vec::with_capacity(n); for j in 0..n { let v = centroid[j] + coefficient * (target[j] - centroid[j]); let (lo, hi) = self.bounds.bounds[j]; out.push(v.clamp(lo, hi)); } out } } fn compare(a: &Evaluation, b: &Evaluation, direction: Direction) -> std::cmp::Ordering { match (a.is_feasible(), b.is_feasible()) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, (false, false) => a .constraint_violation .partial_cmp(&b.constraint_violation) .unwrap_or(std::cmp::Ordering::Equal), (true, true) => match direction { Direction::Minimize => a.objectives[0] .partial_cmp(&b.objectives[0]) .unwrap_or(std::cmp::Ordering::Equal), Direction::Maximize => b.objectives[0] .partial_cmp(&a.objectives[0]) .unwrap_or(std::cmp::Ordering::Equal), }, } } fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool { compare(a, b, direction) == std::cmp::Ordering::Less } #[cfg(feature = "async")] impl NelderMead { /// Async version of [`Optimizer::run`] — drives evaluations through /// the user-chosen async runtime. Available only with the `async` /// feature. /// /// `concurrency` is largely inert here because Nelder-Mead /// evaluates one or two new vertices per iteration sequentially /// (the next decision depends on the previous evaluation); it's /// accepted for API parity with other algorithms. pub async fn run_async

( &mut self, problem: &P, concurrency: usize, ) -> OptimizationResult> where P: crate::core::async_problem::AsyncProblem>, { let _ = concurrency; assert!( self.config.reflection > 0.0, "NelderMead reflection must be > 0" ); assert!( self.config.expansion > 1.0, "NelderMead expansion must be > 1", ); assert!( self.config.contraction > 0.0 && self.config.contraction < 1.0, "NelderMead contraction must be in (0, 1)", ); assert!( self.config.shrinkage > 0.0 && self.config.shrinkage < 1.0, "NelderMead shrinkage must be in (0, 1)", ); assert!( self.config.initial_step > 0.0, "NelderMead initial_step must be > 0", ); let objectives = problem.objectives(); assert!( objectives.is_single_objective(), "NelderMead requires exactly one objective", ); let direction = objectives.objectives[0].direction; let n = self.bounds.bounds.len(); let mut vertices: Vec> = Vec::with_capacity(n + 1); let start: Vec = self .bounds .bounds .iter() .map(|&(lo, hi)| 0.5 * (lo + hi)) .collect(); vertices.push(start.clone()); for j in 0..n { let mut v = start.clone(); let (lo, hi) = self.bounds.bounds[j]; let step = self.config.initial_step.min(0.5 * (hi - lo)); v[j] = (v[j] + step).clamp(lo, hi); vertices.push(v); } let mut evals: Vec = Vec::with_capacity(vertices.len()); for v in &vertices { evals.push(problem.evaluate_async(v).await); } let mut evaluations = evals.len(); for _ in 0..self.config.iterations { let mut order: Vec = (0..vertices.len()).collect(); order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction)); let best_idx = order[0]; let worst_idx = order[order.len() - 1]; let second_worst_idx = order[order.len() - 2]; let mut centroid = vec![0.0_f64; n]; for &idx in &order[..order.len() - 1] { for j in 0..n { centroid[j] += vertices[idx][j]; } } for c in centroid.iter_mut() { *c /= (order.len() - 1) as f64; } let reflected = self.reflect(¢roid, &vertices[worst_idx], self.config.reflection); let r_eval = problem.evaluate_async(&reflected).await; evaluations += 1; if better(&r_eval, &evals[best_idx], direction) { let expanded = self.reflect(¢roid, &vertices[worst_idx], self.config.expansion); let e_eval = problem.evaluate_async(&expanded).await; evaluations += 1; if better(&e_eval, &r_eval, direction) { vertices[worst_idx] = expanded; evals[worst_idx] = e_eval; } else { vertices[worst_idx] = reflected; evals[worst_idx] = r_eval; } } else if better(&r_eval, &evals[second_worst_idx], direction) { vertices[worst_idx] = reflected; evals[worst_idx] = r_eval; } else { let contraction_target = if better(&r_eval, &evals[worst_idx], direction) { self.contract(¢roid, &reflected, self.config.contraction) } else { self.contract(¢roid, &vertices[worst_idx], self.config.contraction) }; let c_eval = problem.evaluate_async(&contraction_target).await; evaluations += 1; if better(&c_eval, &evals[worst_idx], direction) { vertices[worst_idx] = contraction_target; evals[worst_idx] = c_eval; } else { let best_pt = vertices[best_idx].clone(); for &idx in &order { if idx == best_idx { continue; } #[allow(clippy::needless_range_loop)] for j in 0..n { vertices[idx][j] = best_pt[j] + self.config.shrinkage * (vertices[idx][j] - best_pt[j]); } for (j, x) in vertices[idx].iter_mut().enumerate() { let (lo, hi) = self.bounds.bounds[j]; *x = x.clamp(lo, hi); } evals[idx] = problem.evaluate_async(&vertices[idx]).await; evaluations += 1; } } } } let mut best_idx = 0; for i in 1..vertices.len() { if better(&evals[i], &evals[best_idx], direction) { best_idx = i; } } let best = Candidate::new(vertices[best_idx].clone(), evals[best_idx].clone()); let population = Population::new(vec![best.clone()]); let front = vec![best.clone()]; OptimizationResult::new( population, front, Some(best), evaluations, self.config.iterations, ) } } impl crate::traits::AlgorithmInfo for NelderMead { fn name(&self) -> &'static str { "Nelder-Mead" } fn full_name(&self) -> &'static str { "Nelder-Mead simplex direct search" } } #[cfg(test)] mod tests { use super::*; use crate::core::evaluation::Evaluation; use crate::core::objective::{Objective, ObjectiveSpace}; use crate::tests_support::{SchafferN1, Sphere1D}; /// 2-D Rosenbrock for shape exercise. struct Rosenbrock2D; impl Problem for Rosenbrock2D { type Decision = Vec; fn objectives(&self) -> ObjectiveSpace { ObjectiveSpace::new(vec![Objective::minimize("f")]) } fn evaluate(&self, x: &Vec) -> Evaluation { let a = 1.0 - x[0]; let b = x[1] - x[0] * x[0]; Evaluation::new(vec![a * a + 100.0 * b * b]) } } #[test] fn finds_minimum_of_sphere() { let mut opt = NelderMead::new( NelderMeadConfig { iterations: 200, ..NelderMeadConfig::default() }, RealBounds::new(vec![(-5.0, 5.0)]), ); let r = opt.run(&Sphere1D); let best = r.best.unwrap(); assert!( best.evaluation.objectives[0] < 1e-8, "got f = {}", best.evaluation.objectives[0], ); } #[test] fn finds_minimum_of_2d_rosenbrock() { let mut opt = NelderMead::new( NelderMeadConfig { iterations: 500, initial_step: 0.5, ..NelderMeadConfig::default() }, RealBounds::new(vec![(-2.0, 2.0); 2]), ); let r = opt.run(&Rosenbrock2D); let best = r.best.unwrap(); assert!( best.evaluation.objectives[0] < 1e-3, "got f = {}", best.evaluation.objectives[0], ); } #[test] fn deterministic_no_rng() { // Nelder-Mead is purely deterministic — same bounds + same iters // → same result, no seed needed. let make = || { NelderMead::new( NelderMeadConfig { iterations: 100, ..NelderMeadConfig::default() }, RealBounds::new(vec![(-5.0, 5.0)]), ) }; let mut a = make(); let mut b = make(); let ra = a.run(&Sphere1D); let rb = b.run(&Sphere1D); assert_eq!( ra.best.unwrap().evaluation.objectives, rb.best.unwrap().evaluation.objectives, ); } #[test] #[should_panic(expected = "exactly one objective")] fn multi_objective_panics() { let mut opt = NelderMead::new( NelderMeadConfig::default(), RealBounds::new(vec![(-5.0, 5.0)]), ); let _ = opt.run(&SchafferN1); } // ---- Mutation-test pinned helpers -------------------------------------- use crate::core::objective::Direction; #[test] fn compare_feasibility_first_and_direction() { let feasible = Evaluation::new(vec![10.0]); let infeasible = Evaluation::constrained(vec![0.0], 1.0); assert_eq!( compare(&feasible, &infeasible, Direction::Minimize), std::cmp::Ordering::Less ); let lo = Evaluation::new(vec![1.0]); let hi = Evaluation::new(vec![2.0]); assert_eq!( compare(&lo, &hi, Direction::Minimize), std::cmp::Ordering::Less ); assert_eq!( compare(&lo, &hi, Direction::Maximize), std::cmp::Ordering::Greater ); let v_lo = Evaluation::constrained(vec![0.0], 0.2); let v_hi = Evaluation::constrained(vec![0.0], 0.8); assert_eq!( compare(&v_lo, &v_hi, Direction::Minimize), std::cmp::Ordering::Less ); } #[test] fn better_is_strict_less() { let lo = Evaluation::new(vec![1.0]); let hi = Evaluation::new(vec![2.0]); assert!(better(&lo, &hi, Direction::Minimize)); assert!(!better(&hi, &lo, Direction::Minimize)); let eq = Evaluation::new(vec![1.0]); assert!(!better(&lo, &eq, Direction::Minimize)); } }