From cc1b44b34e0d791b028193e1c4e17a5c60a96d37 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 19:44:38 -0600 Subject: [PATCH] =?UTF-8?q?feat(operators):=20add=20CompositeVariation=20p?= =?UTF-8?q?ipeline=20(crossover=20=E2=86=92=20mutation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic two-stage Variation operator: runs an inner crossover-style operator on the parents, then applies an inner mutation-style operator to each resulting child. Lets users build the canonical NSGA-II operator stack — `SimulatedBinaryCrossover` followed by `PolynomialMutation` — by composing the existing primitives instead of bundling a one-off SbxPolyMut struct. Lives in src/operators/composite.rs to keep type-specific operator files unchanged. Generic over decision type and over both inner operators. --- src/operators/composite.rs | 96 ++++++++++++++++++++++++++++++++++++++ src/operators/mod.rs | 2 + src/prelude.rs | 4 +- 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/operators/composite.rs diff --git a/src/operators/composite.rs b/src/operators/composite.rs new file mode 100644 index 0000000..efdd494 --- /dev/null +++ b/src/operators/composite.rs @@ -0,0 +1,96 @@ +//! Compose two `Variation` operators into a pipeline (typically crossover → mutation). + +use crate::core::rng::Rng; +use crate::traits::Variation; + +/// A two-stage variation pipeline. +/// +/// On each call to `vary`: +/// +/// 1. The `crossover` operator is run on the input `parents`, producing one +/// or more children. +/// 2. For every child, the `mutation` operator is run with that child as its +/// sole parent, and the resulting children are concatenated into the +/// output. +/// +/// Use this to build the canonical NSGA-II operator stack — SBX followed by +/// polynomial mutation — out of the existing primitives: +/// +/// ```rust +/// use heuropt::prelude::*; +/// +/// let bounds = vec![(0.0, 1.0); 30]; +/// let variation = CompositeVariation { +/// crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5), +/// mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 30.0), +/// }; +/// let _ = variation; +/// ``` +#[derive(Debug, Clone)] +pub struct CompositeVariation { + /// First-stage operator; typically a crossover that consumes ≥ 2 parents. + pub crossover: C, + /// Second-stage operator; typically a mutation that consumes 1 parent. + pub mutation: M, +} + +impl Variation for CompositeVariation +where + D: Clone, + C: Variation, + M: Variation, +{ + fn vary(&mut self, parents: &[D], rng: &mut Rng) -> Vec { + let crossed = self.crossover.vary(parents, rng); + let mut out = Vec::with_capacity(crossed.len()); + for child in crossed { + let mutated = self.mutation.vary(std::slice::from_ref(&child), rng); + out.extend(mutated); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::rng::rng_from_seed; + use crate::operators::real::{ + BoundedGaussianMutation, PolynomialMutation, SimulatedBinaryCrossover, + }; + + #[test] + fn pipes_sbx_into_polynomial_mutation() { + let bounds = vec![(-1.0, 1.0); 4]; + let mut variation = CompositeVariation { + crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 1.0), + mutation: PolynomialMutation::new(bounds, 20.0, 0.25), + }; + let mut rng = rng_from_seed(123); + let p1 = vec![0.1, -0.2, 0.3, -0.4]; + let p2 = vec![-0.3, 0.4, -0.1, 0.2]; + let children = variation.vary(&[p1, p2], &mut rng); + // SBX produces 2 children; polynomial mutation produces 1 child each. + assert_eq!(children.len(), 2); + for c in &children { + assert_eq!(c.len(), 4); + for &x in c { + assert!(x >= -1.0 && x <= 1.0); + } + } + } + + #[test] + fn output_count_equals_inner_crossover_count_when_mutation_is_1to1() { + // BoundedGaussianMutation always returns 1 child. + let bounds = vec![(0.0, 1.0); 3]; + let mut variation = CompositeVariation { + crossover: SimulatedBinaryCrossover::new(bounds.clone(), 10.0, 0.5), + mutation: BoundedGaussianMutation::new(0.05, bounds), + }; + let mut rng = rng_from_seed(0); + let parents = vec![vec![0.5, 0.5, 0.5], vec![0.25, 0.75, 0.5]]; + let children = variation.vary(&parents, &mut rng); + assert_eq!(children.len(), 2); + } +} diff --git a/src/operators/mod.rs b/src/operators/mod.rs index a8fb0a2..f40dbda 100644 --- a/src/operators/mod.rs +++ b/src/operators/mod.rs @@ -1,9 +1,11 @@ //! Built-in operators for common decision types. pub mod binary; +pub mod composite; pub mod permutation; pub mod real; pub use binary::*; +pub use composite::*; pub use permutation::*; pub use real::*; diff --git a/src/prelude.rs b/src/prelude.rs index c3dd7a7..a924679 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -17,8 +17,8 @@ pub use crate::pareto::{ }; pub use crate::operators::{ - BitFlipMutation, BoundedGaussianMutation, GaussianMutation, PolynomialMutation, - RealBounds, SimulatedBinaryCrossover, SwapMutation, + BitFlipMutation, BoundedGaussianMutation, CompositeVariation, GaussianMutation, + PolynomialMutation, RealBounds, SimulatedBinaryCrossover, SwapMutation, }; pub use crate::algorithms::{