Skip to main content

rlevo_evolution/local_search/
simulated_annealing.rs

1//! Simulated annealing.
2//!
3//! Simulated annealing performs a stochastic walk that accepts worsening moves
4//! with probability `exp(Δf / T)`, cooling the temperature `T` over time so
5//! the walk gradually concentrates on improving moves. This lets it escape
6//! shallow local maxima that strict hill climbing gets stuck in, at the cost of
7//! being a coarse explorer rather than a precision finisher.
8//!
9//! Each iteration proposes a neighbour by adding per-coordinate Gaussian noise
10//! `N(0, step_size)` to the current walker position (sampled through the
11//! supplied `rng`), clamps it to bounds, and evaluates it. A non-worsening move
12//! is always accepted; a worsening move (`Δf = cand_fit - current_fit < 0`) is
13//! accepted iff a uniform draw `rng.random::<f32>()` falls below `exp(Δf / T)`.
14//! The temperature is cooled once per iteration via the configured
15//! [`CoolingSchedule`], and the walk early-stops once `T < min_temp`.
16//!
17//! The returned pair is always the best `(genome, fitness)` observed across all
18//! evaluations — **not** the final walker position, which may sit at an uphill
19//! point it accepted. Tracking the global best on every evaluation is what makes
20//! the [`LocalSearch`] monotone-non-worsening
21//! and fresh-fitness invariants hold structurally despite downhill acceptance.
22
23use burn::tensor::backend::Backend;
24use rand::{Rng, RngExt};
25
26use crate::fitness::FitnessFn;
27use crate::local_search::{BudgetedEval, LocalSearch, clamp_vec, sanitize_fitness};
28use rlevo_core::bounds::Bounds;
29
30/// Temperature-cooling schedule for [`SimulatedAnnealing`].
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum CoolingSchedule {
33    /// Geometric cooling: `T <- T * factor` each step. `factor` in `(0, 1)`.
34    Geometric {
35        /// Multiplicative cooling factor per step.
36        factor: f32,
37    },
38    /// Linear cooling: `T <- T - delta` each step (floored at `0`).
39    Linear {
40        /// Temperature decrement per step.
41        delta: f32,
42    },
43}
44
45/// Static configuration for a [`SimulatedAnnealing`] run.
46///
47/// Fields are private: start from [`default_for`](SimulatedAnnealingParams::default_for)
48/// and override with the fluent `with_*` setters, which reject out-of-domain
49/// values at the call site rather than letting an invalid config reach `refine`.
50#[derive(Debug, Clone)]
51pub struct SimulatedAnnealingParams {
52    /// Inclusive search-space bounds `(lo, hi)`; proposals are clamped here.
53    bounds: Bounds,
54    /// Hard cap on the **total** number of `evaluate_one` calls per `refine`,
55    /// including the initial evaluation of the input genome. Default `200`.
56    max_iters: usize,
57    /// Starting temperature. Default `1.0`.
58    initial_temp: f32,
59    /// Cooling schedule. Default `Geometric { factor: 0.95 }`.
60    cooling: CoolingSchedule,
61    /// Early-stop temperature floor — the walk stops once `T < min_temp`.
62    /// Default `1e-6`.
63    min_temp: f32,
64    /// Standard deviation of the Gaussian proposal step. Default
65    /// `0.1 * (hi - lo)`.
66    step_size: f32,
67}
68
69impl SimulatedAnnealingParams {
70    /// Default parameters derived from the search-space `bounds`.
71    ///
72    /// `initial_temp = 1.0`, `cooling = Geometric { factor: 0.95 }`,
73    /// `min_temp = 1e-6`, `step_size = 0.1 * (hi - lo)`, `max_iters = 200`.
74    #[must_use]
75    pub fn default_for(bounds: Bounds) -> Self {
76        let (lo, hi): (f32, f32) = bounds.into();
77        debug_assert!(
78            (hi - lo) > 0.0,
79            "SimulatedAnnealingParams::default_for: zero-width bounds yields step_size 0 (search cannot move)"
80        );
81        Self {
82            bounds,
83            max_iters: 200,
84            initial_temp: 1.0,
85            cooling: CoolingSchedule::Geometric { factor: 0.95 },
86            min_temp: 1e-6,
87            step_size: 0.1 * (hi - lo),
88        }
89    }
90
91    /// Overrides the search-space bounds.
92    #[must_use]
93    pub fn with_bounds(mut self, bounds: Bounds) -> Self {
94        self.bounds = bounds;
95        self
96    }
97
98    /// Overrides the total evaluation budget per `refine`.
99    ///
100    /// # Panics
101    ///
102    /// Panics if `max_iters == 0` — a `refine` must evaluate the input at
103    /// least once.
104    #[must_use]
105    pub fn with_max_iters(mut self, max_iters: usize) -> Self {
106        assert!(
107            max_iters >= 1,
108            "SimulatedAnnealingParams::with_max_iters: max_iters must be >= 1"
109        );
110        self.max_iters = max_iters;
111        self
112    }
113
114    /// Overrides the starting temperature.
115    ///
116    /// # Panics
117    ///
118    /// Panics if `initial_temp` is not strictly positive and finite.
119    #[must_use]
120    pub fn with_initial_temp(mut self, initial_temp: f32) -> Self {
121        assert!(
122            initial_temp.is_finite() && initial_temp > 0.0,
123            "SimulatedAnnealingParams::with_initial_temp: initial_temp must be finite and > 0"
124        );
125        self.initial_temp = initial_temp;
126        self
127    }
128
129    /// Overrides the cooling schedule.
130    ///
131    /// # Panics
132    ///
133    /// Panics if the schedule's parameter is out of domain: a
134    /// [`Geometric`](CoolingSchedule::Geometric) `factor` outside `(0, 1)`, or
135    /// a [`Linear`](CoolingSchedule::Linear) `delta` that is not strictly
136    /// positive and finite.
137    #[must_use]
138    pub fn with_cooling(mut self, cooling: CoolingSchedule) -> Self {
139        match cooling {
140            CoolingSchedule::Geometric { factor } => assert!(
141                factor.is_finite() && factor > 0.0 && factor < 1.0,
142                "SimulatedAnnealingParams::with_cooling: geometric factor must be in (0, 1)"
143            ),
144            CoolingSchedule::Linear { delta } => assert!(
145                delta.is_finite() && delta > 0.0,
146                "SimulatedAnnealingParams::with_cooling: linear delta must be finite and > 0"
147            ),
148        }
149        self.cooling = cooling;
150        self
151    }
152
153    /// Overrides the early-stop temperature floor.
154    ///
155    /// # Panics
156    ///
157    /// Panics if `min_temp` is negative or non-finite.
158    #[must_use]
159    pub fn with_min_temp(mut self, min_temp: f32) -> Self {
160        assert!(
161            min_temp.is_finite() && min_temp >= 0.0,
162            "SimulatedAnnealingParams::with_min_temp: min_temp must be finite and >= 0"
163        );
164        self.min_temp = min_temp;
165        self
166    }
167
168    /// Overrides the Gaussian proposal step size.
169    ///
170    /// # Panics
171    ///
172    /// Panics if `step_size` is not strictly positive and finite.
173    #[must_use]
174    pub fn with_step_size(mut self, step_size: f32) -> Self {
175        assert!(
176            step_size.is_finite() && step_size > 0.0,
177            "SimulatedAnnealingParams::with_step_size: step_size must be finite and > 0"
178        );
179        self.step_size = step_size;
180        self
181    }
182
183    /// Inclusive search-space bounds.
184    #[must_use]
185    pub fn bounds(&self) -> Bounds {
186        self.bounds
187    }
188
189    /// Total evaluation budget per `refine`.
190    #[must_use]
191    pub fn max_iters(&self) -> usize {
192        self.max_iters
193    }
194
195    /// Starting temperature.
196    #[must_use]
197    pub fn initial_temp(&self) -> f32 {
198        self.initial_temp
199    }
200
201    /// Cooling schedule.
202    #[must_use]
203    pub fn cooling(&self) -> CoolingSchedule {
204        self.cooling
205    }
206
207    /// Early-stop temperature floor.
208    #[must_use]
209    pub fn min_temp(&self) -> f32 {
210        self.min_temp
211    }
212
213    /// Gaussian proposal step size.
214    #[must_use]
215    pub fn step_size(&self) -> f32 {
216        self.step_size
217    }
218}
219
220/// Simulated-annealing local search.
221///
222/// A unit struct: all configuration lives in [`SimulatedAnnealingParams`], so
223/// one instance can refine many genomes. See the [module docs](self) for the
224/// acceptance rule and the best-so-far tracking that upholds the
225/// [`LocalSearch`] contract.
226///
227/// # Example
228///
229/// ```
230/// use burn::backend::Flex;
231/// use rand::{rngs::StdRng, SeedableRng};
232/// use rlevo_evolution::fitness::FitnessFn;
233/// use rlevo_core::bounds::Bounds;
234/// use rlevo_evolution::local_search::{LocalSearch, SimulatedAnnealing, SimulatedAnnealingParams};
235///
236/// // Maximize the negated 2-D sphere; the optimum is the origin with fitness 0.
237/// struct NegSphere;
238/// impl FitnessFn<Vec<f32>> for NegSphere {
239///     fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
240///         -x.iter().map(|v| v * v).sum::<f32>()
241///     }
242/// }
243///
244/// let searcher = SimulatedAnnealing;
245/// let params = SimulatedAnnealingParams::default_for(Bounds::new(-5.12, 5.12));
246/// let mut fitness = NegSphere;
247/// let mut rng = StdRng::seed_from_u64(7);
248///
249/// let start = vec![2.5_f32, -1.5];
250/// let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
251/// let (refined, refined_fit) =
252///     LocalSearch::<Flex>::refine(&searcher, &params, start, &mut fitness, &mut rng);
253///
254/// assert_eq!(refined.len(), 2);          // dimensionality preserved
255/// assert!(refined_fit >= start_fit);     // monotone non-worsening
256/// ```
257#[derive(Debug, Clone, Copy, Default)]
258pub struct SimulatedAnnealing;
259
260impl SimulatedAnnealing {
261    /// Shared body for [`refine`](LocalSearch::refine) and
262    /// [`refine_with_known_fitness`](LocalSearch::refine_with_known_fitness).
263    ///
264    /// `known` is the input genome's fitness when the caller already holds it
265    /// (the hint path) or `None` when the input must be re-evaluated to seed the
266    /// walker and the best-so-far tracker. The seed is sanitized either way so a
267    /// `NaN` never poisons the tracked best.
268    ///
269    /// # Panics
270    ///
271    /// Panics if `params.max_iters == 0`: a zero evaluation budget makes it
272    /// impossible to return an honestly evaluated fitness, so it is treated as
273    /// an invalid configuration (programming error), not runtime data.
274    fn refine_impl(
275        params: &SimulatedAnnealingParams,
276        genome: Vec<f32>,
277        known: Option<f32>,
278        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
279        rng: &mut dyn Rng,
280    ) -> (Vec<f32>, f32) {
281        assert!(
282            params.max_iters >= 1,
283            "SimulatedAnnealingParams::max_iters must be >= 1 (the input genome \
284             is always evaluated once to seed the best-so-far tracker)"
285        );
286        let mut budget: BudgetedEval = BudgetedEval::new(fitness_fn, params.max_iters);
287
288        // First action: seed both the walker and the best-so-far tracker. With a
289        // known fitness we reuse it (sanitizing NaN); otherwise we spend one eval
290        // scoring the input. The assert above guarantees the eval path succeeds.
291        let initial_fit: f32 = if let Some(f) = known {
292            sanitize_fitness(f)
293        } else {
294            let Some(f) = budget.eval(&genome) else {
295                unreachable!("budget of >= 1 cannot be exhausted before the first eval");
296            };
297            f
298        };
299
300        // The walker — may drift uphill when an uphill move is accepted.
301        let mut current: Vec<f32> = genome;
302        let mut current_fit: f32 = initial_fit;
303        // The tracked best — always returned. Updated on EVERY evaluation, so
304        // the monotone + fresh-fitness invariants hold structurally regardless
305        // of how far uphill the walker wanders.
306        let mut best: Vec<f32> = current.clone();
307        let mut best_fit: f32 = current_fit;
308
309        let dim: usize = current.len();
310        if dim == 0 {
311            return (best, best_fit);
312        }
313
314        // Unit Gaussian sampled through the passed rng via
315        // `sampling::standard_normal` (the same path as the crate's
316        // `gaussian_mutation`); scaled by `step_size` per coordinate to realise
317        // an `N(0, step_size)` proposal step.
318        let mut temp: f32 = params.initial_temp;
319
320        loop {
321            // Propose: current walker + per-coordinate Gaussian noise.
322            let mut candidate: Vec<f32> = current.clone();
323            for x in &mut candidate {
324                *x += params.step_size * crate::sampling::standard_normal(rng);
325            }
326            clamp_vec(&mut candidate, params.bounds);
327
328            // Evaluate the proposal (stop if the budget is exhausted).
329            let Some(cand_fit) = budget.eval(&candidate) else {
330                break;
331            };
332
333            // Track the global best on every evaluation.
334            if cand_fit > best_fit {
335                best_fit = cand_fit;
336                best.clone_from(&candidate);
337            }
338
339            // Metropolis acceptance: always take a non-worsening move (`delta >=
340            // 0`); take a worsening move of size `delta` with probability
341            // `exp(delta / T)` (a negative exponent → probability < 1). Both the
342            // comparison and the downhill draw flow through the passed rng so all
343            // stochasticity is reproducible.
344            let delta: f32 = cand_fit - current_fit;
345            let accept: bool = delta >= 0.0 || rng.random::<f32>() < (delta / temp).exp();
346            if accept {
347                current = candidate;
348                current_fit = cand_fit;
349            }
350
351            // Cool once per iteration, then early-stop below the floor.
352            match params.cooling {
353                CoolingSchedule::Geometric { factor } => temp *= factor,
354                CoolingSchedule::Linear { delta } => temp = (temp - delta).max(0.0),
355            }
356            if temp < params.min_temp {
357                break;
358            }
359        }
360
361        (best, best_fit)
362    }
363}
364
365impl<B: Backend> LocalSearch<B> for SimulatedAnnealing {
366    type Params = SimulatedAnnealingParams;
367
368    /// # Panics
369    ///
370    /// Panics if `params.max_iters == 0`; see `refine_impl`.
371    fn refine(
372        &self,
373        params: &SimulatedAnnealingParams,
374        genome: Vec<f32>,
375        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
376        rng: &mut dyn Rng,
377    ) -> (Vec<f32>, f32) {
378        Self::refine_impl(params, genome, None, fitness_fn, rng)
379    }
380
381    /// Seeds the walker and best-so-far tracker with `known_fitness` (sanitizing
382    /// `NaN` to `-inf`) instead of re-scoring the input, saving one eval.
383    ///
384    /// # Panics
385    ///
386    /// Panics if `params.max_iters == 0`; see `refine_impl`.
387    fn refine_with_known_fitness(
388        &self,
389        params: &SimulatedAnnealingParams,
390        genome: Vec<f32>,
391        known_fitness: f32,
392        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
393        rng: &mut dyn Rng,
394    ) -> (Vec<f32>, f32) {
395        Self::refine_impl(params, genome, Some(known_fitness), fitness_fn, rng)
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use burn::backend::Flex;
403    use rand::SeedableRng;
404    use rand::rngs::StdRng;
405
406    type TestBackend = Flex;
407
408    #[test]
409    fn with_setters_override_defaults() {
410        let sa = SimulatedAnnealingParams::default_for(Bounds::new(-2.0, 2.0))
411            .with_max_iters(50)
412            .with_initial_temp(3.0)
413            .with_cooling(CoolingSchedule::Linear { delta: 0.1 })
414            .with_min_temp(0.01)
415            .with_step_size(0.5);
416        assert_eq!(sa.max_iters(), 50);
417        assert!((sa.initial_temp() - 3.0).abs() < 1e-6);
418        assert_eq!(sa.cooling(), CoolingSchedule::Linear { delta: 0.1 });
419        assert!((sa.min_temp() - 0.01).abs() < 1e-6);
420        assert!((sa.step_size() - 0.5).abs() < 1e-6);
421    }
422
423    #[test]
424    #[should_panic(expected = "geometric factor must be in (0, 1)")]
425    fn with_cooling_rejects_out_of_range_geometric_factor() {
426        let _ = SimulatedAnnealingParams::default_for(Bounds::new(-2.0, 2.0))
427            .with_cooling(CoolingSchedule::Geometric { factor: 1.5 });
428    }
429
430    /// Negated sphere `f(x) = -Σ x_i²` — concave bump; global maximum 0 at the
431    /// origin.
432    struct NegSphere;
433    impl FitnessFn<Vec<f32>> for NegSphere {
434        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
435            -x.iter().map(|v| v * v).sum::<f32>()
436        }
437    }
438
439    /// Negated 2-D Rosenbrock — curved ridge; global maximum 0 at `(1, 1)`.
440    struct NegRosenbrock;
441    impl FitnessFn<Vec<f32>> for NegRosenbrock {
442        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
443            let a = 1.0 - x[0];
444            let b = x[1] - x[0] * x[0];
445            -(a * a + 100.0 * b * b)
446        }
447    }
448
449    /// Constant 1.0 — perfectly flat; no probe ever improves.
450    struct Flat;
451    impl FitnessFn<Vec<f32>> for Flat {
452        fn evaluate_one(&mut self, _x: &Vec<f32>) -> f32 {
453            1.0
454        }
455    }
456
457    /// Wraps a fitness function and counts `evaluate_one` calls.
458    struct Counting<'a> {
459        inner: &'a mut dyn FitnessFn<Vec<f32>>,
460        calls: usize,
461    }
462    impl<'a> Counting<'a> {
463        fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
464            Self { inner, calls: 0 }
465        }
466    }
467    impl FitnessFn<Vec<f32>> for Counting<'_> {
468        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
469            self.calls += 1;
470            self.inner.evaluate_one(x)
471        }
472    }
473
474    /// Records, for every evaluation, whether it was strictly worse than the
475    /// previous evaluation's fitness. Lets a test observe uphill *proposals*
476    /// reaching the acceptance test; combined with a tiny step it pins that
477    /// the walker actually accepts some of them at high temperature.
478    struct Recording<'a> {
479        inner: &'a mut dyn FitnessFn<Vec<f32>>,
480        fitnesses: Vec<f32>,
481    }
482    impl<'a> Recording<'a> {
483        fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
484            Self {
485                inner,
486                fitnesses: Vec::new(),
487            }
488        }
489    }
490    impl FitnessFn<Vec<f32>> for Recording<'_> {
491        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
492            let f = self.inner.evaluate_one(x);
493            self.fitnesses.push(f);
494            f
495        }
496    }
497
498    const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
499
500    fn random_start(rng: &mut StdRng, dim: usize, bounds: Bounds) -> Vec<f32> {
501        let (lo, hi): (f32, f32) = bounds.into();
502        (0..dim)
503            .map(|_| lo + (hi - lo) * rng.random::<f32>())
504            .collect()
505    }
506
507    #[test]
508    fn sphere_d2_improves_substantially() {
509        let searcher = SimulatedAnnealing;
510        let params = SimulatedAnnealingParams::default_for(BOUNDS);
511        let mut fitness = NegSphere;
512        let mut rng = StdRng::seed_from_u64(1);
513        let start = random_start(&mut rng, 2, BOUNDS);
514        let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
515        let (_g, fit) =
516            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
517        // SA is a coarse explorer, not a precision finisher: assert a large
518        // relative improvement, not 1e-6 convergence. `start_fit` is negative
519        // (maximum is 0), so closing the gap means rising above `0.1 * start_fit`.
520        assert!(
521            fit > 0.1 * start_fit,
522            "sphere D=2 should improve substantially: best={fit}, start={start_fit}"
523        );
524    }
525
526    #[test]
527    fn sphere_d10_strictly_improves() {
528        let searcher = SimulatedAnnealing;
529        let params = SimulatedAnnealingParams::default_for(BOUNDS);
530        let mut fitness = NegSphere;
531        let mut rng = StdRng::seed_from_u64(2);
532        let start = random_start(&mut rng, 10, BOUNDS);
533        let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
534        let (_g, fit) =
535            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
536        assert!(fit > start_fit, "expected improvement: {fit} > {start_fit}");
537    }
538
539    #[test]
540    fn output_len_equals_input_len() {
541        let searcher = SimulatedAnnealing;
542        let params = SimulatedAnnealingParams::default_for(BOUNDS);
543        let mut fitness = NegSphere;
544        let mut rng = StdRng::seed_from_u64(3);
545        for dim in [1_usize, 2, 5, 10] {
546            let start = random_start(&mut rng, dim, BOUNDS);
547            let (g, _f) = LocalSearch::<TestBackend>::refine(
548                &searcher,
549                &params,
550                start,
551                &mut fitness,
552                &mut rng,
553            );
554            assert_eq!(g.len(), dim);
555        }
556    }
557
558    #[test]
559    fn returned_fitness_matches_fresh_eval() {
560        let searcher = SimulatedAnnealing;
561        let params = SimulatedAnnealingParams::default_for(BOUNDS);
562        let mut fitness = NegSphere;
563        let mut rng = StdRng::seed_from_u64(4);
564        let start = random_start(&mut rng, 4, BOUNDS);
565        let (g, fit) =
566            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
567        let fresh = fitness.evaluate_one(&g);
568        approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
569    }
570
571    #[test]
572    fn rosenbrock_monotone_non_worsening() {
573        // Pins that the tracked best (not the downhill-drifting walker) is what
574        // gets returned: despite accepting worsening moves, `f_out >= f_in`.
575        let searcher = SimulatedAnnealing;
576        let params = SimulatedAnnealingParams::default_for(BOUNDS);
577        let mut rng = StdRng::seed_from_u64(5);
578        for _ in 0..6 {
579            let start = random_start(&mut rng, 2, BOUNDS);
580            let mut fitness = NegRosenbrock;
581            let start_fit = fitness.evaluate_one(&start);
582            let (_g, fit) = LocalSearch::<TestBackend>::refine(
583                &searcher,
584                &params,
585                start,
586                &mut fitness,
587                &mut rng,
588            );
589            assert!(fit >= start_fit, "monotone: {fit} >= {start_fit}");
590        }
591    }
592
593    #[test]
594    #[allow(clippy::float_cmp)]
595    fn flat_landscape_terminates_within_budget() {
596        let searcher = SimulatedAnnealing;
597        let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
598        params.max_iters = 37;
599        let mut base = Flat;
600        let mut counting = Counting::new(&mut base);
601        let mut rng = StdRng::seed_from_u64(6);
602        let start = vec![1.0_f32, 2.0, 3.0];
603        let (g, fit) = LocalSearch::<TestBackend>::refine(
604            &searcher,
605            &params,
606            start.clone(),
607            &mut counting,
608            &mut rng,
609        );
610        assert!(
611            counting.calls <= params.max_iters,
612            "evals {} must not exceed budget {}",
613            counting.calls,
614            params.max_iters
615        );
616        // On a flat landscape nothing improves: the returned genome is the
617        // input and its fitness is the honest constant 1.0.
618        assert_eq!(g, start);
619        assert_eq!(fit, 1.0);
620    }
621
622    #[test]
623    #[allow(clippy::float_cmp)]
624    fn same_seed_bit_identical_different_seed_differs() {
625        let searcher = SimulatedAnnealing;
626        let params = SimulatedAnnealingParams::default_for(BOUNDS);
627        let start = vec![2.0_f32, -3.0, 1.5];
628
629        let mut fitness_a = NegSphere;
630        let mut rng_a = StdRng::seed_from_u64(123);
631        let (g_a, f_a) = LocalSearch::<TestBackend>::refine(
632            &searcher,
633            &params,
634            start.clone(),
635            &mut fitness_a,
636            &mut rng_a,
637        );
638
639        let mut fitness_b = NegSphere;
640        let mut rng_b = StdRng::seed_from_u64(123);
641        let (g_b, f_b) = LocalSearch::<TestBackend>::refine(
642            &searcher,
643            &params,
644            start.clone(),
645            &mut fitness_b,
646            &mut rng_b,
647        );
648
649        // Same seed ⇒ bit-identical genome AND fitness: all stochasticity flows
650        // through the passed rng.
651        assert_eq!(g_a, g_b);
652        assert_eq!(f_a, f_b);
653
654        let mut fitness_c = NegSphere;
655        let mut rng_c = StdRng::seed_from_u64(999);
656        let (g_c, _f_c) = LocalSearch::<TestBackend>::refine(
657            &searcher,
658            &params,
659            start,
660            &mut fitness_c,
661            &mut rng_c,
662        );
663        // Different seed ⇒ (almost surely) a different trajectory and output.
664        assert_ne!(g_a, g_c);
665    }
666
667    #[test]
668    fn min_temp_early_stop_below_budget() {
669        // A tiny initial temperature plus aggressive geometric cooling drops
670        // below `min_temp` long before the evaluation budget is spent.
671        let searcher = SimulatedAnnealing;
672        let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
673        params.max_iters = 1000;
674        params.initial_temp = 1e-3;
675        params.min_temp = 1e-1;
676        params.cooling = CoolingSchedule::Geometric { factor: 0.5 };
677        let mut base = NegSphere;
678        let mut counting = Counting::new(&mut base);
679        let mut rng = StdRng::seed_from_u64(7);
680        let start = vec![1.0_f32, -1.0];
681        let _ =
682            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut counting, &mut rng);
683        assert!(
684            counting.calls < params.max_iters,
685            "min_temp early stop: evals {} should be < budget {}",
686            counting.calls,
687            params.max_iters
688        );
689    }
690
691    #[test]
692    fn boundary_start_stays_within_bounds() {
693        let searcher = SimulatedAnnealing;
694        let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
695        // Large step relative to range pushes proposals hard against bounds.
696        params.step_size = 4.0;
697        let mut fitness = NegSphere;
698        let mut rng = StdRng::seed_from_u64(8);
699        // Start at the upper boundary in every coordinate.
700        let start = vec![BOUNDS.hi(); 4];
701        let (g, _f) =
702            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
703        for &x in &g {
704            assert!(
705                x >= BOUNDS.lo() && x <= BOUNDS.hi(),
706                "coord {x} out of bounds {BOUNDS:?}"
707            );
708        }
709    }
710
711    #[test]
712    fn uphill_moves_accepted_at_high_temperature() {
713        // With a huge initial temperature, exp(Δf / T) ≈ 1, so the walker
714        // should accept worsening moves. We detect acceptance indirectly: the
715        // returned best is the tracked maximum, but if NO worsening move were
716        // ever accepted the walker would behave like a pure ascent and the
717        // recorded evaluation sequence would be (weakly) monotone after the
718        // first improvement. Instead we assert the walker visits a fitness
719        // strictly worse than the running maximum more than once — only possible
720        // if an earlier worsening move was accepted, moving the walker downhill
721        // so its next proposal is centred on a worse point.
722        let searcher = SimulatedAnnealing;
723        let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
724        params.max_iters = 200;
725        params.initial_temp = 1e9;
726        params.min_temp = 1e-9;
727        params.step_size = 0.5;
728        let mut base = NegSphere;
729        let mut recording = Recording::new(&mut base);
730        let mut rng = StdRng::seed_from_u64(11);
731        let start = vec![0.05_f32, -0.05];
732        let _ =
733            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut recording, &mut rng);
734
735        // Count evaluations that were strictly worse than the best-so-far at the
736        // time they were seen. A pure greedy ascent (no worsening acceptance) can
737        // produce such evaluations too — but with a near-origin start and a
738        // large step on the negated sphere, sustained worse-than-best proposals
739        // are only explored because accepted worsening moves keep re-centring the
740        // walker on worse points. Require several to make the assertion robust.
741        let mut running_best = f32::NEG_INFINITY;
742        let mut worse_than_best = 0_usize;
743        for &f in &recording.fitnesses {
744            if f < running_best {
745                worse_than_best += 1;
746            }
747            if f > running_best {
748                running_best = f;
749            }
750        }
751        assert!(
752            worse_than_best >= 3,
753            "expected sustained worsening exploration at high temperature, saw {worse_than_best} \
754             worse-than-best evaluations"
755        );
756    }
757
758    #[test]
759    fn known_fitness_skips_exactly_the_seeding_eval() {
760        // With a large budget the walk terminates by `min_temp`, not the budget,
761        // after a fixed number of cooling steps (one proposal each). The seeding
762        // eval draws no rng, so both entry points draw the identical proposal
763        // sequence — the hint path simply omits the seed.
764        let searcher = SimulatedAnnealing;
765        let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
766        params.max_iters = 10_000;
767        let start = vec![1.0_f32, 2.0, 3.0];
768
769        let refine_evals = {
770            let mut base = Flat;
771            let mut counting = Counting::new(&mut base);
772            let mut rng = StdRng::seed_from_u64(31);
773            let _ = LocalSearch::<TestBackend>::refine(
774                &searcher,
775                &params,
776                start.clone(),
777                &mut counting,
778                &mut rng,
779            );
780            counting.calls
781        };
782        let hint_evals = {
783            let mut base = Flat;
784            let mut counting = Counting::new(&mut base);
785            let mut rng = StdRng::seed_from_u64(31);
786            let _ = LocalSearch::<TestBackend>::refine_with_known_fitness(
787                &searcher,
788                &params,
789                start.clone(),
790                1.0, // Flat fitness of the start
791                &mut counting,
792                &mut rng,
793            );
794            counting.calls
795        };
796        assert_eq!(
797            hint_evals + 1,
798            refine_evals,
799            "hint path must skip exactly the seeding eval ({hint_evals} vs {refine_evals})"
800        );
801    }
802
803    #[test]
804    fn nan_hint_does_not_propagate() {
805        let searcher = SimulatedAnnealing;
806        let params = SimulatedAnnealingParams::default_for(BOUNDS);
807        let mut fitness = NegSphere;
808        let mut rng = StdRng::seed_from_u64(32);
809        let start = vec![2.0_f32, -1.0];
810        let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
811            &searcher,
812            &params,
813            start,
814            f32::NAN,
815            &mut fitness,
816            &mut rng,
817        );
818        assert!(fit.is_finite(), "NaN hint must be sanitized, got {fit}");
819        let fresh = fitness.evaluate_one(&g);
820        approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
821    }
822}