From acf1789d5b3602af55396d9e208d53c459f14dd3 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 19:42:48 -0600 Subject: [PATCH] feat(operators): add BoundedGaussianMutation A bounded variant of GaussianMutation: same Gaussian noise applied to the first parent, but every variable is clamped to its per-dimension inclusive bound. Useful as a drop-in for problems that need feasibility maintained across generations rather than relying on clamp-inside-evaluate. Panics on `sigma <= 0.0`, on no parents, and on construction if any `(lo, hi)` has `lo > hi`. Decision length must match the bounds length when called. --- src/operators/real.rs | 89 +++++++++++++++++++++++++++++++++++++++++++ src/prelude.rs | 4 +- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/operators/real.rs b/src/operators/real.rs index 5e3fbcf..15d86df 100644 --- a/src/operators/real.rs +++ b/src/operators/real.rs @@ -73,6 +73,59 @@ impl Variation> for GaussianMutation { } } +/// Bounded variant of [`GaussianMutation`]: add `Normal(0, sigma)` noise to +/// every variable of the first parent, then clamp each variable to its +/// per-dimension inclusive bound. +/// +/// Always returns exactly one child. Use this when you want feasibility +/// maintained across generations without leaning on +/// clamp-inside-`Problem::evaluate`. +#[derive(Debug, Clone)] +pub struct BoundedGaussianMutation { + /// Standard deviation of the Gaussian noise. Must be positive. + pub sigma: f64, + /// Per-variable inclusive bounds. Length must match the parent decision. + pub bounds: Vec<(f64, f64)>, +} + +impl BoundedGaussianMutation { + /// Construct a `BoundedGaussianMutation`. + /// + /// # Panics + /// If `sigma <= 0.0` or any bound has `lo > hi`. + pub fn new(sigma: f64, bounds: Vec<(f64, f64)>) -> Self { + assert!(sigma > 0.0, "BoundedGaussianMutation sigma must be positive"); + for (i, &(lo, hi)) in bounds.iter().enumerate() { + assert!( + lo <= hi, + "BoundedGaussianMutation bound at index {i} has lo > hi: ({lo}, {hi})", + ); + } + Self { sigma, bounds } + } +} + +impl Variation> for BoundedGaussianMutation { + fn vary(&mut self, parents: &[Vec], rng: &mut Rng) -> Vec> { + assert!( + !parents.is_empty(), + "BoundedGaussianMutation requires at least one parent", + ); + assert_eq!( + parents[0].len(), + self.bounds.len(), + "BoundedGaussianMutation parent length must match bounds length", + ); + let normal = + Normal::new(0.0, self.sigma).expect("Normal distribution rejected sigma"); + let mut child = parents[0].clone(); + for (x, &(lo, hi)) in child.iter_mut().zip(self.bounds.iter()) { + *x = (*x + normal.sample(rng)).clamp(lo, hi); + } + vec![child] + } +} + #[cfg(test)] mod tests { use super::*; @@ -130,4 +183,40 @@ mod tests { let mut rng = rng_from_seed(1); m.vary(&[] as &[Vec], &mut rng); } + + #[test] + fn bounded_gaussian_keeps_child_in_bounds() { + let mut m = BoundedGaussianMutation::new(5.0, vec![(-1.0, 1.0); 4]); + let mut rng = rng_from_seed(0); + let parent = vec![0.0_f64; 4]; + // sigma=5 against bounds [-1, 1] guarantees clamping fires. + for _ in 0..100 { + let children = m.vary(std::slice::from_ref(&parent), &mut rng); + assert_eq!(children.len(), 1); + assert_eq!(children[0].len(), 4); + for &x in &children[0] { + assert!(x >= -1.0 && x <= 1.0, "out of bounds: {x}"); + } + } + } + + #[test] + #[should_panic(expected = "sigma must be positive")] + fn bounded_gaussian_zero_sigma_panics() { + let _ = BoundedGaussianMutation::new(0.0, vec![(0.0, 1.0)]); + } + + #[test] + #[should_panic(expected = "lo > hi")] + fn bounded_gaussian_invalid_bounds_panics() { + let _ = BoundedGaussianMutation::new(0.1, vec![(1.0, 0.0)]); + } + + #[test] + #[should_panic(expected = "must match bounds length")] + fn bounded_gaussian_mismatched_length_panics() { + let mut m = BoundedGaussianMutation::new(0.1, vec![(0.0, 1.0); 3]); + let mut rng = rng_from_seed(0); + m.vary(&[vec![0.0; 2]], &mut rng); + } } diff --git a/src/prelude.rs b/src/prelude.rs index 9415926..0953bc9 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -16,7 +16,9 @@ pub use crate::pareto::{ pareto_compare, pareto_front, }; -pub use crate::operators::{BitFlipMutation, GaussianMutation, RealBounds, SwapMutation}; +pub use crate::operators::{ + BitFlipMutation, BoundedGaussianMutation, GaussianMutation, RealBounds, SwapMutation, +}; pub use crate::algorithms::{ DifferentialEvolution, DifferentialEvolutionConfig, Nsga2, Nsga2Config, Paes, PaesConfig,