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}