Skip to main content

rlevo_evolution/local_search/
random_restart.rs

1//! Random-restart meta-search.
2//!
3//! Random restart wraps an inner
4//! [`LocalSearch`] and runs it several times
5//! from perturbed starting points, returning the best refinement found. Run `0`
6//! starts from the unperturbed input (guaranteeing the
7//! monotone-non-worsening invariant); runs `1..=restarts` start from the input
8//! plus zero-mean Gaussian noise, clamped to bounds. This escapes the inner
9//! searcher's local basins on multimodal landscapes.
10//!
11//! # Run-0-first ordering (load-bearing)
12//!
13//! Run `0` is executed **before any rng value is drawn for perturbation**, and
14//! it refines the *unperturbed* input genome. This ordering is deliberate and
15//! has two consequences that the contract and tests rely on:
16//!
17//! 1. Run `0` consumes the rng stream exactly as a bare
18//!    `inner.refine(&params.inner, genome, fitness_fn, rng)` call would. Hence
19//!    a `RandomRestart` with `restarts > 0` returns a result **bit-identical to
20//!    or strictly better than** `restarts == 0` on the same seed: run `0` is
21//!    shared between the two, and additional restarts only ever replace it on a
22//!    strict improvement.
23//! 2. Monotonicity versus the input is *structural*: run `0` already satisfies
24//!    the [`LocalSearch`]
25//!    monotone-non-worsening invariant (the inner searcher guarantees it), and
26//!    the argmax over all runs can only be `>=` run `0`.
27//!
28//! # Evaluation budget
29//!
30//! `RandomRestart` owns **no cap of its own**. The total number of
31//! `evaluate_one` calls is exactly the product
32//! `(restarts + 1) * inner.max_iters`: one unperturbed run plus `restarts`
33//! perturbed runs, each bounded by the inner searcher's own `max_iters`. The
34//! inner searcher enforces its `max_iters >= 1` panic; `restarts == 0` is a
35//! valid configuration equivalent to a plain inner run.
36
37use core::fmt::Debug;
38
39use burn::tensor::backend::Backend;
40use rand::Rng;
41use rand_distr::{Distribution as _, Normal};
42
43use crate::fitness::FitnessFn;
44use crate::local_search::{LocalSearch, clamp_vec};
45use rlevo_core::bounds::Bounds;
46
47/// Static configuration for a [`RandomRestart`] run.
48///
49/// The total evaluation budget is the product `(restarts + 1) * inner.max_iters`:
50/// one unperturbed run plus `restarts` perturbed runs, each bounded by the inner
51/// searcher's own `max_iters`. There is no second, outer cap.
52///
53/// # Type parameters
54///
55/// - `LP`: the inner searcher's `Params` type.
56#[derive(Debug, Clone)]
57pub struct RandomRestartParams<LP: Clone + Debug + Send + Sync> {
58    /// Configuration handed to the inner searcher on every run.
59    pub inner: LP,
60    /// Number of *perturbed* restarts in addition to the unperturbed run `0`.
61    /// Default `2` (so 3 runs total).
62    ///
63    /// Because `RandomRestart` adds no cap of its own, this directly scales the
64    /// total evaluation budget: `(restarts + 1) * inner.max_iters` total
65    /// `evaluate_one` calls. `0` is valid and reduces to a plain inner run.
66    pub restarts: usize,
67    /// Inclusive search-space bounds `(lo, hi)`; perturbed starts are clamped
68    /// here.
69    pub bounds: Bounds,
70    /// Standard deviation of the Gaussian perturbation applied to the input
71    /// genome for runs `1..=restarts`. Default `0.1 * (hi - lo)`.
72    pub perturbation: f32,
73}
74
75impl<LP: Clone + Debug + Send + Sync> RandomRestartParams<LP> {
76    /// Default parameters: `restarts = 2`, `perturbation = 0.1 * (hi - lo)`,
77    /// wrapping the supplied inner `params`.
78    #[must_use]
79    pub fn default_for(inner: LP, bounds: Bounds) -> Self {
80        let (lo, hi): (f32, f32) = bounds.into();
81        debug_assert!(
82            (hi - lo) > 0.0,
83            "RandomRestartParams::default_for: zero-width bounds yields perturbation 0 (restarts cannot move)"
84        );
85        Self {
86            inner,
87            restarts: 2,
88            bounds,
89            perturbation: 0.1 * (hi - lo),
90        }
91    }
92}
93
94/// Random-restart wrapper around an inner [`LocalSearch`].
95///
96/// Runs the wrapped searcher `restarts + 1` times — once from the unperturbed
97/// input and `restarts` times from Gaussian-perturbed, bounds-clamped starting
98/// points — and returns the argmax over all runs (ties broken toward the
99/// earliest run). The total evaluation budget is the product
100/// `(restarts + 1) * inner.max_iters`; this wrapper adds no cap of its own.
101///
102/// # Type parameters
103///
104/// - `L`: the wrapped inner searcher.
105///
106/// # Example
107///
108/// ```
109/// use burn::backend::Flex;
110/// use rand::{rngs::StdRng, SeedableRng};
111/// use rlevo_evolution::fitness::FitnessFn;
112/// use rlevo_core::bounds::Bounds;
113/// use rlevo_evolution::local_search::{
114///     HillClimbing, HillClimbingParams, LocalSearch, RandomRestart, RandomRestartParams,
115/// };
116///
117/// // Maximize the negated 2-D sphere; the optimum is the origin with fitness 0.
118/// struct NegSphere;
119/// impl FitnessFn<Vec<f32>> for NegSphere {
120///     fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
121///         -x.iter().map(|v| v * v).sum::<f32>()
122///     }
123/// }
124///
125/// // Wrap hill climbing in random restart: 3 perturbed restarts + run 0.
126/// let searcher = RandomRestart::new(HillClimbing);
127/// let inner = HillClimbingParams::default_for(Bounds::new(-5.12, 5.12));
128/// let mut params = RandomRestartParams::default_for(inner, Bounds::new(-5.12, 5.12));
129/// params.restarts = 3;
130/// let mut fitness = NegSphere;
131/// let mut rng = StdRng::seed_from_u64(7);
132///
133/// let start = vec![2.5_f32, -1.5];
134/// let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
135/// let (refined, refined_fit) =
136///     LocalSearch::<Flex>::refine(&searcher, &params, start, &mut fitness, &mut rng);
137///
138/// assert_eq!(refined.len(), 2); // dimensionality preserved
139/// assert!(refined_fit >= start_fit); // monotone non-worsening
140/// ```
141#[derive(Debug, Clone, Copy)]
142pub struct RandomRestart<L> {
143    /// The wrapped inner searcher, invoked once per run.
144    inner: L,
145}
146
147impl<L> RandomRestart<L> {
148    /// Wraps `inner` for multi-start refinement.
149    #[must_use]
150    pub fn new(inner: L) -> Self {
151        Self { inner }
152    }
153}
154
155impl<L> RandomRestart<L> {
156    /// Shared body for [`refine`](LocalSearch::refine) and
157    /// [`refine_with_known_fitness`](LocalSearch::refine_with_known_fitness).
158    ///
159    /// A `known` fitness describes the *unperturbed* input, so it is forwarded
160    /// only to **run 0** (which refines that input); the `restarts` perturbed
161    /// runs start from jittered points with no known fitness and always take the
162    /// plain `inner.refine` path. Because the inner seeding eval draws no rng,
163    /// forwarding the hint leaves run 0's rng consumption — and thus the
164    /// load-bearing run-0-first ordering — bit-identical to the no-hint path.
165    ///
166    /// # Panics
167    ///
168    /// Panics if `params.restarts > 0` and `params.perturbation` is not
169    /// strictly positive: a zero (or negative/non-finite) standard deviation
170    /// cannot parameterize the Gaussian restart jitter, and silently degrading
171    /// to unperturbed restarts would waste the entire restart budget on
172    /// duplicate runs. `restarts == 0` is a valid configuration (a plain inner
173    /// run) and never panics here. The inner searcher enforces its own
174    /// `max_iters >= 1` invariant and will panic on a zero inner budget.
175    fn refine_impl<B: Backend>(
176        &self,
177        params: &RandomRestartParams<L::Params>,
178        genome: &[f32],
179        known: Option<f32>,
180        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
181        rng: &mut dyn Rng,
182    ) -> (Vec<f32>, f32)
183    where
184        L: LocalSearch<B>,
185    {
186        assert!(
187            params.restarts == 0 || params.perturbation > 0.0,
188            "RandomRestartParams::perturbation must be > 0 when restarts > 0 \
189             (zero jitter would make every restart a duplicate of run 0)"
190        );
191        // Run 0 FIRST, from the UNPERTURBED input, before drawing ANY rng
192        // values for perturbation. This ordering is load-bearing (see module
193        // docs): it makes run 0 consume the rng stream exactly as a bare
194        // `inner.refine` call would, so monotonicity is structural and the
195        // `restarts > 0` result is bit-exactly `<=` the `restarts == 0` result
196        // on the same seed. A known fitness describes this unperturbed input, so
197        // it is forwarded here and nowhere else.
198        let (mut best_genome, mut best_fit): (Vec<f32>, f32) = match known {
199            Some(f) => self.inner.refine_with_known_fitness(
200                &params.inner,
201                genome.to_vec(),
202                f,
203                fitness_fn,
204                rng,
205            ),
206            None => self
207                .inner
208                .refine(&params.inner, genome.to_vec(), fitness_fn, rng),
209        };
210
211        // Runs 1..=restarts: perturb the input with per-coordinate Gaussian
212        // noise drawn through the passed rng, clamp to bounds, refine. Replace
213        // the incumbent only on a STRICT improvement, so ties keep the earliest
214        // run (run 0 wins ties).
215        if params.restarts > 0 {
216            let normal: Normal<f32> = Normal::new(0.0_f32, params.perturbation)
217                .expect("perturbation std-dev is strictly positive (asserted above)");
218            for _ in 0..params.restarts {
219                let mut start: Vec<f32> = genome.to_vec();
220                for coord in &mut start {
221                    *coord += normal.sample(rng);
222                }
223                clamp_vec(&mut start, params.bounds);
224
225                let (run_genome, run_fit): (Vec<f32>, f32) =
226                    self.inner.refine(&params.inner, start, fitness_fn, rng);
227                if run_fit > best_fit {
228                    best_fit = run_fit;
229                    best_genome = run_genome;
230                }
231            }
232        }
233
234        (best_genome, best_fit)
235    }
236}
237
238impl<B: Backend, L: LocalSearch<B>> LocalSearch<B> for RandomRestart<L> {
239    type Params = RandomRestartParams<L::Params>;
240
241    /// # Panics
242    ///
243    /// Panics if `params.restarts > 0` and `params.perturbation` is not strictly
244    /// positive; see `refine_impl`.
245    fn refine(
246        &self,
247        params: &RandomRestartParams<L::Params>,
248        genome: Vec<f32>,
249        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
250        rng: &mut dyn Rng,
251    ) -> (Vec<f32>, f32) {
252        self.refine_impl::<B>(params, &genome, None, fitness_fn, rng)
253    }
254
255    /// Forwards `known_fitness` to **run 0** (the unperturbed input) so its inner
256    /// searcher skips its seeding eval; perturbed runs are unaffected. See
257    /// `refine_impl`.
258    ///
259    /// # Panics
260    ///
261    /// Panics if `params.restarts > 0` and `params.perturbation` is not strictly
262    /// positive; see `refine_impl`.
263    fn refine_with_known_fitness(
264        &self,
265        params: &RandomRestartParams<L::Params>,
266        genome: Vec<f32>,
267        known_fitness: f32,
268        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
269        rng: &mut dyn Rng,
270    ) -> (Vec<f32>, f32) {
271        self.refine_impl::<B>(params, &genome, Some(known_fitness), fitness_fn, rng)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::local_search::{HillClimbing, HillClimbingParams};
279    use burn::backend::Flex;
280    use rand::rngs::StdRng;
281    use rand::{RngExt as _, SeedableRng};
282
283    type TestBackend = Flex;
284
285    const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
286
287    /// Negated sphere `f(x) = -Σ x_i²` — concave bump; global maximum 0 at the
288    /// origin.
289    struct NegSphere;
290    impl FitnessFn<Vec<f32>> for NegSphere {
291        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
292            -x.iter().map(|v| v * v).sum::<f32>()
293        }
294    }
295
296    /// Negated 2-D Rastrigin — highly multimodal; global maximum 0 at the
297    /// origin.
298    struct NegRastrigin;
299    impl FitnessFn<Vec<f32>> for NegRastrigin {
300        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
301            use core::f32::consts::PI;
302            let a = 10.0_f32;
303            // `a * D` constant folded per-coordinate to avoid a usize->f32 cast.
304            -x.iter()
305                .map(|&xi| a + xi * xi - a * (2.0 * PI * xi).cos())
306                .sum::<f32>()
307        }
308    }
309
310    /// Negated 2-D Rosenbrock — curved ridge; global maximum 0 at `(1, 1)`.
311    struct NegRosenbrock;
312    impl FitnessFn<Vec<f32>> for NegRosenbrock {
313        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
314            let a = 1.0 - x[0];
315            let b = x[1] - x[0] * x[0];
316            -(a * a + 100.0 * b * b)
317        }
318    }
319
320    /// Constant 1.0 — perfectly flat; no probe ever improves.
321    struct Flat;
322    impl FitnessFn<Vec<f32>> for Flat {
323        fn evaluate_one(&mut self, _x: &Vec<f32>) -> f32 {
324            1.0
325        }
326    }
327
328    /// Wraps a fitness function and counts `evaluate_one` calls.
329    struct Counting<'a> {
330        inner: &'a mut dyn FitnessFn<Vec<f32>>,
331        calls: usize,
332    }
333    impl<'a> Counting<'a> {
334        fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
335            Self { inner, calls: 0 }
336        }
337    }
338    impl FitnessFn<Vec<f32>> for Counting<'_> {
339        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
340            self.calls += 1;
341            self.inner.evaluate_one(x)
342        }
343    }
344
345    /// Builds a `RandomRestart<HillClimbing>` params set with the given restart
346    /// count, sharing the supplied inner `HillClimbingParams`.
347    fn rr_params(
348        inner: HillClimbingParams,
349        restarts: usize,
350    ) -> RandomRestartParams<HillClimbingParams> {
351        let mut params: RandomRestartParams<HillClimbingParams> =
352            RandomRestartParams::default_for(inner, BOUNDS);
353        params.restarts = restarts;
354        params
355    }
356
357    #[test]
358    fn budget_is_product_of_runs_and_inner_max_iters() {
359        // On a flat landscape no probe ever improves, so every run burns its
360        // full inner budget. Total evals must respect the product formula and
361        // exceed a single inner run (proving the restarts actually ran).
362        let searcher = RandomRestart::new(HillClimbing);
363        let inner = HillClimbingParams::default_for(BOUNDS).with_max_iters(20);
364        let restarts = 3_usize;
365        let params = rr_params(inner.clone(), restarts);
366
367        let mut base = Flat;
368        let mut counting = Counting::new(&mut base);
369        let mut rng = StdRng::seed_from_u64(1);
370        let start = vec![1.0_f32, 2.0, 3.0];
371        let _ =
372            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut counting, &mut rng);
373
374        let upper = (restarts + 1) * inner.max_iters();
375        assert!(
376            counting.calls <= upper,
377            "evals {} must not exceed product budget {}",
378            counting.calls,
379            upper
380        );
381        assert!(
382            counting.calls > inner.max_iters(),
383            "evals {} must exceed a single inner run ({}) — restarts must run",
384            counting.calls,
385            inner.max_iters()
386        );
387    }
388
389    #[test]
390    fn restarts_never_worse_than_zero_same_seed() {
391        // On a multimodal landscape, restarts > 0 must never return a worse
392        // result than restarts == 0 with the same seed (run 0 is shared).
393        let searcher = RandomRestart::new(HillClimbing);
394        let inner = HillClimbingParams::default_for(BOUNDS);
395        let start = vec![3.7_f32, -2.9];
396
397        let params_zero = rr_params(inner.clone(), 0);
398        let mut fit_zero = NegRastrigin;
399        let mut rng_zero = StdRng::seed_from_u64(42);
400        let (_g0, f0) = LocalSearch::<TestBackend>::refine(
401            &searcher,
402            &params_zero,
403            start.clone(),
404            &mut fit_zero,
405            &mut rng_zero,
406        );
407
408        let params_three = rr_params(inner, 3);
409        let mut fit_three = NegRastrigin;
410        let mut rng_three = StdRng::seed_from_u64(42);
411        let (_g3, f3) = LocalSearch::<TestBackend>::refine(
412            &searcher,
413            &params_three,
414            start,
415            &mut fit_three,
416            &mut rng_three,
417        );
418
419        assert!(
420            f3 >= f0,
421            "restarts=3 ({f3}) must not be worse than restarts=0 ({f0})"
422        );
423    }
424
425    #[test]
426    fn restarts_escape_local_basin() {
427        // From a start trapped on a non-global Neg-Rastrigin peak, a single inner
428        // run (restarts=0) settles onto that peak; restarts with healthy
429        // perturbation escape to a strictly better fitness.
430        let searcher = RandomRestart::new(HillClimbing);
431        // Small step so run 0 stays trapped near the start peak.
432        let inner = HillClimbingParams::default_for(BOUNDS)
433            .with_step_size(0.25)
434            .with_max_iters(120);
435        // Start near a non-global Neg-Rastrigin local maximum (lattice point
436        // (4, -3), a local minimum of the original Rastrigin).
437        let start = vec![4.0_f32, -3.0];
438
439        let params_zero = rr_params(inner.clone(), 0);
440        let mut fit_zero = NegRastrigin;
441        let mut rng_zero = StdRng::seed_from_u64(7);
442        let (_g0, f0) = LocalSearch::<TestBackend>::refine(
443            &searcher,
444            &params_zero,
445            start.clone(),
446            &mut fit_zero,
447            &mut rng_zero,
448        );
449
450        // Healthy perturbation lets restarts jump basins.
451        let mut params_five = rr_params(inner, 5);
452        params_five.perturbation = 2.5;
453        let mut fit_five = NegRastrigin;
454        let mut rng_five = StdRng::seed_from_u64(7);
455        let (_g5, f5) = LocalSearch::<TestBackend>::refine(
456            &searcher,
457            &params_five,
458            start,
459            &mut fit_five,
460            &mut rng_five,
461        );
462
463        assert!(
464            f5 > f0,
465            "restarts=5 ({f5}) should strictly beat restarts=0 ({f0})"
466        );
467    }
468
469    #[test]
470    fn rosenbrock_monotone_non_worsening() {
471        let searcher = RandomRestart::new(HillClimbing);
472        let inner = HillClimbingParams::default_for(BOUNDS);
473        let params = rr_params(inner, 2);
474        let mut rng = StdRng::seed_from_u64(11);
475        let (lo, hi): (f32, f32) = BOUNDS.into();
476        for _ in 0..5 {
477            let start: Vec<f32> = (0..2)
478                .map(|_| lo + (hi - lo) * rng.random::<f32>())
479                .collect();
480            let mut fitness = NegRosenbrock;
481            let start_fit = fitness.evaluate_one(&start);
482            let (_g, fit) = LocalSearch::<TestBackend>::refine(
483                &searcher,
484                &params,
485                start,
486                &mut fitness,
487                &mut rng,
488            );
489            assert!(fit >= start_fit, "monotone: {fit} >= {start_fit}");
490        }
491    }
492
493    #[test]
494    fn output_len_equals_input_len() {
495        let searcher = RandomRestart::new(HillClimbing);
496        let inner = HillClimbingParams::default_for(BOUNDS);
497        let params = rr_params(inner, 2);
498        let mut fitness = NegSphere;
499        let mut rng = StdRng::seed_from_u64(3);
500        let (lo, hi): (f32, f32) = BOUNDS.into();
501        for dim in [1_usize, 2, 5, 10] {
502            let start: Vec<f32> = (0..dim)
503                .map(|_| lo + (hi - lo) * rng.random::<f32>())
504                .collect();
505            let (g, _f) = LocalSearch::<TestBackend>::refine(
506                &searcher,
507                &params,
508                start,
509                &mut fitness,
510                &mut rng,
511            );
512            assert_eq!(g.len(), dim);
513        }
514    }
515
516    #[test]
517    fn returned_fitness_matches_fresh_eval() {
518        let searcher = RandomRestart::new(HillClimbing);
519        let inner = HillClimbingParams::default_for(BOUNDS);
520        let params = rr_params(inner, 3);
521        let mut fitness = NegRastrigin;
522        let mut rng = StdRng::seed_from_u64(4);
523        let start = vec![1.3_f32, -2.7];
524        let (g, fit) =
525            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
526        let fresh = fitness.evaluate_one(&g);
527        approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
528    }
529
530    #[test]
531    fn boundary_start_with_large_perturbation_stays_within_bounds() {
532        let searcher = RandomRestart::new(HillClimbing);
533        // Big inner step, no decay, so probes push hard on bounds too.
534        let inner = HillClimbingParams::default_for(BOUNDS)
535            .with_step_size(4.0)
536            .with_step_decay(1.0);
537        let mut params = rr_params(inner, 4);
538        // Large perturbation relative to range: starts will spill past bounds
539        // before clamping.
540        params.perturbation = 10.0;
541        let mut fitness = NegSphere;
542        let mut rng = StdRng::seed_from_u64(5);
543        // Start at the upper boundary in every coordinate.
544        let start = vec![BOUNDS.hi(); 4];
545        let (g, _f) =
546            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
547        for &x in &g {
548            assert!(
549                x >= BOUNDS.lo() && x <= BOUNDS.hi(),
550                "coord {x} out of bounds {BOUNDS:?}"
551            );
552        }
553    }
554
555    #[test]
556    #[allow(clippy::float_cmp)]
557    fn same_seed_is_bit_identical() {
558        let searcher = RandomRestart::new(HillClimbing);
559        let inner = HillClimbingParams::default_for(BOUNDS);
560        let params = rr_params(inner, 4);
561        let start = vec![2.0_f32, -3.0, 1.5];
562
563        let mut fitness_a = NegRastrigin;
564        let mut rng_a = StdRng::seed_from_u64(123);
565        let (g_a, f_a) = LocalSearch::<TestBackend>::refine(
566            &searcher,
567            &params,
568            start.clone(),
569            &mut fitness_a,
570            &mut rng_a,
571        );
572
573        let mut fitness_b = NegRastrigin;
574        let mut rng_b = StdRng::seed_from_u64(123);
575        let (g_b, f_b) = LocalSearch::<TestBackend>::refine(
576            &searcher,
577            &params,
578            start,
579            &mut fitness_b,
580            &mut rng_b,
581        );
582
583        assert_eq!(g_a, g_b);
584        assert_eq!(f_a, f_b);
585    }
586
587    #[test]
588    fn known_fitness_saves_exactly_one_eval_total() {
589        // The hint is forwarded only to run 0 (the unperturbed input); the
590        // perturbed runs are untouched. With an inner budget large enough that
591        // step-underflow (not the budget) terminates each run, total evals drop
592        // by exactly one: run 0's seeding eval.
593        let searcher = RandomRestart::new(HillClimbing);
594        let inner = HillClimbingParams::default_for(BOUNDS).with_max_iters(10_000);
595        let params = rr_params(inner, 3);
596        let start = vec![1.0_f32, 2.0, 3.0];
597
598        let refine_evals = {
599            let mut base = Flat;
600            let mut counting = Counting::new(&mut base);
601            let mut rng = StdRng::seed_from_u64(51);
602            let _ = LocalSearch::<TestBackend>::refine(
603                &searcher,
604                &params,
605                start.clone(),
606                &mut counting,
607                &mut rng,
608            );
609            counting.calls
610        };
611        let hint_evals = {
612            let mut base = Flat;
613            let mut counting = Counting::new(&mut base);
614            let mut rng = StdRng::seed_from_u64(51);
615            let _ = LocalSearch::<TestBackend>::refine_with_known_fitness(
616                &searcher,
617                &params,
618                start.clone(),
619                1.0, // Flat fitness of the start
620                &mut counting,
621                &mut rng,
622            );
623            counting.calls
624        };
625        assert_eq!(
626            hint_evals + 1,
627            refine_evals,
628            "hint must save exactly run 0's seeding eval ({hint_evals} vs {refine_evals})"
629        );
630    }
631
632    #[test]
633    fn nan_hint_does_not_propagate() {
634        let searcher = RandomRestart::new(HillClimbing);
635        let inner = HillClimbingParams::default_for(BOUNDS);
636        let params = rr_params(inner, 3);
637        let mut fitness = NegSphere;
638        let mut rng = StdRng::seed_from_u64(52);
639        let start = vec![2.0_f32, -1.0];
640        let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
641            &searcher,
642            &params,
643            start,
644            f32::NAN,
645            &mut fitness,
646            &mut rng,
647        );
648        assert!(fit.is_finite(), "NaN hint must be sanitized, got {fit}");
649        let fresh = fitness.evaluate_one(&g);
650        approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
651    }
652}