Skip to main content

basin/solver/
cma_inject.rs

1use crate::core::executor::OptimizationResult;
2use crate::core::inner::{InitialState, InnerExecutor, WarmStart};
3use crate::core::math::{
4    ComponentMulAssign, MatTransposeVec, MatVec, MatrixFromDiagonal,
5    MatrixIdentity, NormSquared, RankOneUpdate, SampleStandardNormal, Scalar,
6    ScaleInPlace, ScaledAdd, SymmetricEigen, VectorLen,
7};
8use crate::core::problem::{CostFunction, Problem};
9use crate::core::solver::Solver;
10use crate::core::state::{
11    CountsMirror, FirstOrderState, IntoInitialSimplex, PointState,
12    PopulationProgress, SimplexProgress, State,
13};
14use crate::core::termination::Termination;
15use crate::solver::cma_es::{CmaEs, sort_population_ascending};
16use crate::solver::lbfgs::{Bounded, Lbfgs};
17use crate::solver::levenberg_marquardt::LevenbergMarquardt;
18use crate::solver::nelder_mead::NelderMead;
19
20/// An inner solver eligible to plug into a CMA-ES injection wrapper
21/// ([`CmaInject`]/[`BoundedCmaInject`](crate::solver::BoundedCmaInject)).
22///
23/// Extends [`WarmStart`] (and thus [`InitialState`]), which supplies the
24/// associated [`State`](InitialState::State) shape and the σ-free
25/// [`seed`](InitialState::seed). `MemeticInner` adds the step-size-scaled
26/// seed CMA-ES injection needs: given a candidate `x` and the current
27/// CMA step-size `σ`, configure the inner solver and build a fresh progress
28/// seed whose search scale tracks the outer distribution's spread.
29///
30/// # Implementations
31///
32/// Shipped impls for [`NelderMead`], [`LevenbergMarquardt`], and
33/// [`Lbfgsb`](crate::Lbfgsb). To plug in something else, either impl this trait (plus
34/// [`WarmStart`] and [`InitialState`]) on your solver, or wrap a `Solver<P, S>` in
35/// [`ClosureInner`] with an inline seeder closure (escape hatch for
36/// one-off experiments and the `AlwaysFails`-style failure-bubbling
37/// tests).
38///
39/// # Why an associated state type
40///
41/// Each inner publishes its progress shape: NM uses a simplex (`n + 1`
42/// vertices), LM uses a point and cost, and L-BFGS-B also publishes a gradient.
43/// The inner solver owns its evolving model. Tying
44/// [`State`](InitialState::State) to [`InitialState`] lets the memetic factory
45/// write `BoundedCmaInject::with_inner_solver(cma, Lbfgsb::new())`
46/// without the caller having to spell out `FirstOrderState<V>` in turbofish;
47/// `I` determines it.
48///
49/// # Eval aggregation
50///
51/// No per-trait hook: same-problem composition shares the outer's
52/// [`Problem`] wrapper, so inner evals flow through automatically.
53/// [`PopulationProgress`] preserves every raw evaluation category; inner
54/// derivative work remains separate from outer cost calls.
55pub trait MemeticInner<V, F = f64>: WarmStart<V>
56where
57    F: Scalar,
58{
59    /// Configure the inner search scale and supply a progress seed at `x`.
60    /// The caller must then initialize a fresh solve with the returned state.
61    /// The default ignores `sigma` and uses [`InitialState::seed`].
62    fn seed_scaled(&mut self, x: &V, _sigma: F) -> Self::State {
63        self.seed(x)
64    }
65}
66
67/// Closure type for `ClosureInner`'s state seeder.
68type ClosureSeedFn<V, S, F> = Box<dyn Fn(&V, F) -> S>;
69
70/// Closure-based [`MemeticInner`] wrapper for custom inner solvers.
71///
72/// Intended use is one-off experiments and contract tests (e.g. the
73/// `AlwaysFails` harness verifying `SolverFailed` bubbling). For
74/// shipping configurations, prefer impl-ing `MemeticInner` on your
75/// solver type; it's a three-line trait.
76pub struct ClosureInner<I, S, V, F = f64> {
77    inner: I,
78    seed_fn: ClosureSeedFn<V, S, F>,
79}
80
81impl<I, S, V, F> ClosureInner<I, S, V, F> {
82    /// Wrap `inner` with an explicit seeder closure.
83    pub fn new(inner: I, seed_fn: impl Fn(&V, F) -> S + 'static) -> Self {
84        Self {
85            inner,
86            seed_fn: Box::new(seed_fn),
87        }
88    }
89}
90
91impl<P, I, S, V, F> Solver<P, S> for ClosureInner<I, S, V, F>
92where
93    I: Solver<P, S>,
94    S: State,
95{
96    type Error = I::Error;
97
98    fn init(
99        &mut self,
100        problem: &mut Problem<P>,
101        state: S,
102    ) -> Result<S, Self::Error> {
103        self.inner.init(problem, state)
104    }
105    fn next_iter(
106        &mut self,
107        problem: &mut Problem<P>,
108        state: S,
109    ) -> Result<crate::SolverStep<S>, Self::Error> {
110        self.inner.next_iter(problem, state)
111    }
112    fn terminate(&self, state: &S) -> Option<Termination<S::Float>> {
113        self.inner.terminate(state)
114    }
115}
116
117impl<I, S, V, F> InitialState<V> for ClosureInner<I, S, V, F>
118where
119    F: Scalar,
120    S: State<Param = V>,
121{
122    type State = S;
123    fn seed(&self, x: &V) -> S {
124        // σ-free seed: the closure receives σ = 0. `ClosureInner` is an
125        // experiment and contract-test escape hatch, so a documented dummy
126        // is acceptable here where it is not in the native impls.
127        (self.seed_fn)(x, F::zero())
128    }
129}
130
131impl<I, S, V, F> WarmStart<V> for ClosureInner<I, S, V, F>
132where
133    F: Scalar,
134    S: State<Param = V>,
135{
136}
137
138impl<I, S, V, F> MemeticInner<V, F> for ClosureInner<I, S, V, F>
139where
140    F: Scalar,
141    S: State<Param = V>,
142{
143    fn seed_scaled(&mut self, x: &V, sigma: F) -> S {
144        (self.seed_fn)(x, sigma)
145    }
146}
147
148// WarmStart + MemeticInner impls for the three shipped inners.
149
150impl<Mode, V, F> InitialState<V> for NelderMead<V, F, Mode>
151where
152    F: Scalar,
153    V: VectorLen + Clone + IntoInitialSimplex<V, F>,
154{
155    type State = SimplexProgress<V, F>;
156    fn seed(&self, x: &V) -> SimplexProgress<V, F> {
157        // σ-free seed: Nelder-Mead's own default relative-step simplex
158        // (FMINSEARCH and SciPy 5%), used when there is no outer step-size to
159        // track (e.g. a barrier or AL inner).
160        SimplexProgress::new(x.clone())
161    }
162}
163
164impl<Mode, V, F> WarmStart<V> for NelderMead<V, F, Mode>
165where
166    F: Scalar,
167    V: VectorLen + Clone + IntoInitialSimplex<V, F>,
168{
169}
170
171impl<Mode, V, F> MemeticInner<V, F> for NelderMead<V, F, Mode>
172where
173    F: Scalar,
174    V: VectorLen + Clone + IntoInitialSimplex<V, F> + crate::VectorIndex<F>,
175{
176    fn seed_scaled(&mut self, x: &V, sigma: F) -> SimplexProgress<V, F> {
177        // σ-scaled axis-aligned simplex: edge = current CMA step-size,
178        // so the inner's exploration tracks the outer distribution's
179        // spread and shrinks with σ. Hansen 2011 doesn't prescribe a
180        // specific simplex; this matches the S11 default that the
181        // existing tests validate against.
182        let n = x.vec_len();
183        let mut vertices = Vec::with_capacity(n + 1);
184        vertices.push(x.clone());
185        for j in 0..n {
186            let mut v = x.clone();
187            v.set_scalar(j, v.get_scalar(j) + sigma);
188            vertices.push(v);
189        }
190        SimplexProgress::from_simplex(vertices)
191    }
192}
193
194impl<V, M, F> InitialState<V> for LevenbergMarquardt<V, M, F>
195where
196    F: Scalar,
197    V: Clone,
198{
199    type State = PointState<V, F>;
200    fn seed(&self, x: &V) -> PointState<V, F> {
201        PointState::new(x.clone())
202    }
203}
204
205impl<V, M, F> WarmStart<V> for LevenbergMarquardt<V, M, F>
206where
207    F: Scalar,
208    V: Clone,
209{
210}
211
212impl<V, M, F> MemeticInner<V, F> for LevenbergMarquardt<V, M, F>
213where
214    F: Scalar,
215    V: Clone,
216{
217    // `seed_scaled` defaults to `seed`; LM ignores σ.
218}
219
220// `WarmStart` is generic over the mode marker so both `Lbfgsb` (bounded,
221// used as a CMA inner) and `Lbfgs<V, F, Unbounded>` (used as a barrier or AL
222// inner) seed the same `FirstOrderState`. `MemeticInner` stays on the bounded
223// alias only; CMA injection pairs with the bounded variant.
224impl<Mode, S, V, F> InitialState<V> for Lbfgs<V, F, Mode, S>
225where
226    F: Scalar,
227    V: Clone,
228{
229    type State = FirstOrderState<V, F>;
230    fn seed(&self, x: &V) -> FirstOrderState<V, F> {
231        FirstOrderState::new(x.clone())
232    }
233}
234
235impl<Mode, S, V, F> WarmStart<V> for Lbfgs<V, F, Mode, S>
236where
237    F: Scalar,
238    V: Clone,
239{
240}
241
242impl<S, V, F> MemeticInner<V, F> for Lbfgs<V, F, Bounded, S>
243where
244    F: Scalar,
245    V: Clone,
246{
247    // `seed_scaled` defaults to `seed`; L-BFGS-B ignores σ.
248}
249
250// CmaInject: memetic CMA-ES with Hansen-2011 injection.
251
252/// Memetic CMA-ES with Hansen (2011) injection: outer CMA-ES proposes
253/// `λ` candidates per generation, an inner local solver
254/// ([`MemeticInner`]) refines the best `k`, and the refined points are
255/// Mahalanobis-clipped and injected back into the population for the
256/// next CMA update.
257///
258/// The only departure from the standard
259/// [`CmaEs`] update is clipping each
260/// injected point's normalized step in Mahalanobis distance:
261///
262/// ```text
263///   y_i ← min(1, c_y / ‖C^{-1/2} y_i‖) · y_i        (Hansen 2011 eq. 4)
264///   c_y = √n + 2n/(n+2)                              (Table 1 default)
265/// ```
266///
267/// with `y_i = (x_i − m)/σ` and `C^{-1/2} = B D^{-1} Bᵀ` from the
268/// post-update eigendecomposition CMA-ES already maintains. After
269/// clipping, replaced candidates re-enter the population on equal
270/// footing with regular samples; all subsequent CMA updates
271/// (m, p_σ, p_c, C, σ) run the standard equations unchanged. Lamarckian
272/// by construction; no Baldwinian mode in the paper.
273///
274/// # Inner solver
275///
276/// Generic over any `I: MemeticInner<V>`. The associated `I::State`
277/// determines the inner state shape. Shipped impls cover
278/// [`NelderMead`], [`LevenbergMarquardt`], and [`Lbfgsb`](crate::Lbfgsb). For
279/// L-BFGS-B inner with consistent bound flow, use the bounded sibling
280/// [`BoundedCmaInject`](crate::solver::BoundedCmaInject) over
281/// [`BoundedCmaEs`](crate::solver::BoundedCmaEs).
282///
283/// # Eval aggregation
284///
285/// Same-problem composition: the inner shares the outer's
286/// [`Problem`] wrapper, so every inner cost/gradient/Jacobian/
287/// Hessian call bumps the same
288/// [`EvalCounts`](crate::core::problem::EvalCounts) as the outer's own
289/// evaluations. [`PopulationProgress`] preserves all six categories without
290/// folding derivative work into `cost_evals`. Use `counts().total_work()` for
291/// an explicit aggregate. Exact checkpoints retain the outer distribution,
292/// inner solver, progress, and counts together. With `serde`, these serialize
293/// when their constituent types do; application hooks cannot be serialized.
294///
295/// # Backends
296///
297/// Same coverage as [`CmaEs`]: `Vec<F>` for `F = f32` or `f64` (via
298/// [`DenseMatrix`](crate::DenseMatrix)), nalgebra, ndarray, and faer. The
299/// matrix capabilities are those of [`CmaEs`], and the
300/// shipped [`MemeticInner`] inners are
301/// backend-generic.
302///
303/// # Examples
304///
305/// See [`CmaEs`] for the base population-based `Executor` pattern;
306/// `CmaInject` adds a local-search inner via Hansen-2011 injection.
307#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
308#[cfg_attr(
309    feature = "serde",
310    serde(bound(
311        serialize = "I: serde::Serialize, V: serde::Serialize, M: serde::Serialize, F: serde::Serialize",
312        deserialize = "I: serde::Deserialize<'de>, V: serde::Deserialize<'de>, M: serde::Deserialize<'de>, F: serde::Deserialize<'de>"
313    ))
314)]
315pub struct CmaInject<I, V, M, F = f64>
316where
317    F: Scalar,
318    I: MemeticInner<V, F>,
319{
320    cma: CmaEs<V, M, F>,
321    inner: InnerExecutor<I::State, I>,
322    #[cfg_attr(feature = "serde", serde(default))]
323    inner_diagnostics: Option<Vec<crate::core::inner::diagnostics::Record<F>>>,
324    k: usize,
325    c_y_override: Option<F>,
326}
327
328impl<I, V, M, F> CmaInject<I, V, M, F>
329where
330    F: Scalar,
331    I: MemeticInner<V, F>,
332    I::State: CountsMirror,
333{
334    /// Wrap a configured [`CmaEs`] with `inner` as the local
335    /// refinement step. Defaults: `k = 1` refinement per generation,
336    /// inner `max_iter = 50`, `c_y` = Hansen-2011 Table 1 default.
337    pub fn with_inner_solver(cma: CmaEs<V, M, F>, inner: I) -> Self {
338        Self {
339            cma,
340            inner: InnerExecutor::new(inner).max_iter(50),
341            k: 1,
342            c_y_override: None,
343            inner_diagnostics: None,
344        }
345    }
346
347    /// Inspect the outer distribution model without transferring its ownership.
348    pub fn cma(&self) -> &CmaEs<V, M, F> {
349        &self.cma
350    }
351
352    /// Number of best-ranked candidates to refine and inject each
353    /// generation. Default `1`.
354    ///
355    /// # Panics
356    ///
357    /// Panics if `k == 0`. `k > λ` is silently clamped at runtime.
358    pub fn with_k(mut self, k: usize) -> Self {
359        assert!(k >= 1, "CmaInject requires k >= 1, got {}", k);
360        self.k = k;
361        self
362    }
363
364    /// Override the Hansen-2011 clipping threshold `c_y` (default
365    /// `√n + 2n/(n+2)`).
366    ///
367    /// # Panics
368    ///
369    /// Panics if `c_y <= 0`.
370    pub fn with_c_y(mut self, c_y: F) -> Self {
371        assert!(c_y > F::zero(), "CmaInject requires c_y > 0, got {:?}", c_y);
372        self.c_y_override = Some(c_y);
373        self
374    }
375
376    /// Enable owned inner stopping reports and count snapshots. Disabled by default.
377    /// Fresh initialization clears records; exact continuation retains them.
378    pub fn with_inner_diagnostics(mut self, enabled: bool) -> Self {
379        self.inner_diagnostics = enabled.then(Vec::new);
380        self
381    }
382
383    /// Inner reports consumed during this outer run, in dispatch order.
384    /// A callback error retains counts and has no returned report.
385    pub fn inner_diagnostics(
386        &self,
387    ) -> &[crate::core::inner::diagnostics::Record<F>] {
388        self.inner_diagnostics.as_deref().unwrap_or(&[])
389    }
390
391    /// Inner solver iteration budget per outer generation (default `50`).
392    pub fn with_inner_max_iter(self, n: u64) -> Self {
393        let Self {
394            cma,
395            inner,
396            k,
397            c_y_override,
398            inner_diagnostics,
399        } = self;
400        Self {
401            cma,
402            inner: inner.max_iter(n),
403            k,
404            c_y_override,
405            inner_diagnostics,
406        }
407    }
408
409    /// Configure the outer CMA distribution-size tolerance; `None` disables it.
410    pub fn with_absolute_distribution_size_tolerance(
411        mut self,
412        value: impl Into<Option<F>>,
413    ) -> Self {
414        self.cma = self.cma.with_absolute_distribution_size_tolerance(value);
415        self
416    }
417
418    /// Add a factory creating fresh application-stop history for each inner run.
419    pub fn inner_stop_when_factory<Mk, CheckFn>(self, make: Mk) -> Self
420    where
421        Mk: FnMut() -> CheckFn + 'static,
422        CheckFn: FnMut(&I::State) -> Option<crate::ApplicationStop> + 'static,
423    {
424        let Self {
425            cma,
426            inner,
427            k,
428            c_y_override,
429            inner_diagnostics,
430        } = self;
431        Self {
432            cma,
433            inner: inner.stop_when_factory(make),
434            k,
435            c_y_override,
436            inner_diagnostics,
437        }
438    }
439}
440
441/// Hansen 2011 Table 1: `c_y = √n + 2n/(n+2)`, chosen so <10% of
442/// regular `y_i` would be clipped at typical `n` and <1% for `n > 10`.
443///
444/// `pub(crate)` so the sibling
445/// [`BoundedCmaInject`](crate::solver::BoundedCmaInject) can share
446/// this default without re-deriving it.
447pub(crate) fn default_c_y<F: Scalar>(n: usize) -> F {
448    let n = F::from_usize(n).unwrap();
449    let two = F::from_f64(2.0).unwrap();
450    n.sqrt() + two * n / (n + two)
451}
452
453impl<P, I, V, M, F> Solver<P, PopulationProgress<V, F>>
454    for CmaInject<I, V, M, F>
455where
456    F: Scalar,
457    P: CostFunction<Param = V, Output = F>,
458    I: MemeticInner<V, F>
459        + Solver<P, <I as InitialState<V>>::State, Error = P::Error>,
460    I::State: State<Param = V, Float = F> + CountsMirror,
461    V: VectorLen
462        + Clone
463        + ScaledAdd<F>
464        + ScaleInPlace<F>
465        + ComponentMulAssign
466        + NormSquared<F>
467        + SampleStandardNormal
468        + std::ops::Index<usize, Output = F>
469        + std::ops::IndexMut<usize, Output = F>,
470    M: MatrixIdentity
471        + MatrixFromDiagonal<V>
472        + MatVec<V>
473        + MatTransposeVec<V>
474        + ScaleInPlace<F>
475        + RankOneUpdate<V, F>
476        + SymmetricEigen<V>
477        + Clone,
478    CmaEs<V, M, F>: Solver<P, PopulationProgress<V, F>, Error = P::Error>,
479    P: crate::ProblemConstraints<Constraints = crate::NoConstraints>,
480{
481    type Error = P::Error;
482
483    fn init(
484        &mut self,
485        problem: &mut Problem<P>,
486        state: PopulationProgress<V, F>,
487    ) -> Result<PopulationProgress<V, F>, Self::Error> {
488        if let Some(records) = &mut self.inner_diagnostics {
489            records.clear();
490        }
491        // Hansen's preliminary experiments inject from iter 1 onward,
492        // so we delegate the initial population to vanilla CMA-ES.
493        self.cma.init(problem, state)
494    }
495
496    fn next_iter(
497        &mut self,
498        problem: &mut Problem<P>,
499        state: PopulationProgress<V, F>,
500    ) -> Result<crate::SolverStep<PopulationProgress<V, F>>, Self::Error> {
501        // 1. Vanilla CMA-ES iteration: update m, σ, C from the
502        //    previous generation, sample λ fresh candidates sorted by
503        //    cost ascending.
504        let step = self.cma.next_iter(problem, state)?;
505        if step.termination.is_some() {
506            return Ok(step);
507        }
508        let mut state = step.state;
509
510        let work = self.cma.work.as_mut().expect("CMA init precedes injection");
511        let n = work.m.vec_len();
512        let m = work.m.clone();
513        let sigma = work.sigma;
514        let c_y = self.c_y_override.unwrap_or_else(|| default_c_y::<F>(n));
515        let refine = self.k.min(state.candidates.len());
516
517        for i in 0..refine {
518            // 2. Seed the inner state via the trait. The σ argument
519            //    lets seeders that scale with the CMA distribution
520            //    (NM's σ-scaled simplex) track the current spread.
521            let inner_state = self
522                .inner
523                .solver_mut()
524                .seed_scaled(&state.candidates[i], sigma);
525
526            // 3. Drive the inner. Same-problem composition: inner shares
527            //    the outer wrapper, so its evals flow into the outer's
528            //    EvalCounts transparently and the PopulationProgress mirror picks
529            //    them up in their original categories.
530            let inner_result: OptimizationResult<I::State> =
531                crate::core::inner::diagnostics::run(
532                    &mut self.inner_diagnostics,
533                    problem,
534                    crate::core::inner::diagnostics::Context {
535                        outer_iteration: state.iter(),
536                        member: Some(i),
537                        resumed: false,
538                        initial_scale: Some(sigma),
539                    },
540                    |problem| self.inner.run(problem, inner_state),
541                )?;
542
543            // 4. Failure routing: bubble SolverFailed only (composition
544            //    contract).
545            if !inner_result
546                .report
547                .termination
548                .can_continue_as_inner(crate::PartialResultPolicy::Consume)
549            {
550                sort_population_ascending(
551                    &mut state.candidates,
552                    &mut state.costs,
553                );
554                return Ok(crate::SolverStep::from((
555                    state,
556                    inner_result.report.into_outer_termination(
557                        crate::PartialResultPolicy::Consume,
558                    ),
559                )));
560            }
561
562            // 5. Extract refined point.
563            let x_refined = inner_result.state.param().clone();
564
565            // 6. y = (x_refined − m) / σ.
566            let mut y = x_refined;
567            y.scaled_add(-F::one(), &m);
568            y.scale_in_place(F::one() / sigma);
569
570            // 7. ‖C^{-1/2} y‖ = ‖D^{-1} ⊙ Bᵀ y‖, with B, D⁻¹ from the solver.
571            let inv_sqrt_norm = {
572                let mut bt_y = work.b.mat_transpose_vec(&y);
573                bt_y.component_mul_assign(&work.d_inv);
574                bt_y.norm_squared().sqrt()
575            };
576
577            // 8. Clipping factor α (Hansen 2011 eq. 4 + eq. 10).
578            if inv_sqrt_norm > F::zero() {
579                let alpha = (c_y / inv_sqrt_norm).min(F::one());
580                if alpha < F::one() {
581                    y.scale_in_place(alpha);
582                }
583            }
584
585            // 9. x_inj = m + σ · y_clipped.
586            let mut x_inj = m.clone();
587            x_inj.scaled_add(sigma, &y);
588
589            // 10. Re-evaluate: clipping moves the point in original
590            //     space, so the cost field has to match.
591            let cost_new = problem.cost(&x_inj)?;
592
593            state.candidates[i] = x_inj;
594            state.costs[i] = cost_new;
595        }
596
597        // 12. Re-sort: rank-µ update depends on the order.
598        if refine > 0 {
599            sort_population_ascending(&mut state.candidates, &mut state.costs);
600        }
601
602        Ok(crate::SolverStep::from((state, None)))
603    }
604    fn terminate(
605        &self,
606        state: &PopulationProgress<V, F>,
607    ) -> Option<Termination<F>> {
608        <_ as Solver<P, PopulationProgress<V, F>>>::terminate(&self.cma, state)
609    }
610}