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}