Skip to main content

rlevo_evolution/
local_search.rs

1//! Host-side local-search refinement for memetic algorithms.
2//!
3//! A *memetic algorithm* (MA) interleaves a population-level evolutionary
4//! strategy with a per-individual **local search** that polishes promising
5//! genomes between generations. This module defines the local-search seam —
6//! the [`LocalSearch`] trait — together with the four reference searchers
7//! ([`HillClimbing`], [`NelderMead`], [`SimulatedAnnealing`],
8//! [`RandomRestart`]) that a `MemeticWrapper` can compose with any existing
9//! [`Strategy`](crate::strategy::Strategy).
10//!
11//! # Design seam
12//!
13//! Local search here is **host-side and gradient-free**. Searchers operate on
14//! a flat `Vec<f32>` genome and a single-member
15//! [`FitnessFn`], never touching device tensors or
16//! computing gradients. This keeps refinement trivially reproducible and
17//! independent of the Burn backend: it is pure host arithmetic plus calls to
18//! the supplied fitness function.
19//!
20//! # Stochasticity convention
21//!
22//! Every searcher takes a `&mut dyn Rng` and **all** randomness flows through
23//! it. Searchers never seed the process-wide backend RNG (`B::seed` +
24//! `Tensor::random`) and never reach for `rand::rng()` / thread-local RNGs —
25//! that would race the global Flex mutex under the parallel test runner and
26//! destroy reproducibility (see [`crate::rng`] for the host-RNG convention).
27//! A memetic wrapper derives a dedicated stream via
28//! `seed_stream(base, generation, SeedPurpose::LocalSearch)` and threads it in
29//! here.
30//!
31//! # Evaluation budget
32//!
33//! Each `refine` call is bounded by `Params::max_iters` **total**
34//! [`FitnessFn::evaluate_one`] calls
35//! (including the mandatory first re-evaluation of the input genome). The
36//! shared `BudgetedEval` helper enforces this so the bound holds structurally
37//! even on flat landscapes where no probe ever improves.
38//!
39//! [`refine_with_known_fitness`](LocalSearch::refine_with_known_fitness) skips
40//! that seeding eval when the caller already knows the input's fitness, so the
41//! same `max_iters` then buys one extra *probe* evaluation. The seeding eval
42//! consumes no rng, so skipping it never shifts a searcher's random stream.
43
44use burn::tensor::backend::Backend;
45use rand::Rng;
46
47use rlevo_core::bounds::Bounds;
48
49use crate::fitness::FitnessFn;
50// `sanitize_fitness` is the crate-wide fitness-hygiene primitive; it now lives in
51// [`crate::fitness`]. Re-exported here so the searchers below (and existing
52// `crate::fitness::sanitize_fitness` callers) keep resolving it unchanged.
53pub(crate) use crate::fitness::sanitize_fitness;
54
55pub mod hill_climbing;
56pub mod nelder_mead;
57pub mod random_restart;
58pub mod simulated_annealing;
59
60pub use hill_climbing::{HillClimbVariant, HillClimbing, HillClimbingParams};
61pub use nelder_mead::{NelderMead, NelderMeadParams};
62pub use random_restart::{RandomRestart, RandomRestartParams};
63pub use simulated_annealing::{CoolingSchedule, SimulatedAnnealing, SimulatedAnnealingParams};
64
65/// A gradient-free, host-side local search over real-valued genomes.
66///
67/// Implementors refine a single genome by repeatedly probing the supplied
68/// [`FitnessFn`] and returning the best point found, subject to a strict
69/// evaluation budget. They are the *meme* in a memetic algorithm: a
70/// `MemeticWrapper` invokes `refine` on selected population members between an
71/// inner strategy's `ask` and `tell`.
72///
73/// # Contract
74///
75/// For an input `genome` of length `D`, `refine` **must**:
76///
77/// 1. **Preserve dimensionality** — the returned `Vec<f32>` has length `D`.
78/// 2. **Return a fresh, honest fitness** — the returned `f32` is the actual
79///    value the supplied `fitness_fn` assigns to the returned genome (never a
80///    stale or estimated value). For a deterministic `fitness_fn` this is
81///    exact; for a stochastic one it is the value observed on the evaluation
82///    that produced the returned genome.
83/// 3. **Never worsen the input** (maximise, monotone non-worsening) — the
84///    returned fitness is `>=` the fitness of the *input* `genome` under the
85///    same `fitness_fn`. Implementors guarantee this structurally by
86///    evaluating the input genome first and tracking a best-so-far pair that is
87///    updated on *every* evaluation; the returned pair is always that tracked
88///    best.
89/// 4. **Terminate within budget** — make at most `Params::max_iters` total
90///    `evaluate_one` calls, even on a perfectly flat landscape where no probe
91///    ever improves.
92/// 5. **Respect bounds** — every coordinate of the returned genome lies within
93///    the `bounds` carried by `Params`.
94///
95/// Because the input genome is always evaluated once (contract item 3), a
96/// `max_iters` of `0` cannot be honored honestly. Reference searchers treat
97/// `max_iters == 0` as an invalid configuration and **panic**; implementors
98/// should do the same rather than fabricate a fitness value. This holds on the
99/// [`refine_with_known_fitness`](Self::refine_with_known_fitness) path too: the
100/// reference searchers keep the `max_iters >= 1` panic even though that path
101/// performs no seeding eval, so the two entry points share one budget contract.
102///
103/// All reference searchers route every evaluation — including the seeding eval
104/// of the input — through a shared budget helper that
105/// maps a `NaN` fitness to [`f32::NEG_INFINITY`], so a `NaN` probe can never seed or
106/// displace a finite best-so-far and thus never propagates to the returned
107/// fitness. The same rule applies to the `known_fitness` hint, which arrives
108/// from a path that does *not* flow through the budget helper: every reference
109/// override sanitizes the hint before seeding. Custom implementors that probe a
110/// `fitness_fn` directly — or seed from a hint — should apply the same
111/// sanitization rather than let a `NaN` reach their best-so-far tracker.
112///
113/// # Type parameters
114///
115/// - `B`: Burn backend. **Currently unused** by every reference searcher (they
116///   are pure host code) and present only to reserve the seam for future
117///   on-device searchers — e.g. a batched line search that materializes probe
118///   tensors directly. Keeping `B` on the trait now avoids a breaking signature
119///   change when such a searcher lands.
120///
121/// # Example
122///
123/// A one-line searcher that simply re-evaluates the input (the trivial,
124/// always-valid refinement) illustrates the contract:
125///
126/// ```
127/// use burn::backend::Flex;
128/// use rand::{rngs::StdRng, Rng, SeedableRng};
129/// use rlevo_evolution::fitness::FitnessFn;
130/// use rlevo_evolution::local_search::LocalSearch;
131///
132/// struct Identity;
133/// impl<B: burn::tensor::backend::Backend> LocalSearch<B> for Identity {
134///     type Params = ();
135///     fn refine(
136///         &self,
137///         _params: &(),
138///         genome: Vec<f32>,
139///         fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
140///         _rng: &mut dyn Rng,
141///     ) -> (Vec<f32>, f32) {
142///         let f = fitness_fn.evaluate_one(&genome); // fresh fitness
143///         (genome, f)                               // same length, no worsening
144///     }
145/// }
146///
147/// struct Sphere;
148/// impl FitnessFn<Vec<f32>> for Sphere {
149///     fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
150///         x.iter().map(|v| v * v).sum()
151///     }
152/// }
153///
154/// let searcher = Identity;
155/// let mut fitness = Sphere;
156/// let mut rng = StdRng::seed_from_u64(0);
157/// let (refined, fit) = LocalSearch::<Flex>::refine(
158///     &searcher,
159///     &(),
160///     vec![3.0, 4.0],
161///     &mut fitness,
162///     &mut rng,
163/// );
164/// assert_eq!(refined.len(), 2);
165/// assert_eq!(fit, 25.0);
166/// ```
167pub trait LocalSearch<B: Backend>: Send + Sync {
168    /// Static configuration for a refinement run (bounds, budget, step
169    /// sizes, …). Cloned by a memetic wrapper once per generation.
170    type Params: Clone + core::fmt::Debug + Send + Sync;
171
172    /// Refines `genome` and returns `(refined_genome, refined_fitness)`.
173    ///
174    /// See the [trait-level contract](LocalSearch#contract) for the full set
175    /// of invariants every implementation must uphold.
176    fn refine(
177        &self,
178        params: &Self::Params,
179        genome: Vec<f32>,
180        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
181        rng: &mut dyn Rng,
182    ) -> (Vec<f32>, f32);
183
184    /// Refines `genome`, seeding the best-so-far tracker with `known_fitness`
185    /// instead of re-evaluating the input — saving exactly one
186    /// [`FitnessFn::evaluate_one`] call per refinement.
187    ///
188    /// `known_fitness` **must** be the value `fitness_fn` assigns to `genome`
189    /// (for a stochastic `fitness_fn`, a value it plausibly assigned). A `NaN`
190    /// hint is sanitized to [`f32::NEG_INFINITY`] by the reference overrides, exactly
191    /// as a `NaN` probe would be (see the [contract](LocalSearch#contract)). All
192    /// other invariants — dimensionality, monotone non-worsening, budget, bounds
193    /// — are identical to [`refine`](Self::refine); the only difference is that
194    /// the seeding eval is elided, so a given `max_iters` buys one extra probe.
195    ///
196    /// Because the seeding eval consumes no rng, this method draws from the
197    /// supplied `rng` exactly as [`refine`](Self::refine) would, leaving
198    /// same-seed determinism intact.
199    ///
200    /// The default **ignores the hint** and delegates to
201    /// [`refine`](Self::refine), preserving current behavior (and the seeding
202    /// eval) for any implementor that does not override it.
203    fn refine_with_known_fitness(
204        &self,
205        params: &Self::Params,
206        genome: Vec<f32>,
207        known_fitness: f32,
208        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
209        rng: &mut dyn Rng,
210    ) -> (Vec<f32>, f32) {
211        let _ = known_fitness;
212        self.refine(params, genome, fitness_fn, rng)
213    }
214}
215
216/// A fitness function wrapped with a hard evaluation budget.
217///
218/// `BudgetedEval` decrements its `remaining` counter on every successful
219/// [`eval`](Self::eval) call and refuses further evaluations once the budget
220/// is exhausted. Searchers route *all* their `evaluate_one` calls through it so
221/// the [`LocalSearch`] budget invariant holds structurally rather than relying
222/// on each searcher's loop bound being correct.
223pub(crate) struct BudgetedEval<'a> {
224    /// The underlying single-member fitness function.
225    inner: &'a mut dyn FitnessFn<Vec<f32>>,
226    /// Remaining permitted `evaluate_one` calls.
227    remaining: usize,
228}
229
230impl<'a> BudgetedEval<'a> {
231    /// Wraps `inner` with a budget of `max_evals` total evaluations.
232    pub(crate) fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>, max_evals: usize) -> Self {
233        Self {
234            inner,
235            remaining: max_evals,
236        }
237    }
238
239    /// Evaluates `genome`, consuming one unit of budget.
240    ///
241    /// Returns `Some(fitness)` while budget remains, or `None` once the budget
242    /// is exhausted (in which case no underlying evaluation is performed).
243    ///
244    /// A `NaN` fitness is sanitized to [`f32::NEG_INFINITY`] via [`sanitize_fitness`]
245    /// before it leaves this method. Because every searcher routes *all*
246    /// evaluations — including the mandatory seeding eval of the input genome
247    /// (contract item 3) — through `BudgetedEval`, this is the single chokepoint
248    /// that keeps `NaN` out of the best-so-far trackers on the probe path; the
249    /// `refine_with_known_fitness` overrides apply the same helper to the
250    /// `known_fitness` hint. If *every* probe is `NaN` the searcher honestly
251    /// returns `−inf` rather than `NaN`.
252    pub(crate) fn eval(&mut self, genome: &Vec<f32>) -> Option<f32> {
253        if self.remaining == 0 {
254            return None;
255        }
256        self.remaining -= 1;
257        Some(sanitize_fitness(self.inner.evaluate_one(genome)))
258    }
259
260    /// Number of evaluations still permitted.
261    pub(crate) fn remaining(&self) -> usize {
262        self.remaining
263    }
264}
265
266/// Clamps every coordinate of `genome` into the inclusive range `bounds`.
267///
268/// `bounds` is `(lo, hi)`; coordinates below `lo` are raised to `lo` and those
269/// above `hi` are lowered to `hi`. Uses `x.max(lo).min(hi)`, so a degenerate
270/// range where `lo > hi` collapses every coordinate to `hi` — the `min` is
271/// applied last and wins. Callers are expected to supply valid bounds.
272pub(crate) fn clamp_vec(genome: &mut [f32], bounds: Bounds) {
273    let (lo, hi): (f32, f32) = bounds.into();
274    for x in genome.iter_mut() {
275        *x = x.max(lo).min(hi);
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// Counts the genome length each `evaluate_one` sees; returns the sum of
284    /// squares (sphere). Used to exercise the shared helpers in isolation.
285    struct Sphere;
286    impl FitnessFn<Vec<f32>> for Sphere {
287        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
288            x.iter().map(|v| v * v).sum()
289        }
290    }
291
292    #[test]
293    fn budgeted_eval_decrements_and_exhausts() {
294        let mut sphere = Sphere;
295        let mut budget = BudgetedEval::new(&mut sphere, 2);
296        assert_eq!(budget.remaining(), 2);
297        assert_eq!(budget.eval(&vec![1.0, 0.0]), Some(1.0));
298        assert_eq!(budget.remaining(), 1);
299        assert_eq!(budget.eval(&vec![3.0, 4.0]), Some(25.0));
300        assert_eq!(budget.remaining(), 0);
301        // Budget exhausted: no further evaluation, returns None.
302        assert_eq!(budget.eval(&vec![0.0, 0.0]), None);
303    }
304
305    /// Returns `NaN` for the origin, sphere otherwise — exercises the
306    /// `BudgetedEval` NaN sanitization at the single evaluation chokepoint.
307    struct NanAtOrigin;
308    impl FitnessFn<Vec<f32>> for NanAtOrigin {
309        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
310            let s: f32 = x.iter().map(|v| v * v).sum();
311            if s == 0.0 { f32::NAN } else { s }
312        }
313    }
314
315    #[test]
316    fn budgeted_eval_sanitizes_nan_to_neg_infinity() {
317        let mut f = NanAtOrigin;
318        let mut budget = BudgetedEval::new(&mut f, 2);
319        // A NaN probe is mapped to −inf (the worst value under the maximise
320        // convention), so it can never seed or displace a finite best-so-far.
321        assert_eq!(budget.eval(&vec![0.0, 0.0]), Some(f32::NEG_INFINITY));
322        // A finite probe is passed through unchanged.
323        assert_eq!(budget.eval(&vec![3.0, 4.0]), Some(25.0));
324    }
325
326    #[test]
327    fn clamp_vec_respects_bounds() {
328        let mut g = vec![-10.0_f32, 0.5, 10.0, -0.5];
329        clamp_vec(&mut g, Bounds::new(-1.0, 1.0));
330        assert_eq!(g, vec![-1.0, 0.5, 1.0, -0.5]);
331    }
332}