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(¶ms.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, ¶ms, 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 ¶ms.inner,
201 genome.to_vec(),
202 f,
203 fitness_fn,
204 rng,
205 ),
206 None => self
207 .inner
208 .refine(¶ms.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(¶ms.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, ¶ms, 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 ¶ms_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 ¶ms_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 ¶ms_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 ¶ms_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 ¶ms,
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 ¶ms,
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, ¶ms, 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, ¶ms, 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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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}