From a93d0df858a6f4a06b541844740a43efb56d600a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 07:52:01 -0600 Subject: [PATCH] feat(algorithms): add HillClimber single-objective greedy local search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simplest possible local search: start from one initializer-sampled decision, repeatedly mutate it via the variation operator, and keep the child only when it is strictly better than the current incumbent (with the standard feasible-beats-infeasible / lower-violation tiebreaks when relevant). Single-objective only — panics with a clear message if the problem exposes more than one objective. Deterministic under a seed. Returns a population/front of size one (the current incumbent) so it slots into the comparison harness like any other optimizer. --- src/algorithms/hill_climber.rs | 151 +++++++++++++++++++++++++++++++++ src/algorithms/mod.rs | 2 + src/prelude.rs | 6 +- 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/algorithms/hill_climber.rs diff --git a/src/algorithms/hill_climber.rs b/src/algorithms/hill_climber.rs new file mode 100644 index 0000000..f25abdc --- /dev/null +++ b/src/algorithms/hill_climber.rs @@ -0,0 +1,151 @@ +//! `HillClimber` — single-objective greedy local search. + +use crate::core::candidate::Candidate; +use crate::core::objective::Direction; +use crate::core::population::Population; +use crate::core::problem::Problem; +use crate::core::result::OptimizationResult; +use crate::core::rng::rng_from_seed; +use crate::traits::{Initializer, Optimizer, Variation}; + +/// Configuration for [`HillClimber`]. +#[derive(Debug, Clone)] +pub struct HillClimberConfig { + /// Number of mutation iterations. + pub iterations: usize, + /// Seed for the deterministic RNG. + pub seed: u64, +} + +impl Default for HillClimberConfig { + fn default() -> Self { + Self { iterations: 1000, seed: 42 } + } +} + +/// Single-objective greedy hill climber. +/// +/// Starts from one initializer-sampled decision, repeatedly mutates it via +/// the variation operator, and keeps the child only when it is strictly +/// better than the current incumbent. Standard feasibility tiebreaks apply: +/// feasible beats infeasible, smaller violation wins among infeasibles. +/// +/// Single-objective only. +#[derive(Debug, Clone)] +pub struct HillClimber { + /// Algorithm configuration. + pub config: HillClimberConfig, + /// Initial-decision sampler. + pub initializer: I, + /// Mutation operator. + pub variation: V, +} + +impl HillClimber { + /// Construct a `HillClimber`. + pub fn new(config: HillClimberConfig, initializer: I, variation: V) -> Self { + Self { config, initializer, variation } + } +} + +impl Optimizer

for HillClimber +where + P: Problem + Sync, + P::Decision: Send, + I: Initializer, + V: Variation, +{ + fn run(&mut self, problem: &P) -> OptimizationResult { + let objectives = problem.objectives(); + assert!( + objectives.is_single_objective(), + "HillClimber requires exactly one objective", + ); + let direction = objectives.objectives[0].direction; + let mut rng = rng_from_seed(self.config.seed); + + let mut initial = self.initializer.initialize(1, &mut rng); + assert!(!initial.is_empty(), "HillClimber initializer returned no decisions"); + let mut current_decision = initial.remove(0); + let mut current_eval = problem.evaluate(¤t_decision); + let mut evaluations = 1usize; + + for _ in 0..self.config.iterations { + let parents = vec![current_decision.clone()]; + let children = self.variation.vary(&parents, &mut rng); + assert!(!children.is_empty(), "HillClimber variation returned no children"); + let child_decision = children.into_iter().next().unwrap(); + let child_eval = problem.evaluate(&child_decision); + evaluations += 1; + + let child_better = match (child_eval.is_feasible(), current_eval.is_feasible()) { + (true, false) => true, + (false, true) => false, + (false, false) => { + child_eval.constraint_violation < current_eval.constraint_violation + } + (true, true) => match direction { + Direction::Minimize => child_eval.objectives[0] < current_eval.objectives[0], + Direction::Maximize => child_eval.objectives[0] > current_eval.objectives[0], + }, + }; + if child_better { + current_decision = child_decision; + current_eval = child_eval; + } + } + + let best = Candidate::new(current_decision, current_eval); + let population = Population::new(vec![best.clone()]); + let front = vec![best.clone()]; + OptimizationResult::new( + population, + front, + Some(best), + evaluations, + self.config.iterations, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::operators::{GaussianMutation, RealBounds}; + use crate::tests_support::{SchafferN1, Sphere1D}; + + fn make_optimizer(seed: u64) -> HillClimber { + HillClimber::new( + HillClimberConfig { iterations: 500, seed }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ) + } + + #[test] + fn finds_minimum_of_sphere() { + let mut opt = make_optimizer(1); + let r = opt.run(&Sphere1D); + let best = r.best.unwrap(); + assert!(best.evaluation.objectives[0] < 1e-2, "got f = {}", best.evaluation.objectives[0]); + } + + #[test] + fn deterministic_with_same_seed() { + let mut a = make_optimizer(99); + let mut b = make_optimizer(99); + 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 = make_optimizer(0); + let _ = opt.run(&SchafferN1); + } +} diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index 71fc557..f62687b 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -1,6 +1,7 @@ //! Built-in reference optimizers. pub mod differential_evolution; +pub mod hill_climber; pub mod moead; pub mod nsga2; pub mod nsga3; @@ -10,6 +11,7 @@ pub mod random_search; pub mod spea2; pub use differential_evolution::*; +pub use hill_climber::*; pub use moead::*; pub use nsga2::*; pub use nsga3::*; diff --git a/src/prelude.rs b/src/prelude.rs index 64cb471..b638028 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -22,7 +22,7 @@ pub use crate::operators::{ }; pub use crate::algorithms::{ - DifferentialEvolution, DifferentialEvolutionConfig, Moead, MoeadConfig, Nsga2, - Nsga2Config, Nsga3, Nsga3Config, Paes, PaesConfig, RandomSearch, RandomSearchConfig, - Spea2, Spea2Config, + DifferentialEvolution, DifferentialEvolutionConfig, HillClimber, HillClimberConfig, + Moead, MoeadConfig, Nsga2, Nsga2Config, Nsga3, Nsga3Config, Paes, PaesConfig, + RandomSearch, RandomSearchConfig, Spea2, Spea2Config, };