Skip to main content

basin/solver/
cma_inject.rs

1use crate::core::executor::OptimizationResult;
2use crate::core::inner::{InnerExecutor, WarmStart};
3use crate::core::math::{
4    ComponentMulAssign, MatTransposeVec, MatVec, MatrixFromDiagonal, MatrixIdentity, NormSquared,
5    RankOneUpdate, SampleStandardNormal, ScaleInPlace, ScaledAdd, SymmetricEigen, VectorLen,
6};
7use crate::core::problem::CostFunction;
8use crate::core::solver::Solver;
9use crate::core::state::{
10    BasicPopulationState, BasicSimplexState, BasicState, GradientState, IntoInitialSimplex,
11    LbfgsState, State,
12};
13use crate::core::termination::{TerminationCriterion, TerminationReason};
14use crate::solver::cma_es::{sort_population_ascending, CmaEs};
15use crate::solver::lbfgs::{LBFGS, LBFGSB};
16use crate::solver::levenberg_marquardt::LevenbergMarquardt;
17use crate::solver::nelder_mead::NelderMead;
18
19/// An inner solver eligible to plug into a CMA-ES injection wrapper
20/// ([`CmaInject`] / [`BoundedCmaInject`](crate::solver::BoundedCmaInject)).
21///
22/// Extends [`WarmStart`], which supplies the associated
23/// [`State`](WarmStart::State) shape and the σ-free
24/// [`seed`](WarmStart::seed). `MemeticInner` adds what CMA-ES injection
25/// additionally needs: a step-size-scaled seed and a work-unit count.
26///
27/// The trait is the contract between the outer memetic glue and the
28/// inner local solver: it says "given a CMA-ES candidate `x` and the
29/// current step-size `σ`, build a fresh inner state; given a final
30/// inner state, report the total work units accumulated."
31///
32/// # Implementations
33///
34/// Shipped impls for [`NelderMead`], [`LevenbergMarquardt`], and
35/// [`LBFGSB`]. To plug in something else, either impl this trait (plus
36/// [`WarmStart`]) on your solver, or wrap a `Solver<P, S>` in
37/// [`ClosureInner`] with inline seeder/work closures (escape hatch for
38/// one-off experiments and the `AlwaysFails`-style failure-bubbling
39/// tests).
40///
41/// # Why an associated state type
42///
43/// Each inner has a natural state shape: NM wants a simplex (`n + 1`
44/// vertices), LM wants a single iterate with cached residual / Jacobian,
45/// L-BFGS-B wants the limited-memory history. Tying
46/// [`State`](WarmStart::State) to [`WarmStart`] lets the memetic factory
47/// write `BoundedCmaInject::with_inner_solver(cma, LBFGSB::new())`
48/// without the caller having to spell out `LbfgsState<V>` in turbofish —
49/// `I` determines it.
50///
51/// # Eval aggregation
52///
53/// `work_units(&self, state)` is what the outer rolls into its
54/// `cost_evals` counter (AGENTS.md "Solver composition" rule 1). For
55/// gradient/Jacobian inners it should sum `state.cost_evals() +
56/// state.gradient_evals()` — CMA-ES outer state has no separate
57/// derivative-eval counter, so derivative work collapses into
58/// `cost_evals` honestly.
59pub trait MemeticInner<V>: WarmStart<V> {
60    /// Build a fresh inner state seeded at CMA-ES candidate `x`, scaled
61    /// by the current step-size `sigma`. Called once per refined
62    /// candidate per outer generation.
63    ///
64    /// Defaults to the σ-free [`WarmStart::seed`]; only inners whose
65    /// state scales with σ (Nelder-Mead's simplex edge) override it.
66    fn seed_scaled(&self, x: &V, _sigma: f64) -> Self::State {
67        self.seed(x)
68    }
69
70    /// Total inner work units to roll into the outer's `cost_evals`.
71    /// Typically `state.cost_evals() + state.gradient_evals()` for
72    /// derivative-based inners, `state.cost_evals()` alone for
73    /// derivative-free inners. Takes `&self` so closure-based inners
74    /// ([`ClosureInner`]) can dispatch through captured state.
75    fn work_units(&self, state: &Self::State) -> u64;
76}
77
78/// Closure type for `ClosureInner`'s state seeder.
79type ClosureSeedFn<V, S> = Box<dyn Fn(&V, f64) -> S>;
80/// Closure type for `ClosureInner`'s work-unit aggregator.
81type ClosureWorkFn<S> = Box<dyn Fn(&S) -> u64>;
82
83/// Closure-based [`MemeticInner`] wrapper for custom inners that don't
84/// have a native impl. Holds an inner solver plus the two closures
85/// `MemeticInner` would otherwise express directly.
86///
87/// Intended use is one-off experiments and contract tests (e.g. the
88/// `AlwaysFails` harness verifying `SolverFailed` bubbling). For
89/// shipping configurations, prefer impl-ing `MemeticInner` on your
90/// solver type — it's a five-line trait.
91pub struct ClosureInner<I, S, V> {
92    inner: I,
93    seed_fn: ClosureSeedFn<V, S>,
94    work_fn: ClosureWorkFn<S>,
95}
96
97impl<I, S, V> ClosureInner<I, S, V> {
98    /// Wrap `inner` with explicit seeder and work-unit closures.
99    pub fn new(
100        inner: I,
101        seed_fn: impl Fn(&V, f64) -> S + 'static,
102        work_fn: impl Fn(&S) -> u64 + 'static,
103    ) -> Self {
104        Self {
105            inner,
106            seed_fn: Box::new(seed_fn),
107            work_fn: Box::new(work_fn),
108        }
109    }
110}
111
112impl<P, I, S, V> Solver<P, S> for ClosureInner<I, S, V>
113where
114    I: Solver<P, S>,
115    S: State,
116{
117    fn init(&mut self, problem: &P, state: S) -> S {
118        self.inner.init(problem, state)
119    }
120    fn next_iter(&mut self, problem: &P, state: S) -> (S, Option<TerminationReason>) {
121        self.inner.next_iter(problem, state)
122    }
123    fn terminate(&self, state: &S) -> Option<TerminationReason> {
124        self.inner.terminate(state)
125    }
126}
127
128impl<I, S, V> WarmStart<V> for ClosureInner<I, S, V>
129where
130    S: State<Param = V>,
131{
132    type State = S;
133    fn seed(&self, x: &V) -> S {
134        // σ-free seed: the closure receives σ = 0. `ClosureInner` is an
135        // experiment / contract-test escape hatch, so a documented dummy
136        // is acceptable here where it is not in the native impls.
137        (self.seed_fn)(x, 0.0)
138    }
139}
140
141impl<I, S, V> MemeticInner<V> for ClosureInner<I, S, V>
142where
143    S: State<Param = V>,
144{
145    fn seed_scaled(&self, x: &V, sigma: f64) -> S {
146        (self.seed_fn)(x, sigma)
147    }
148    fn work_units(&self, state: &S) -> u64 {
149        (self.work_fn)(state)
150    }
151}
152
153// -----------------------------------------------------------------------
154// WarmStart + MemeticInner impls for the three shipped inners.
155// -----------------------------------------------------------------------
156
157impl<V> WarmStart<V> for NelderMead
158where
159    V: VectorLen + Clone + IntoInitialSimplex<V> + std::ops::IndexMut<usize, Output = f64>,
160{
161    type State = BasicSimplexState<V>;
162    fn seed(&self, x: &V) -> BasicSimplexState<V> {
163        // σ-free seed: Nelder-Mead's own default relative-step simplex
164        // (FMINSEARCH/SciPy 5%), used when there is no outer step-size to
165        // track (e.g. a barrier / AL inner).
166        BasicSimplexState::new(x.clone())
167    }
168}
169
170impl<V> MemeticInner<V> for NelderMead
171where
172    V: VectorLen + Clone + IntoInitialSimplex<V> + std::ops::IndexMut<usize, Output = f64>,
173{
174    fn seed_scaled(&self, x: &V, sigma: f64) -> BasicSimplexState<V> {
175        // σ-scaled axis-aligned simplex: edge = current CMA step-size,
176        // so the inner's exploration tracks the outer distribution's
177        // spread and shrinks with σ. Hansen 2011 doesn't prescribe a
178        // specific simplex; this matches the S11 default that the
179        // existing tests validate against.
180        let n = x.vec_len();
181        let mut vertices = Vec::with_capacity(n + 1);
182        vertices.push(x.clone());
183        for j in 0..n {
184            let mut v = x.clone();
185            v[j] += sigma;
186            vertices.push(v);
187        }
188        BasicSimplexState::from_simplex(vertices)
189    }
190    fn work_units(&self, state: &BasicSimplexState<V>) -> u64 {
191        state.cost_evals()
192    }
193}
194
195impl<V, M> WarmStart<V> for LevenbergMarquardt<V, M>
196where
197    V: Clone,
198{
199    type State = BasicState<V>;
200    fn seed(&self, x: &V) -> BasicState<V> {
201        BasicState::new(x.clone())
202    }
203}
204
205impl<V, M> MemeticInner<V> for LevenbergMarquardt<V, M>
206where
207    V: Clone,
208{
209    // `seed_scaled` defaults to `seed` — LM ignores σ.
210    fn work_units(&self, state: &BasicState<V>) -> u64 {
211        state.cost_evals() + state.gradient_evals()
212    }
213}
214
215// `WarmStart` is generic over the mode marker so both `LBFGSB` (bounded,
216// used as a CMA inner) and `LBFGS<Unbounded>` (used as a barrier / AL
217// inner) seed the same `LbfgsState`. `MemeticInner` stays on the bounded
218// alias only — CMA injection pairs with the bounded variant.
219impl<Mode, S, V> WarmStart<V> for LBFGS<Mode, S>
220where
221    V: Clone,
222{
223    type State = LbfgsState<V>;
224    fn seed(&self, x: &V) -> LbfgsState<V> {
225        LbfgsState::new(x.clone(), self.m_capacity)
226    }
227}
228
229impl<V, LS> MemeticInner<V> for LBFGSB<LS>
230where
231    V: Clone,
232{
233    // `seed_scaled` defaults to `seed` — L-BFGS-B ignores σ.
234    fn work_units(&self, state: &LbfgsState<V>) -> u64 {
235        state.cost_evals() + state.gradient_evals()
236    }
237}
238
239// -----------------------------------------------------------------------
240// CmaInject — memetic CMA-ES with Hansen-2011 injection.
241// -----------------------------------------------------------------------
242
243/// Memetic CMA-ES with Hansen (2011) injection: outer CMA-ES proposes
244/// `λ` candidates per generation, an inner local solver
245/// ([`MemeticInner`]) refines the best `k`, and the refined points are
246/// Mahalanobis-clipped and injected back into the population for the
247/// next CMA update.
248///
249/// The only departure from the standard
250/// [`CmaEs`] update is clipping each
251/// injected point's normalised step in Mahalanobis distance:
252///
253/// ```text
254///   y_i ← min(1, c_y / ‖C^{-1/2} y_i‖) · y_i        (Hansen 2011 eq. 4)
255///   c_y = √n + 2n/(n+2)                              (Table 1 default)
256/// ```
257///
258/// with `y_i = (x_i − m)/σ` and `C^{-1/2} = B D^{-1} Bᵀ` from the
259/// post-update eigendecomposition CMA-ES already maintains. After
260/// clipping, replaced candidates re-enter the population on equal
261/// footing with regular samples — all subsequent CMA updates
262/// (m, p_σ, p_c, C, σ) run the standard equations unchanged. Lamarckian
263/// by construction; no Baldwinian mode in the paper.
264///
265/// # Inner solver
266///
267/// Generic over any `I: MemeticInner<V>`. The associated `I::State`
268/// determines the inner state shape. Shipped impls cover
269/// [`NelderMead`], [`LevenbergMarquardt`], and [`LBFGSB`]. For
270/// L-BFGS-B inner with consistent bound flow, use the bounded sibling
271/// [`BoundedCmaInject`](crate::solver::BoundedCmaInject) over
272/// [`BoundedCmaEs`](crate::solver::BoundedCmaEs).
273///
274/// # Eval aggregation
275///
276/// The outer's `state.cost_evals` aggregates total inner work units
277/// per `I::work_units(state)`. For derivative-based inners (LM,
278/// L-BFGS-B) the impl sums `cost_evals + gradient_evals`; CMA-ES
279/// outer state has no `gradient_evals` field
280/// (`BasicPopulationState` extends `State`, not `GradientState`), so
281/// derivative-eval counts collapse into `cost_evals` honestly per
282/// AGENTS.md "Solver composition" rule 1.
283///
284/// # Backends
285///
286/// Same coverage as [`CmaEs`]: nalgebra (`DVector` / `DMatrix`) and
287/// faer (`Col` / `Mat`). `Vec<f64>` and `ndarray` produce a
288/// compile-time error per tenet 5.
289///
290/// # Examples
291///
292/// See [`CmaEs`] for the base population-based `Executor` pattern;
293/// `CmaInject` adds a local-search inner via Hansen-2011 injection.
294pub struct CmaInject<I, V, M>
295where
296    I: MemeticInner<V>,
297{
298    cma: CmaEs<V, M>,
299    inner: InnerExecutor<I::State, I>,
300    k: usize,
301    c_y_override: Option<f64>,
302}
303
304impl<I, V, M> CmaInject<I, V, M>
305where
306    I: MemeticInner<V>,
307{
308    /// Wrap a configured [`CmaEs`] with `inner` as the local
309    /// refinement step. Defaults: `k = 1` refinement per generation,
310    /// inner `max_iter = 50`, `c_y` = Hansen-2011 Table 1 default.
311    pub fn with_inner_solver(cma: CmaEs<V, M>, inner: I) -> Self {
312        Self {
313            cma,
314            inner: InnerExecutor::new(inner).max_iter(50),
315            k: 1,
316            c_y_override: None,
317        }
318    }
319
320    /// Number of best-ranked candidates to refine and inject each
321    /// generation. Default `1`.
322    ///
323    /// # Panics
324    ///
325    /// Panics if `k == 0`. `k > λ` is silently clamped at runtime.
326    pub fn with_k(mut self, k: usize) -> Self {
327        assert!(k >= 1, "CmaInject requires k >= 1, got {}", k);
328        self.k = k;
329        self
330    }
331
332    /// Override the Hansen-2011 clipping threshold `c_y` (default
333    /// `√n + 2n/(n+2)`).
334    ///
335    /// # Panics
336    ///
337    /// Panics if `c_y <= 0`.
338    pub fn with_c_y(mut self, c_y: f64) -> Self {
339        assert!(c_y > 0.0, "CmaInject requires c_y > 0, got {}", c_y);
340        self.c_y_override = Some(c_y);
341        self
342    }
343
344    /// Inner solver iteration budget per outer generation (default `50`).
345    pub fn with_inner_max_iter(self, n: u64) -> Self {
346        let Self {
347            cma,
348            inner,
349            k,
350            c_y_override,
351        } = self;
352        Self {
353            cma,
354            inner: inner.max_iter(n),
355            k,
356            c_y_override,
357        }
358    }
359
360    /// Register a stateless termination criterion on the inner loop.
361    /// Criteria are reused across every outer iteration's inner run,
362    /// so they MUST be stateless across calls — `MaxIter`, the
363    /// `*Tolerance` family, and `MaxCostEvals` are safe;
364    /// [`MaxTime`](crate::core::termination::MaxTime) is **not**.
365    /// See AGENTS.md "Solver composition" rule 2.
366    pub fn inner_terminate_on<C>(self, criterion: C) -> Self
367    where
368        C: TerminationCriterion<I::State> + 'static,
369    {
370        let Self {
371            cma,
372            inner,
373            k,
374            c_y_override,
375        } = self;
376        Self {
377            cma,
378            inner: inner.terminate_on(criterion),
379            k,
380            c_y_override,
381        }
382    }
383}
384
385/// Hansen 2011 Table 1: `c_y = √n + 2n/(n+2)`, chosen so <10% of
386/// regular `y_i` would be clipped at typical `n` and <1% for `n > 10`.
387///
388/// `pub(crate)` so the sibling
389/// [`BoundedCmaInject`](crate::solver::BoundedCmaInject) can share
390/// this default without re-deriving it.
391pub(crate) fn default_c_y(n: usize) -> f64 {
392    let n = n as f64;
393    n.sqrt() + 2.0 * n / (n + 2.0)
394}
395
396impl<P, I, V, M> Solver<P, BasicPopulationState<V>> for CmaInject<I, V, M>
397where
398    P: CostFunction<Param = V, Output = f64>,
399    I: MemeticInner<V> + Solver<P, <I as WarmStart<V>>::State>,
400    I::State: State<Param = V, Float = f64>,
401    V: VectorLen
402        + Clone
403        + ScaledAdd<f64>
404        + ScaleInPlace
405        + ComponentMulAssign
406        + NormSquared
407        + SampleStandardNormal
408        + std::ops::Index<usize, Output = f64>
409        + std::ops::IndexMut<usize, Output = f64>,
410    M: MatrixIdentity
411        + MatrixFromDiagonal<V>
412        + MatVec<V>
413        + MatTransposeVec<V>
414        + ScaleInPlace
415        + RankOneUpdate<V>
416        + SymmetricEigen<V>
417        + Clone,
418{
419    fn init(&mut self, problem: &P, state: BasicPopulationState<V>) -> BasicPopulationState<V> {
420        // Hansen's preliminary experiments inject from iter 1 onward,
421        // so we delegate the initial population to vanilla CMA-ES.
422        self.cma.init(problem, state)
423    }
424
425    fn next_iter(
426        &mut self,
427        problem: &P,
428        state: BasicPopulationState<V>,
429    ) -> (BasicPopulationState<V>, Option<TerminationReason>) {
430        // 1. Vanilla CMA-ES iteration: update m, σ, C from the
431        //    previous generation, sample λ fresh candidates sorted by
432        //    cost ascending.
433        let (mut state, reason) = self.cma.next_iter(problem, state);
434        if let Some(r) = reason {
435            return (state, Some(r));
436        }
437
438        // Snapshot internals for clipping.
439        let (n, m, sigma) = {
440            let w = self
441                .cma
442                .working()
443                .expect("CmaEs::init must run before CmaInject::next_iter");
444            (w.n, w.m.clone(), w.sigma)
445        };
446        let c_y = self.c_y_override.unwrap_or_else(|| default_c_y(n));
447        let refine = self.k.min(state.candidates.len());
448
449        for i in 0..refine {
450            // 2. Seed the inner state via the trait. The σ argument
451            //    lets seeders that scale with the CMA distribution
452            //    (NM's σ-scaled simplex) track the current spread.
453            let inner_state = self.inner.solver().seed_scaled(&state.candidates[i], sigma);
454
455            // 3. Drive the inner.
456            let inner_result: OptimizationResult<I::State> = self.inner.run(problem, inner_state);
457
458            // 4. Eval aggregation: roll inner total-work into outer
459            //    cost_evals via the trait (AGENTS.md "Solver
460            //    composition" rule 1).
461            state.cost_evals += self.inner.solver().work_units(&inner_result.state);
462
463            // 5. Failure routing: bubble SolverFailed only (rule 3).
464            if inner_result.reason.is_failure() {
465                return (state, Some(inner_result.reason));
466            }
467
468            // 6. Extract refined point.
469            let x_refined = inner_result.state.param().clone();
470
471            // 7. y = (x_refined − m) / σ.
472            let mut y = x_refined;
473            y.scaled_add(-1.0, &m);
474            y.scale_in_place(1.0 / sigma);
475
476            // 8. ‖C^{-1/2} y‖ = ‖D^{-1} ⊙ Bᵀ y‖.
477            let inv_sqrt_norm = {
478                let w = self.cma.working().expect("working still populated");
479                let mut bt_y = w.b.mat_transpose_vec(&y);
480                bt_y.component_mul_assign(&w.d_inv);
481                bt_y.norm_squared().sqrt()
482            };
483
484            // 9. Clipping factor α (Hansen 2011 eq. 4 + eq. 10).
485            if inv_sqrt_norm > 0.0 {
486                let alpha = (c_y / inv_sqrt_norm).min(1.0);
487                if alpha < 1.0 {
488                    y.scale_in_place(alpha);
489                }
490            }
491
492            // 10. x_inj = m + σ · y_clipped.
493            let mut x_inj = m.clone();
494            x_inj.scaled_add(sigma, &y);
495
496            // 11. Re-evaluate: clipping moves the point in original
497            //     space, so the cost field has to match.
498            let cost_new = problem.cost(&x_inj);
499            state.cost_evals += 1;
500
501            state.candidates[i] = x_inj;
502            state.costs[i] = cost_new;
503        }
504
505        // 12. Re-sort: rank-µ update depends on the order.
506        if refine > 0 {
507            sort_population_ascending(&mut state.candidates, &mut state.costs);
508        }
509
510        (state, None)
511    }
512
513    fn terminate(&self, state: &BasicPopulationState<V>) -> Option<TerminationReason> {
514        <CmaEs<V, M> as Solver<P, BasicPopulationState<V>>>::terminate(&self.cma, state)
515    }
516}