1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum HillClimbVariant {
33 FirstImprovement,
36 BestImprovement,
39}
40
41#[derive(Debug, Clone)]
47pub struct HillClimbingParams {
48 bounds: Bounds,
51 max_iters: usize,
55 step_size: f32,
58 step_decay: f32,
61 variant: HillClimbVariant,
64}
65
66impl HillClimbingParams {
67 #[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 #[must_use]
85 pub fn with_bounds(mut self, bounds: Bounds) -> Self {
86 self.bounds = bounds;
87 self
88 }
89
90 #[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 #[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 #[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 #[must_use]
140 pub fn with_variant(mut self, variant: HillClimbVariant) -> Self {
141 self.variant = variant;
142 self
143 }
144
145 #[must_use]
147 pub fn bounds(&self) -> Bounds {
148 self.bounds
149 }
150
151 #[must_use]
153 pub fn max_iters(&self) -> usize {
154 self.max_iters
155 }
156
157 #[must_use]
159 pub fn step_size(&self) -> f32 {
160 self.step_size
161 }
162
163 #[must_use]
165 pub fn step_decay(&self) -> f32 {
166 self.step_decay
167 }
168
169 #[must_use]
171 pub fn variant(&self) -> HillClimbVariant {
172 self.variant
173 }
174}
175
176#[derive(Debug, Clone, Copy, Default)]
213pub struct HillClimbing;
214
215impl HillClimbing {
216 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 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 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 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 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 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 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 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 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 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 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 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 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, ¶ms, 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, ¶ms, 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 ¶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 = 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, ¶ms, 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 ¶ms,
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 ¶ms,
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 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 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 let start = vec![BOUNDS.hi(); 4];
589 let (g, _f) =
590 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, 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 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, ¶ms, 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 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 ¶ms,
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 ¶ms,
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 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 ¶ms,
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 ¶ms,
697 start.clone(),
698 1.0, &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 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 ¶ms,
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}