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}