use core::fmt::Debug;
use burn::tensor::backend::Backend;
use rand::Rng;
use rand_distr::{Distribution as _, Normal};
use crate::fitness::FitnessFn;
use crate::local_search::{LocalSearch, clamp_vec};
use rlevo_core::bounds::Bounds;
#[derive(Debug, Clone)]
pub struct RandomRestartParams<LP: Clone + Debug + Send + Sync> {
pub inner: LP,
pub restarts: usize,
pub bounds: Bounds,
pub perturbation: f32,
}
impl<LP: Clone + Debug + Send + Sync> RandomRestartParams<LP> {
#[must_use]
pub fn default_for(inner: LP, bounds: Bounds) -> Self {
let (lo, hi): (f32, f32) = bounds.into();
debug_assert!(
(hi - lo) > 0.0,
"RandomRestartParams::default_for: zero-width bounds yields perturbation 0 (restarts cannot move)"
);
Self {
inner,
restarts: 2,
bounds,
perturbation: 0.1 * (hi - lo),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct RandomRestart<L> {
inner: L,
}
impl<L> RandomRestart<L> {
#[must_use]
pub fn new(inner: L) -> Self {
Self { inner }
}
}
impl<L> RandomRestart<L> {
fn refine_impl<B: Backend>(
&self,
params: &RandomRestartParams<L::Params>,
genome: &[f32],
known: Option<f32>,
fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
rng: &mut dyn Rng,
) -> (Vec<f32>, f32)
where
L: LocalSearch<B>,
{
assert!(
params.restarts == 0 || params.perturbation > 0.0,
"RandomRestartParams::perturbation must be > 0 when restarts > 0 \
(zero jitter would make every restart a duplicate of run 0)"
);
let (mut best_genome, mut best_fit): (Vec<f32>, f32) = match known {
Some(f) => self.inner.refine_with_known_fitness(
¶ms.inner,
genome.to_vec(),
f,
fitness_fn,
rng,
),
None => self
.inner
.refine(¶ms.inner, genome.to_vec(), fitness_fn, rng),
};
if params.restarts > 0 {
let normal: Normal<f32> = Normal::new(0.0_f32, params.perturbation)
.expect("perturbation std-dev is strictly positive (asserted above)");
for _ in 0..params.restarts {
let mut start: Vec<f32> = genome.to_vec();
for coord in &mut start {
*coord += normal.sample(rng);
}
clamp_vec(&mut start, params.bounds);
let (run_genome, run_fit): (Vec<f32>, f32) =
self.inner.refine(¶ms.inner, start, fitness_fn, rng);
if run_fit > best_fit {
best_fit = run_fit;
best_genome = run_genome;
}
}
}
(best_genome, best_fit)
}
}
impl<B: Backend, L: LocalSearch<B>> LocalSearch<B> for RandomRestart<L> {
type Params = RandomRestartParams<L::Params>;
fn refine(
&self,
params: &RandomRestartParams<L::Params>,
genome: Vec<f32>,
fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
rng: &mut dyn Rng,
) -> (Vec<f32>, f32) {
self.refine_impl::<B>(params, &genome, None, fitness_fn, rng)
}
fn refine_with_known_fitness(
&self,
params: &RandomRestartParams<L::Params>,
genome: Vec<f32>,
known_fitness: f32,
fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
rng: &mut dyn Rng,
) -> (Vec<f32>, f32) {
self.refine_impl::<B>(params, &genome, Some(known_fitness), fitness_fn, rng)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local_search::{HillClimbing, HillClimbingParams};
use burn::backend::Flex;
use rand::rngs::StdRng;
use rand::{RngExt as _, SeedableRng};
type TestBackend = Flex;
const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
struct NegSphere;
impl FitnessFn<Vec<f32>> for NegSphere {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
-x.iter().map(|v| v * v).sum::<f32>()
}
}
struct NegRastrigin;
impl FitnessFn<Vec<f32>> for NegRastrigin {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
use core::f32::consts::PI;
let a = 10.0_f32;
-x.iter()
.map(|&xi| a + xi * xi - a * (2.0 * PI * xi).cos())
.sum::<f32>()
}
}
struct NegRosenbrock;
impl FitnessFn<Vec<f32>> for NegRosenbrock {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
let a = 1.0 - x[0];
let b = x[1] - x[0] * x[0];
-(a * a + 100.0 * b * b)
}
}
struct Flat;
impl FitnessFn<Vec<f32>> for Flat {
fn evaluate_one(&mut self, _x: &Vec<f32>) -> f32 {
1.0
}
}
struct Counting<'a> {
inner: &'a mut dyn FitnessFn<Vec<f32>>,
calls: usize,
}
impl<'a> Counting<'a> {
fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
Self { inner, calls: 0 }
}
}
impl FitnessFn<Vec<f32>> for Counting<'_> {
fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
self.calls += 1;
self.inner.evaluate_one(x)
}
}
fn rr_params(
inner: HillClimbingParams,
restarts: usize,
) -> RandomRestartParams<HillClimbingParams> {
let mut params: RandomRestartParams<HillClimbingParams> =
RandomRestartParams::default_for(inner, BOUNDS);
params.restarts = restarts;
params
}
#[test]
fn budget_is_product_of_runs_and_inner_max_iters() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS).with_max_iters(20);
let restarts = 3_usize;
let params = rr_params(inner.clone(), restarts);
let mut base = Flat;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(1);
let start = vec![1.0_f32, 2.0, 3.0];
let _ =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut counting, &mut rng);
let upper = (restarts + 1) * inner.max_iters();
assert!(
counting.calls <= upper,
"evals {} must not exceed product budget {}",
counting.calls,
upper
);
assert!(
counting.calls > inner.max_iters(),
"evals {} must exceed a single inner run ({}) — restarts must run",
counting.calls,
inner.max_iters()
);
}
#[test]
fn restarts_never_worse_than_zero_same_seed() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS);
let start = vec![3.7_f32, -2.9];
let params_zero = rr_params(inner.clone(), 0);
let mut fit_zero = NegRastrigin;
let mut rng_zero = StdRng::seed_from_u64(42);
let (_g0, f0) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms_zero,
start.clone(),
&mut fit_zero,
&mut rng_zero,
);
let params_three = rr_params(inner, 3);
let mut fit_three = NegRastrigin;
let mut rng_three = StdRng::seed_from_u64(42);
let (_g3, f3) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms_three,
start,
&mut fit_three,
&mut rng_three,
);
assert!(
f3 >= f0,
"restarts=3 ({f3}) must not be worse than restarts=0 ({f0})"
);
}
#[test]
fn restarts_escape_local_basin() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS)
.with_step_size(0.25)
.with_max_iters(120);
let start = vec![4.0_f32, -3.0];
let params_zero = rr_params(inner.clone(), 0);
let mut fit_zero = NegRastrigin;
let mut rng_zero = StdRng::seed_from_u64(7);
let (_g0, f0) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms_zero,
start.clone(),
&mut fit_zero,
&mut rng_zero,
);
let mut params_five = rr_params(inner, 5);
params_five.perturbation = 2.5;
let mut fit_five = NegRastrigin;
let mut rng_five = StdRng::seed_from_u64(7);
let (_g5, f5) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms_five,
start,
&mut fit_five,
&mut rng_five,
);
assert!(
f5 > f0,
"restarts=5 ({f5}) should strictly beat restarts=0 ({f0})"
);
}
#[test]
fn rosenbrock_monotone_non_worsening() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS);
let params = rr_params(inner, 2);
let mut rng = StdRng::seed_from_u64(11);
let (lo, hi): (f32, f32) = BOUNDS.into();
for _ in 0..5 {
let start: Vec<f32> = (0..2)
.map(|_| lo + (hi - lo) * rng.random::<f32>())
.collect();
let mut fitness = NegRosenbrock;
let start_fit = fitness.evaluate_one(&start);
let (_g, fit) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start,
&mut fitness,
&mut rng,
);
assert!(fit >= start_fit, "monotone: {fit} >= {start_fit}");
}
}
#[test]
fn output_len_equals_input_len() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS);
let params = rr_params(inner, 2);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(3);
let (lo, hi): (f32, f32) = BOUNDS.into();
for dim in [1_usize, 2, 5, 10] {
let start: Vec<f32> = (0..dim)
.map(|_| lo + (hi - lo) * rng.random::<f32>())
.collect();
let (g, _f) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start,
&mut fitness,
&mut rng,
);
assert_eq!(g.len(), dim);
}
}
#[test]
fn returned_fitness_matches_fresh_eval() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS);
let params = rr_params(inner, 3);
let mut fitness = NegRastrigin;
let mut rng = StdRng::seed_from_u64(4);
let start = vec![1.3_f32, -2.7];
let (g, fit) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
let fresh = fitness.evaluate_one(&g);
approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
}
#[test]
fn boundary_start_with_large_perturbation_stays_within_bounds() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS)
.with_step_size(4.0)
.with_step_decay(1.0);
let mut params = rr_params(inner, 4);
params.perturbation = 10.0;
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(5);
let start = vec![BOUNDS.hi(); 4];
let (g, _f) =
LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
for &x in &g {
assert!(
x >= BOUNDS.lo() && x <= BOUNDS.hi(),
"coord {x} out of bounds {BOUNDS:?}"
);
}
}
#[test]
#[allow(clippy::float_cmp)]
fn same_seed_is_bit_identical() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS);
let params = rr_params(inner, 4);
let start = vec![2.0_f32, -3.0, 1.5];
let mut fitness_a = NegRastrigin;
let mut rng_a = StdRng::seed_from_u64(123);
let (g_a, f_a) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start.clone(),
&mut fitness_a,
&mut rng_a,
);
let mut fitness_b = NegRastrigin;
let mut rng_b = StdRng::seed_from_u64(123);
let (g_b, f_b) = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start,
&mut fitness_b,
&mut rng_b,
);
assert_eq!(g_a, g_b);
assert_eq!(f_a, f_b);
}
#[test]
fn known_fitness_saves_exactly_one_eval_total() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS).with_max_iters(10_000);
let params = rr_params(inner, 3);
let start = vec![1.0_f32, 2.0, 3.0];
let refine_evals = {
let mut base = Flat;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(51);
let _ = LocalSearch::<TestBackend>::refine(
&searcher,
¶ms,
start.clone(),
&mut counting,
&mut rng,
);
counting.calls
};
let hint_evals = {
let mut base = Flat;
let mut counting = Counting::new(&mut base);
let mut rng = StdRng::seed_from_u64(51);
let _ = LocalSearch::<TestBackend>::refine_with_known_fitness(
&searcher,
¶ms,
start.clone(),
1.0, &mut counting,
&mut rng,
);
counting.calls
};
assert_eq!(
hint_evals + 1,
refine_evals,
"hint must save exactly run 0's seeding eval ({hint_evals} vs {refine_evals})"
);
}
#[test]
fn nan_hint_does_not_propagate() {
let searcher = RandomRestart::new(HillClimbing);
let inner = HillClimbingParams::default_for(BOUNDS);
let params = rr_params(inner, 3);
let mut fitness = NegSphere;
let mut rng = StdRng::seed_from_u64(52);
let start = vec![2.0_f32, -1.0];
let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
&searcher,
¶ms,
start,
f32::NAN,
&mut fitness,
&mut rng,
);
assert!(fit.is_finite(), "NaN hint must be sanitized, got {fit}");
let fresh = fitness.evaluate_one(&g);
approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
}
}