Skip to main content

rlevo_evolution/local_search/
hill_climbing.rs

1//! Coordinate-wise hill climbing.
2//!
3//! A simple, robust gradient-free local search: from the input genome, probe
4//! neighbours one coordinate at a time and move whenever a probe strictly
5//! improves the objective. Two acceptance strategies are offered via
6//! [`HillClimbVariant`]:
7//!
8//! - [`FirstImprovement`](HillClimbVariant::FirstImprovement) — each iteration
9//!   perturbs one randomly chosen coordinate by `±step_size` and accepts the
10//!   move on the first strict improvement seen. Cheap per step; good when the
11//!   budget is small.
12//! - [`BestImprovement`](HillClimbVariant::BestImprovement) — each *sweep*
13//!   probes `±step_size` along every coordinate (`2·dim` evaluations) and moves
14//!   to the single best strict improver. More evaluations per move, but each
15//!   move is greedier.
16//!
17//! Both variants shrink `step_size` by `step_decay` once a probe budget passes
18//! without improvement, letting the search settle into a peak. The returned
19//! pair is always the best `(genome, fitness)` observed across all evaluations,
20//! which makes the [`LocalSearch`]
21//! monotone-non-worsening and fresh-fitness invariants hold structurally.
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/// Acceptance strategy for [`HillClimbing`].
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum HillClimbVariant {
33    /// Probe one random coordinate per iteration; accept the first strict
34    /// improvement.
35    FirstImprovement,
36    /// Probe `±step` along every coordinate per sweep; move to the best strict
37    /// improver.
38    BestImprovement,
39}
40
41/// Static configuration for a [`HillClimbing`] run.
42///
43/// Fields are private: start from [`default_for`](HillClimbingParams::default_for)
44/// and override with the fluent `with_*` setters, which reject out-of-domain
45/// values at the call site rather than letting an invalid config reach `refine`.
46#[derive(Debug, Clone)]
47pub struct HillClimbingParams {
48    /// Inclusive search-space bounds `(lo, hi)`; refined genomes are clamped
49    /// here.
50    bounds: Bounds,
51    /// Hard cap on the **total** number of `evaluate_one` calls per `refine`,
52    /// including the mandatory initial re-evaluation of the input genome.
53    /// Default `100`.
54    max_iters: usize,
55    /// Per-coordinate perturbation magnitude. Default `0.1 * (hi - lo)` via
56    /// [`HillClimbingParams::default_for`].
57    step_size: f32,
58    /// Multiplicative shrink applied to `step_size` after a failed probe
59    /// budget. `0.5` halves the step; `1.0` keeps it fixed. Default `0.5`.
60    step_decay: f32,
61    /// Acceptance strategy. Default
62    /// [`FirstImprovement`](HillClimbVariant::FirstImprovement).
63    variant: HillClimbVariant,
64}
65
66impl HillClimbingParams {
67    /// Default parameters derived from the search-space `bounds`.
68    ///
69    /// `step_size = 0.1 * (hi - lo)`, `step_decay = 0.5`, `max_iters = 100`,
70    /// variant [`FirstImprovement`](HillClimbVariant::FirstImprovement).
71    #[must_use]
72    pub fn default_for(bounds: Bounds) -> Self {
73        let (lo, hi): (f32, f32) = bounds.into();
74        Self {
75            bounds,
76            max_iters: 100,
77            step_size: 0.1 * (hi - lo),
78            step_decay: 0.5,
79            variant: HillClimbVariant::FirstImprovement,
80        }
81    }
82
83    /// Overrides the search-space bounds.
84    #[must_use]
85    pub fn with_bounds(mut self, bounds: Bounds) -> Self {
86        self.bounds = bounds;
87        self
88    }
89
90    /// Overrides the total evaluation budget per `refine`.
91    ///
92    /// # Panics
93    ///
94    /// Panics if `max_iters == 0` — a `refine` must evaluate at least the
95    /// input genome once.
96    #[must_use]
97    pub fn with_max_iters(mut self, max_iters: usize) -> Self {
98        assert!(
99            max_iters >= 1,
100            "HillClimbingParams::with_max_iters: max_iters must be >= 1"
101        );
102        self.max_iters = max_iters;
103        self
104    }
105
106    /// Overrides the per-coordinate perturbation magnitude.
107    ///
108    /// # Panics
109    ///
110    /// Panics if `step_size` is not strictly positive and finite — a
111    /// non-positive step cannot move the search.
112    #[must_use]
113    pub fn with_step_size(mut self, step_size: f32) -> Self {
114        assert!(
115            step_size.is_finite() && step_size > 0.0,
116            "HillClimbingParams::with_step_size: step_size must be finite and > 0"
117        );
118        self.step_size = step_size;
119        self
120    }
121
122    /// Overrides the multiplicative step-shrink factor.
123    ///
124    /// # Panics
125    ///
126    /// Panics if `step_decay` is outside the half-open interval `(0, 1]` — a
127    /// factor `> 1` would grow the step unboundedly and `<= 0` would zero it.
128    #[must_use]
129    pub fn with_step_decay(mut self, step_decay: f32) -> Self {
130        assert!(
131            step_decay.is_finite() && step_decay > 0.0 && step_decay <= 1.0,
132            "HillClimbingParams::with_step_decay: step_decay must be in (0, 1]"
133        );
134        self.step_decay = step_decay;
135        self
136    }
137
138    /// Overrides the acceptance strategy.
139    #[must_use]
140    pub fn with_variant(mut self, variant: HillClimbVariant) -> Self {
141        self.variant = variant;
142        self
143    }
144
145    /// Inclusive search-space bounds.
146    #[must_use]
147    pub fn bounds(&self) -> Bounds {
148        self.bounds
149    }
150
151    /// Total evaluation budget per `refine`.
152    #[must_use]
153    pub fn max_iters(&self) -> usize {
154        self.max_iters
155    }
156
157    /// Per-coordinate perturbation magnitude.
158    #[must_use]
159    pub fn step_size(&self) -> f32 {
160        self.step_size
161    }
162
163    /// Multiplicative step-shrink factor.
164    #[must_use]
165    pub fn step_decay(&self) -> f32 {
166        self.step_decay
167    }
168
169    /// Acceptance strategy.
170    #[must_use]
171    pub fn variant(&self) -> HillClimbVariant {
172        self.variant
173    }
174}
175
176/// Coordinate-wise hill-climbing local search.
177///
178/// A unit struct: all configuration lives in [`HillClimbingParams`], so one
179/// instance can refine many genomes. See the [module docs](self) for the two
180/// acceptance variants.
181///
182/// # Example
183///
184/// ```
185/// use burn::backend::Flex;
186/// use rand::{rngs::StdRng, SeedableRng};
187/// use rlevo_evolution::fitness::FitnessFn;
188/// use rlevo_core::bounds::Bounds;
189/// use rlevo_evolution::local_search::{HillClimbing, HillClimbingParams, LocalSearch};
190///
191/// // Maximize the negated 2-D sphere; the optimum is the origin with fitness 0.
192/// struct NegSphere;
193/// impl FitnessFn<Vec<f32>> for NegSphere {
194///     fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
195///         -x.iter().map(|v| v * v).sum::<f32>()
196///     }
197/// }
198///
199/// let searcher = HillClimbing;
200/// let params = HillClimbingParams::default_for(Bounds::new(-5.12, 5.12));
201/// let mut fitness = NegSphere;
202/// let mut rng = StdRng::seed_from_u64(7);
203///
204/// let start = vec![2.5_f32, -1.5];
205/// let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
206/// let (refined, refined_fit) =
207///     LocalSearch::<Flex>::refine(&searcher, &params, start, &mut fitness, &mut rng);
208///
209/// assert_eq!(refined.len(), 2);          // dimensionality preserved
210/// assert!(refined_fit >= start_fit);     // monotone non-worsening
211/// ```
212#[derive(Debug, Clone, Copy, Default)]
213pub struct HillClimbing;
214
215impl HillClimbing {
216    /// Shared body for [`refine`](LocalSearch::refine) and
217    /// [`refine_with_known_fitness`](LocalSearch::refine_with_known_fitness).
218    ///
219    /// `known` is the input genome's fitness when the caller already holds it
220    /// (the hint path) or `None` when the input must be re-evaluated to seed the
221    /// tracker. Either way the seed is sanitized so a `NaN` never poisons the
222    /// best-so-far pair, and the budget meaning is identical: skipping the
223    /// seeding eval simply leaves one more probe within `max_iters`.
224    ///
225    /// # Panics
226    ///
227    /// Panics if `params.max_iters == 0`: a zero evaluation budget makes it
228    /// impossible to return an honestly evaluated fitness, so it is treated
229    /// as an invalid configuration (programming error), not runtime data.
230    fn refine_impl(
231        params: &HillClimbingParams,
232        genome: Vec<f32>,
233        known: Option<f32>,
234        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
235        rng: &mut dyn Rng,
236    ) -> (Vec<f32>, f32) {
237        assert!(
238            params.max_iters >= 1,
239            "HillClimbingParams::max_iters must be >= 1 (the input genome is \
240             always evaluated once to seed the best-so-far tracker)"
241        );
242        let mut budget = BudgetedEval::new(fitness_fn, params.max_iters);
243
244        // First action: seed the best-so-far tracker. With a known fitness we
245        // reuse it (sanitizing NaN exactly as a probe would); otherwise we spend
246        // one eval scoring the input. The assert above guarantees the eval path
247        // succeeds.
248        let initial_fit: f32 = if let Some(f) = known {
249            sanitize_fitness(f)
250        } else {
251            let Some(f) = budget.eval(&genome) else {
252                unreachable!("budget of >= 1 cannot be exhausted before the first eval");
253            };
254            f
255        };
256
257        let mut current: Vec<f32> = genome;
258        let mut current_fit = initial_fit;
259        // The tracked best — always returned. Updated on EVERY evaluation, so
260        // the monotone + fresh-fitness invariants hold structurally.
261        let mut best: Vec<f32> = current.clone();
262        let mut best_fit = current_fit;
263
264        let mut step = params.step_size;
265        let dim = current.len();
266        if dim == 0 {
267            return (best, best_fit);
268        }
269
270        match params.variant {
271            HillClimbVariant::FirstImprovement => {
272                // After `2 * dim` consecutive non-improving probes, shrink the
273                // step. This budget gives every coordinate a chance in both
274                // directions before concluding the current step is too coarse.
275                let mut consecutive_failures: usize = 0;
276                let failure_budget = 2 * dim;
277                loop {
278                    let coord = rng.random_range(0..dim);
279                    let sign: f32 = if rng.random::<bool>() { 1.0 } else { -1.0 };
280                    let mut candidate = current.clone();
281                    candidate[coord] += sign * step;
282                    clamp_vec(&mut candidate, params.bounds);
283
284                    let Some(cand_fit) = budget.eval(&candidate) else {
285                        break;
286                    };
287                    if cand_fit > best_fit {
288                        best_fit = cand_fit;
289                        best.clone_from(&candidate);
290                    }
291                    if cand_fit > current_fit {
292                        current = candidate;
293                        current_fit = cand_fit;
294                        consecutive_failures = 0;
295                    } else {
296                        consecutive_failures += 1;
297                        if consecutive_failures >= failure_budget {
298                            step *= params.step_decay;
299                            consecutive_failures = 0;
300                            // Step underflowed to ~0: no neighbour will ever
301                            // differ from `current`. Early-stop; the eval bound
302                            // is the hard guarantee, this is just a courtesy.
303                            if step <= f32::EPSILON {
304                                break;
305                            }
306                        }
307                    }
308                }
309            }
310            HillClimbVariant::BestImprovement => {
311                'sweeps: loop {
312                    if budget.remaining() == 0 {
313                        break;
314                    }
315                    let mut sweep_best_fit = current_fit;
316                    let mut sweep_best: Option<Vec<f32>> = None;
317                    for coord in 0..dim {
318                        for &sign in &[1.0_f32, -1.0_f32] {
319                            let mut candidate = current.clone();
320                            candidate[coord] += sign * step;
321                            clamp_vec(&mut candidate, params.bounds);
322                            let Some(cand_fit) = budget.eval(&candidate) else {
323                                // Budget ran out mid-sweep: commit whatever the
324                                // partial sweep found and stop.
325                                break 'sweeps;
326                            };
327                            if cand_fit > best_fit {
328                                best_fit = cand_fit;
329                                best.clone_from(&candidate);
330                            }
331                            if cand_fit > sweep_best_fit {
332                                sweep_best_fit = cand_fit;
333                                sweep_best = Some(candidate);
334                            }
335                        }
336                    }
337                    if let Some(next) = sweep_best {
338                        current = next;
339                        current_fit = sweep_best_fit;
340                    } else {
341                        // No coordinate improved this sweep: shrink the step.
342                        step *= params.step_decay;
343                        if step <= f32::EPSILON {
344                            break;
345                        }
346                    }
347                }
348            }
349        }
350
351        (best, best_fit)
352    }
353}
354
355impl<B: Backend> LocalSearch<B> for HillClimbing {
356    type Params = HillClimbingParams;
357
358    /// # Panics
359    ///
360    /// Panics if `params.max_iters == 0`; see `refine_impl`.
361    fn refine(
362        &self,
363        params: &HillClimbingParams,
364        genome: Vec<f32>,
365        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
366        rng: &mut dyn Rng,
367    ) -> (Vec<f32>, f32) {
368        Self::refine_impl(params, genome, None, fitness_fn, rng)
369    }
370
371    /// Seeds the best-so-far tracker with `known_fitness` (sanitizing `NaN` to
372    /// `-inf`) instead of re-scoring the input, saving one eval.
373    ///
374    /// # Panics
375    ///
376    /// Panics if `params.max_iters == 0`; see `refine_impl`.
377    fn refine_with_known_fitness(
378        &self,
379        params: &HillClimbingParams,
380        genome: Vec<f32>,
381        known_fitness: f32,
382        fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
383        rng: &mut dyn Rng,
384    ) -> (Vec<f32>, f32) {
385        Self::refine_impl(params, genome, Some(known_fitness), fitness_fn, rng)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use burn::backend::Flex;
393    use rand::SeedableRng;
394    use rand::rngs::StdRng;
395
396    type TestBackend = Flex;
397
398    #[test]
399    fn with_setters_override_defaults() {
400        let bounds = Bounds::new(-1.0, 1.0);
401        let hc = HillClimbingParams::default_for(bounds)
402            .with_max_iters(20)
403            .with_step_size(0.4)
404            .with_step_decay(0.5)
405            .with_variant(HillClimbVariant::BestImprovement);
406        assert_eq!(hc.max_iters(), 20);
407        assert!((hc.step_size() - 0.4).abs() < 1e-6);
408        assert!((hc.step_decay() - 0.5).abs() < 1e-6);
409        assert_eq!(hc.variant(), HillClimbVariant::BestImprovement);
410    }
411
412    #[test]
413    #[should_panic(expected = "step_size must be finite and > 0")]
414    fn with_step_size_rejects_nonpositive() {
415        let _ = HillClimbingParams::default_for(Bounds::new(-1.0, 1.0)).with_step_size(0.0);
416    }
417
418    /// Negated sphere `f(x) = -Σ x_i²` — concave bump; global maximum 0 at the
419    /// origin.
420    struct NegSphere;
421    impl FitnessFn<Vec<f32>> for NegSphere {
422        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
423            -x.iter().map(|v| v * v).sum::<f32>()
424        }
425    }
426
427    /// Negated 2-D Rosenbrock — curved ridge; global maximum 0 at `(1, 1)`.
428    struct NegRosenbrock;
429    impl FitnessFn<Vec<f32>> for NegRosenbrock {
430        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
431            let a = 1.0 - x[0];
432            let b = x[1] - x[0] * x[0];
433            -(a * a + 100.0 * b * b)
434        }
435    }
436
437    /// Constant 1.0 — perfectly flat; no probe ever improves.
438    struct Flat;
439    impl FitnessFn<Vec<f32>> for Flat {
440        fn evaluate_one(&mut self, _x: &Vec<f32>) -> f32 {
441            1.0
442        }
443    }
444
445    /// Wraps a fitness function and counts `evaluate_one` calls.
446    struct Counting<'a> {
447        inner: &'a mut dyn FitnessFn<Vec<f32>>,
448        calls: usize,
449    }
450    impl<'a> Counting<'a> {
451        fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
452            Self { inner, calls: 0 }
453        }
454    }
455    impl FitnessFn<Vec<f32>> for Counting<'_> {
456        fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
457            self.calls += 1;
458            self.inner.evaluate_one(x)
459        }
460    }
461
462    const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
463
464    fn random_start(rng: &mut StdRng, dim: usize, bounds: Bounds) -> Vec<f32> {
465        let (lo, hi): (f32, f32) = bounds.into();
466        (0..dim)
467            .map(|_| lo + (hi - lo) * rng.random::<f32>())
468            .collect()
469    }
470
471    #[test]
472    fn sphere_d2_converges_below_threshold() {
473        let searcher = HillClimbing;
474        let mut params = HillClimbingParams::default_for(BOUNDS);
475        params.max_iters = 100;
476        let mut fitness = NegSphere;
477        let mut rng = StdRng::seed_from_u64(1);
478        let start = random_start(&mut rng, 2, BOUNDS);
479        let (_g, fit) =
480            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
481        assert!(fit > -1e-3, "sphere D=2 should converge: best={fit}");
482    }
483
484    #[test]
485    fn sphere_d10_strictly_improves() {
486        let searcher = HillClimbing;
487        let params = HillClimbingParams::default_for(BOUNDS);
488        let mut fitness = NegSphere;
489        let mut rng = StdRng::seed_from_u64(2);
490        let start = random_start(&mut rng, 10, BOUNDS);
491        let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
492        let (_g, fit) =
493            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
494        assert!(fit > start_fit, "expected improvement: {fit} > {start_fit}");
495    }
496
497    #[test]
498    fn output_len_equals_input_len() {
499        let searcher = HillClimbing;
500        let params = HillClimbingParams::default_for(BOUNDS);
501        let mut fitness = NegSphere;
502        let mut rng = StdRng::seed_from_u64(3);
503        for dim in [1_usize, 2, 5, 10] {
504            let start = random_start(&mut rng, dim, BOUNDS);
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 = HillClimbing;
519        let params = HillClimbingParams::default_for(BOUNDS);
520        let mut fitness = NegSphere;
521        let mut rng = StdRng::seed_from_u64(4);
522        let start = random_start(&mut rng, 4, BOUNDS);
523        let (g, fit) =
524            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
525        let fresh = fitness.evaluate_one(&g);
526        approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
527    }
528
529    #[test]
530    fn rosenbrock_monotone_non_worsening() {
531        let searcher = HillClimbing;
532        let params = HillClimbingParams::default_for(BOUNDS);
533        let mut rng = StdRng::seed_from_u64(5);
534        for _ in 0..6 {
535            let start = random_start(&mut rng, 2, BOUNDS);
536            let mut fitness = NegRosenbrock;
537            let start_fit = fitness.evaluate_one(&start);
538            let (_g, fit) = LocalSearch::<TestBackend>::refine(
539                &searcher,
540                &params,
541                start,
542                &mut fitness,
543                &mut rng,
544            );
545            assert!(fit >= start_fit, "monotone: {fit} >= {start_fit}");
546        }
547    }
548
549    #[test]
550    #[allow(clippy::float_cmp)]
551    fn flat_landscape_terminates_within_budget() {
552        let searcher = HillClimbing;
553        let mut params = HillClimbingParams::default_for(BOUNDS);
554        params.max_iters = 37;
555        let mut base = Flat;
556        let mut counting = Counting::new(&mut base);
557        let mut rng = StdRng::seed_from_u64(6);
558        let start = vec![1.0_f32, 2.0, 3.0];
559        let (g, fit) = LocalSearch::<TestBackend>::refine(
560            &searcher,
561            &params,
562            start.clone(),
563            &mut counting,
564            &mut rng,
565        );
566        assert!(
567            counting.calls <= params.max_iters,
568            "evals {} must not exceed budget {}",
569            counting.calls,
570            params.max_iters
571        );
572        // On a flat landscape nothing improves: the returned genome is the
573        // input and its fitness is the constant 1.0.
574        assert_eq!(g, start);
575        assert_eq!(fit, 1.0);
576    }
577
578    #[test]
579    fn boundary_start_stays_within_bounds() {
580        let searcher = HillClimbing;
581        let mut params = HillClimbingParams::default_for(BOUNDS);
582        // Big step relative to range, no decay, so probes push hard on bounds.
583        params.step_size = 4.0;
584        params.step_decay = 1.0;
585        let mut fitness = NegSphere;
586        let mut rng = StdRng::seed_from_u64(8);
587        // Start at the upper boundary in every coordinate.
588        let start = vec![BOUNDS.hi(); 4];
589        let (g, _f) =
590            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut fitness, &mut rng);
591        for &x in &g {
592            assert!(
593                x >= BOUNDS.lo() && x <= BOUNDS.hi(),
594                "coord {x} out of bounds {BOUNDS:?}"
595            );
596        }
597    }
598
599    /// Evaluations the given variant needs to drive the 2-D negated sphere above
600    /// `-tol` from a fixed start/seed, or `None` if it never reaches `tol`.
601    fn evals_to_tolerance(variant: HillClimbVariant, tol: f32) -> Option<usize> {
602        let searcher = HillClimbing;
603        let mut params = HillClimbingParams::default_for(BOUNDS);
604        params.variant = variant;
605        params.max_iters = 400;
606        let mut base = NegSphere;
607        let mut counting = Counting::new(&mut base);
608        let mut rng = StdRng::seed_from_u64(99);
609        let start = vec![3.0_f32, -2.0];
610        let (_g, fit) =
611            LocalSearch::<TestBackend>::refine(&searcher, &params, start, &mut counting, &mut rng);
612        if fit > -tol {
613            Some(counting.calls)
614        } else {
615            None
616        }
617    }
618
619    #[test]
620    fn best_improvement_competitive_with_first_improvement() {
621        // On a smooth unimodal landscape, BestImprovement should reach the
622        // same tolerance using no more evaluations than FirstImprovement.
623        let tol = 1e-2_f32;
624        let first = evals_to_tolerance(HillClimbVariant::FirstImprovement, tol)
625            .expect("first-improvement should reach tolerance");
626        let best = evals_to_tolerance(HillClimbVariant::BestImprovement, tol)
627            .expect("best-improvement should reach tolerance");
628        assert!(
629            best <= first,
630            "best-improvement evals {best} should be <= first-improvement evals {first}"
631        );
632    }
633
634    #[test]
635    #[allow(clippy::float_cmp)]
636    fn same_seed_is_bit_identical() {
637        let searcher = HillClimbing;
638        let params = HillClimbingParams::default_for(BOUNDS);
639        let start = vec![2.0_f32, -3.0, 1.5];
640
641        let mut fitness_a = NegSphere;
642        let mut rng_a = StdRng::seed_from_u64(123);
643        let (g_a, f_a) = LocalSearch::<TestBackend>::refine(
644            &searcher,
645            &params,
646            start.clone(),
647            &mut fitness_a,
648            &mut rng_a,
649        );
650
651        let mut fitness_b = NegSphere;
652        let mut rng_b = StdRng::seed_from_u64(123);
653        let (g_b, f_b) = LocalSearch::<TestBackend>::refine(
654            &searcher,
655            &params,
656            start,
657            &mut fitness_b,
658            &mut rng_b,
659        );
660
661        assert_eq!(g_a, g_b);
662        assert_eq!(f_a, f_b);
663    }
664
665    #[test]
666    fn known_fitness_skips_exactly_the_seeding_eval() {
667        // On a flat landscape the search terminates by step-underflow long
668        // before a large budget, so the eval count is the probe walk plus the
669        // seed. The seeding eval draws no rng, so both entry points walk the
670        // identical probe sequence — the hint path simply omits the seed, i.e.
671        // exactly one fewer evaluation.
672        let searcher = HillClimbing;
673        let mut params = HillClimbingParams::default_for(BOUNDS);
674        params.max_iters = 10_000;
675        let start = vec![1.0_f32, 2.0, 3.0];
676
677        let refine_evals = {
678            let mut base = Flat;
679            let mut counting = Counting::new(&mut base);
680            let mut rng = StdRng::seed_from_u64(21);
681            let _ = LocalSearch::<TestBackend>::refine(
682                &searcher,
683                &params,
684                start.clone(),
685                &mut counting,
686                &mut rng,
687            );
688            counting.calls
689        };
690        let hint_evals = {
691            let mut base = Flat;
692            let mut counting = Counting::new(&mut base);
693            let mut rng = StdRng::seed_from_u64(21);
694            let _ = LocalSearch::<TestBackend>::refine_with_known_fitness(
695                &searcher,
696                &params,
697                start.clone(),
698                1.0, // Flat fitness of the start
699                &mut counting,
700                &mut rng,
701            );
702            counting.calls
703        };
704        assert_eq!(
705            hint_evals + 1,
706            refine_evals,
707            "hint path must skip exactly the seeding eval ({hint_evals} vs {refine_evals})"
708        );
709    }
710
711    #[test]
712    fn nan_hint_does_not_propagate() {
713        // A NaN hint must be sanitized to -inf so finite probes displace it; the
714        // returned fitness is finite and honest for the returned genome.
715        let searcher = HillClimbing;
716        let params = HillClimbingParams::default_for(BOUNDS);
717        let mut fitness = NegSphere;
718        let mut rng = StdRng::seed_from_u64(22);
719        let start = vec![2.0_f32, -1.0];
720        let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
721            &searcher,
722            &params,
723            start,
724            f32::NAN,
725            &mut fitness,
726            &mut rng,
727        );
728        assert!(fit.is_finite(), "NaN hint must be sanitized, got {fit}");
729        let fresh = fitness.evaluate_one(&g);
730        approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
731    }
732}