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)]
32pub enum CoolingSchedule {
33 Geometric {
35 factor: f32,
37 },
38 Linear {
40 delta: f32,
42 },
43}
44
45#[derive(Debug, Clone)]
51pub struct SimulatedAnnealingParams {
52 bounds: Bounds,
54 max_iters: usize,
57 initial_temp: f32,
59 cooling: CoolingSchedule,
61 min_temp: f32,
64 step_size: f32,
67}
68
69impl SimulatedAnnealingParams {
70 #[must_use]
75 pub fn default_for(bounds: Bounds) -> Self {
76 let (lo, hi): (f32, f32) = bounds.into();
77 debug_assert!(
78 (hi - lo) > 0.0,
79 "SimulatedAnnealingParams::default_for: zero-width bounds yields step_size 0 (search cannot move)"
80 );
81 Self {
82 bounds,
83 max_iters: 200,
84 initial_temp: 1.0,
85 cooling: CoolingSchedule::Geometric { factor: 0.95 },
86 min_temp: 1e-6,
87 step_size: 0.1 * (hi - lo),
88 }
89 }
90
91 #[must_use]
93 pub fn with_bounds(mut self, bounds: Bounds) -> Self {
94 self.bounds = bounds;
95 self
96 }
97
98 #[must_use]
105 pub fn with_max_iters(mut self, max_iters: usize) -> Self {
106 assert!(
107 max_iters >= 1,
108 "SimulatedAnnealingParams::with_max_iters: max_iters must be >= 1"
109 );
110 self.max_iters = max_iters;
111 self
112 }
113
114 #[must_use]
120 pub fn with_initial_temp(mut self, initial_temp: f32) -> Self {
121 assert!(
122 initial_temp.is_finite() && initial_temp > 0.0,
123 "SimulatedAnnealingParams::with_initial_temp: initial_temp must be finite and > 0"
124 );
125 self.initial_temp = initial_temp;
126 self
127 }
128
129 #[must_use]
138 pub fn with_cooling(mut self, cooling: CoolingSchedule) -> Self {
139 match cooling {
140 CoolingSchedule::Geometric { factor } => assert!(
141 factor.is_finite() && factor > 0.0 && factor < 1.0,
142 "SimulatedAnnealingParams::with_cooling: geometric factor must be in (0, 1)"
143 ),
144 CoolingSchedule::Linear { delta } => assert!(
145 delta.is_finite() && delta > 0.0,
146 "SimulatedAnnealingParams::with_cooling: linear delta must be finite and > 0"
147 ),
148 }
149 self.cooling = cooling;
150 self
151 }
152
153 #[must_use]
159 pub fn with_min_temp(mut self, min_temp: f32) -> Self {
160 assert!(
161 min_temp.is_finite() && min_temp >= 0.0,
162 "SimulatedAnnealingParams::with_min_temp: min_temp must be finite and >= 0"
163 );
164 self.min_temp = min_temp;
165 self
166 }
167
168 #[must_use]
174 pub fn with_step_size(mut self, step_size: f32) -> Self {
175 assert!(
176 step_size.is_finite() && step_size > 0.0,
177 "SimulatedAnnealingParams::with_step_size: step_size must be finite and > 0"
178 );
179 self.step_size = step_size;
180 self
181 }
182
183 #[must_use]
185 pub fn bounds(&self) -> Bounds {
186 self.bounds
187 }
188
189 #[must_use]
191 pub fn max_iters(&self) -> usize {
192 self.max_iters
193 }
194
195 #[must_use]
197 pub fn initial_temp(&self) -> f32 {
198 self.initial_temp
199 }
200
201 #[must_use]
203 pub fn cooling(&self) -> CoolingSchedule {
204 self.cooling
205 }
206
207 #[must_use]
209 pub fn min_temp(&self) -> f32 {
210 self.min_temp
211 }
212
213 #[must_use]
215 pub fn step_size(&self) -> f32 {
216 self.step_size
217 }
218}
219
220#[derive(Debug, Clone, Copy, Default)]
258pub struct SimulatedAnnealing;
259
260impl SimulatedAnnealing {
261 fn refine_impl(
275 params: &SimulatedAnnealingParams,
276 genome: Vec<f32>,
277 known: Option<f32>,
278 fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
279 rng: &mut dyn Rng,
280 ) -> (Vec<f32>, f32) {
281 assert!(
282 params.max_iters >= 1,
283 "SimulatedAnnealingParams::max_iters must be >= 1 (the input genome \
284 is always evaluated once to seed the best-so-far tracker)"
285 );
286 let mut budget: BudgetedEval = BudgetedEval::new(fitness_fn, params.max_iters);
287
288 let initial_fit: f32 = if let Some(f) = known {
292 sanitize_fitness(f)
293 } else {
294 let Some(f) = budget.eval(&genome) else {
295 unreachable!("budget of >= 1 cannot be exhausted before the first eval");
296 };
297 f
298 };
299
300 let mut current: Vec<f32> = genome;
302 let mut current_fit: f32 = initial_fit;
303 let mut best: Vec<f32> = current.clone();
307 let mut best_fit: f32 = current_fit;
308
309 let dim: usize = current.len();
310 if dim == 0 {
311 return (best, best_fit);
312 }
313
314 let mut temp: f32 = params.initial_temp;
319
320 loop {
321 let mut candidate: Vec<f32> = current.clone();
323 for x in &mut candidate {
324 *x += params.step_size * crate::sampling::standard_normal(rng);
325 }
326 clamp_vec(&mut candidate, params.bounds);
327
328 let Some(cand_fit) = budget.eval(&candidate) else {
330 break;
331 };
332
333 if cand_fit > best_fit {
335 best_fit = cand_fit;
336 best.clone_from(&candidate);
337 }
338
339 let delta: f32 = cand_fit - current_fit;
345 let accept: bool = delta >= 0.0 || rng.random::<f32>() < (delta / temp).exp();
346 if accept {
347 current = candidate;
348 current_fit = cand_fit;
349 }
350
351 match params.cooling {
353 CoolingSchedule::Geometric { factor } => temp *= factor,
354 CoolingSchedule::Linear { delta } => temp = (temp - delta).max(0.0),
355 }
356 if temp < params.min_temp {
357 break;
358 }
359 }
360
361 (best, best_fit)
362 }
363}
364
365impl<B: Backend> LocalSearch<B> for SimulatedAnnealing {
366 type Params = SimulatedAnnealingParams;
367
368 fn refine(
372 &self,
373 params: &SimulatedAnnealingParams,
374 genome: Vec<f32>,
375 fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
376 rng: &mut dyn Rng,
377 ) -> (Vec<f32>, f32) {
378 Self::refine_impl(params, genome, None, fitness_fn, rng)
379 }
380
381 fn refine_with_known_fitness(
388 &self,
389 params: &SimulatedAnnealingParams,
390 genome: Vec<f32>,
391 known_fitness: f32,
392 fitness_fn: &mut dyn FitnessFn<Vec<f32>>,
393 rng: &mut dyn Rng,
394 ) -> (Vec<f32>, f32) {
395 Self::refine_impl(params, genome, Some(known_fitness), fitness_fn, rng)
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use burn::backend::Flex;
403 use rand::SeedableRng;
404 use rand::rngs::StdRng;
405
406 type TestBackend = Flex;
407
408 #[test]
409 fn with_setters_override_defaults() {
410 let sa = SimulatedAnnealingParams::default_for(Bounds::new(-2.0, 2.0))
411 .with_max_iters(50)
412 .with_initial_temp(3.0)
413 .with_cooling(CoolingSchedule::Linear { delta: 0.1 })
414 .with_min_temp(0.01)
415 .with_step_size(0.5);
416 assert_eq!(sa.max_iters(), 50);
417 assert!((sa.initial_temp() - 3.0).abs() < 1e-6);
418 assert_eq!(sa.cooling(), CoolingSchedule::Linear { delta: 0.1 });
419 assert!((sa.min_temp() - 0.01).abs() < 1e-6);
420 assert!((sa.step_size() - 0.5).abs() < 1e-6);
421 }
422
423 #[test]
424 #[should_panic(expected = "geometric factor must be in (0, 1)")]
425 fn with_cooling_rejects_out_of_range_geometric_factor() {
426 let _ = SimulatedAnnealingParams::default_for(Bounds::new(-2.0, 2.0))
427 .with_cooling(CoolingSchedule::Geometric { factor: 1.5 });
428 }
429
430 struct NegSphere;
433 impl FitnessFn<Vec<f32>> for NegSphere {
434 fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
435 -x.iter().map(|v| v * v).sum::<f32>()
436 }
437 }
438
439 struct NegRosenbrock;
441 impl FitnessFn<Vec<f32>> for NegRosenbrock {
442 fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
443 let a = 1.0 - x[0];
444 let b = x[1] - x[0] * x[0];
445 -(a * a + 100.0 * b * b)
446 }
447 }
448
449 struct Flat;
451 impl FitnessFn<Vec<f32>> for Flat {
452 fn evaluate_one(&mut self, _x: &Vec<f32>) -> f32 {
453 1.0
454 }
455 }
456
457 struct Counting<'a> {
459 inner: &'a mut dyn FitnessFn<Vec<f32>>,
460 calls: usize,
461 }
462 impl<'a> Counting<'a> {
463 fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
464 Self { inner, calls: 0 }
465 }
466 }
467 impl FitnessFn<Vec<f32>> for Counting<'_> {
468 fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
469 self.calls += 1;
470 self.inner.evaluate_one(x)
471 }
472 }
473
474 struct Recording<'a> {
479 inner: &'a mut dyn FitnessFn<Vec<f32>>,
480 fitnesses: Vec<f32>,
481 }
482 impl<'a> Recording<'a> {
483 fn new(inner: &'a mut dyn FitnessFn<Vec<f32>>) -> Self {
484 Self {
485 inner,
486 fitnesses: Vec::new(),
487 }
488 }
489 }
490 impl FitnessFn<Vec<f32>> for Recording<'_> {
491 fn evaluate_one(&mut self, x: &Vec<f32>) -> f32 {
492 let f = self.inner.evaluate_one(x);
493 self.fitnesses.push(f);
494 f
495 }
496 }
497
498 const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
499
500 fn random_start(rng: &mut StdRng, dim: usize, bounds: Bounds) -> Vec<f32> {
501 let (lo, hi): (f32, f32) = bounds.into();
502 (0..dim)
503 .map(|_| lo + (hi - lo) * rng.random::<f32>())
504 .collect()
505 }
506
507 #[test]
508 fn sphere_d2_improves_substantially() {
509 let searcher = SimulatedAnnealing;
510 let params = SimulatedAnnealingParams::default_for(BOUNDS);
511 let mut fitness = NegSphere;
512 let mut rng = StdRng::seed_from_u64(1);
513 let start = random_start(&mut rng, 2, BOUNDS);
514 let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
515 let (_g, fit) =
516 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
517 assert!(
521 fit > 0.1 * start_fit,
522 "sphere D=2 should improve substantially: best={fit}, start={start_fit}"
523 );
524 }
525
526 #[test]
527 fn sphere_d10_strictly_improves() {
528 let searcher = SimulatedAnnealing;
529 let params = SimulatedAnnealingParams::default_for(BOUNDS);
530 let mut fitness = NegSphere;
531 let mut rng = StdRng::seed_from_u64(2);
532 let start = random_start(&mut rng, 10, BOUNDS);
533 let start_fit: f32 = -start.iter().map(|v| v * v).sum::<f32>();
534 let (_g, fit) =
535 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
536 assert!(fit > start_fit, "expected improvement: {fit} > {start_fit}");
537 }
538
539 #[test]
540 fn output_len_equals_input_len() {
541 let searcher = SimulatedAnnealing;
542 let params = SimulatedAnnealingParams::default_for(BOUNDS);
543 let mut fitness = NegSphere;
544 let mut rng = StdRng::seed_from_u64(3);
545 for dim in [1_usize, 2, 5, 10] {
546 let start = random_start(&mut rng, dim, BOUNDS);
547 let (g, _f) = LocalSearch::<TestBackend>::refine(
548 &searcher,
549 ¶ms,
550 start,
551 &mut fitness,
552 &mut rng,
553 );
554 assert_eq!(g.len(), dim);
555 }
556 }
557
558 #[test]
559 fn returned_fitness_matches_fresh_eval() {
560 let searcher = SimulatedAnnealing;
561 let params = SimulatedAnnealingParams::default_for(BOUNDS);
562 let mut fitness = NegSphere;
563 let mut rng = StdRng::seed_from_u64(4);
564 let start = random_start(&mut rng, 4, BOUNDS);
565 let (g, fit) =
566 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
567 let fresh = fitness.evaluate_one(&g);
568 approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
569 }
570
571 #[test]
572 fn rosenbrock_monotone_non_worsening() {
573 let searcher = SimulatedAnnealing;
576 let params = SimulatedAnnealingParams::default_for(BOUNDS);
577 let mut rng = StdRng::seed_from_u64(5);
578 for _ in 0..6 {
579 let start = random_start(&mut rng, 2, BOUNDS);
580 let mut fitness = NegRosenbrock;
581 let start_fit = fitness.evaluate_one(&start);
582 let (_g, fit) = LocalSearch::<TestBackend>::refine(
583 &searcher,
584 ¶ms,
585 start,
586 &mut fitness,
587 &mut rng,
588 );
589 assert!(fit >= start_fit, "monotone: {fit} >= {start_fit}");
590 }
591 }
592
593 #[test]
594 #[allow(clippy::float_cmp)]
595 fn flat_landscape_terminates_within_budget() {
596 let searcher = SimulatedAnnealing;
597 let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
598 params.max_iters = 37;
599 let mut base = Flat;
600 let mut counting = Counting::new(&mut base);
601 let mut rng = StdRng::seed_from_u64(6);
602 let start = vec![1.0_f32, 2.0, 3.0];
603 let (g, fit) = LocalSearch::<TestBackend>::refine(
604 &searcher,
605 ¶ms,
606 start.clone(),
607 &mut counting,
608 &mut rng,
609 );
610 assert!(
611 counting.calls <= params.max_iters,
612 "evals {} must not exceed budget {}",
613 counting.calls,
614 params.max_iters
615 );
616 assert_eq!(g, start);
619 assert_eq!(fit, 1.0);
620 }
621
622 #[test]
623 #[allow(clippy::float_cmp)]
624 fn same_seed_bit_identical_different_seed_differs() {
625 let searcher = SimulatedAnnealing;
626 let params = SimulatedAnnealingParams::default_for(BOUNDS);
627 let start = vec![2.0_f32, -3.0, 1.5];
628
629 let mut fitness_a = NegSphere;
630 let mut rng_a = StdRng::seed_from_u64(123);
631 let (g_a, f_a) = LocalSearch::<TestBackend>::refine(
632 &searcher,
633 ¶ms,
634 start.clone(),
635 &mut fitness_a,
636 &mut rng_a,
637 );
638
639 let mut fitness_b = NegSphere;
640 let mut rng_b = StdRng::seed_from_u64(123);
641 let (g_b, f_b) = LocalSearch::<TestBackend>::refine(
642 &searcher,
643 ¶ms,
644 start.clone(),
645 &mut fitness_b,
646 &mut rng_b,
647 );
648
649 assert_eq!(g_a, g_b);
652 assert_eq!(f_a, f_b);
653
654 let mut fitness_c = NegSphere;
655 let mut rng_c = StdRng::seed_from_u64(999);
656 let (g_c, _f_c) = LocalSearch::<TestBackend>::refine(
657 &searcher,
658 ¶ms,
659 start,
660 &mut fitness_c,
661 &mut rng_c,
662 );
663 assert_ne!(g_a, g_c);
665 }
666
667 #[test]
668 fn min_temp_early_stop_below_budget() {
669 let searcher = SimulatedAnnealing;
672 let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
673 params.max_iters = 1000;
674 params.initial_temp = 1e-3;
675 params.min_temp = 1e-1;
676 params.cooling = CoolingSchedule::Geometric { factor: 0.5 };
677 let mut base = NegSphere;
678 let mut counting = Counting::new(&mut base);
679 let mut rng = StdRng::seed_from_u64(7);
680 let start = vec![1.0_f32, -1.0];
681 let _ =
682 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut counting, &mut rng);
683 assert!(
684 counting.calls < params.max_iters,
685 "min_temp early stop: evals {} should be < budget {}",
686 counting.calls,
687 params.max_iters
688 );
689 }
690
691 #[test]
692 fn boundary_start_stays_within_bounds() {
693 let searcher = SimulatedAnnealing;
694 let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
695 params.step_size = 4.0;
697 let mut fitness = NegSphere;
698 let mut rng = StdRng::seed_from_u64(8);
699 let start = vec![BOUNDS.hi(); 4];
701 let (g, _f) =
702 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut fitness, &mut rng);
703 for &x in &g {
704 assert!(
705 x >= BOUNDS.lo() && x <= BOUNDS.hi(),
706 "coord {x} out of bounds {BOUNDS:?}"
707 );
708 }
709 }
710
711 #[test]
712 fn uphill_moves_accepted_at_high_temperature() {
713 let searcher = SimulatedAnnealing;
723 let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
724 params.max_iters = 200;
725 params.initial_temp = 1e9;
726 params.min_temp = 1e-9;
727 params.step_size = 0.5;
728 let mut base = NegSphere;
729 let mut recording = Recording::new(&mut base);
730 let mut rng = StdRng::seed_from_u64(11);
731 let start = vec![0.05_f32, -0.05];
732 let _ =
733 LocalSearch::<TestBackend>::refine(&searcher, ¶ms, start, &mut recording, &mut rng);
734
735 let mut running_best = f32::NEG_INFINITY;
742 let mut worse_than_best = 0_usize;
743 for &f in &recording.fitnesses {
744 if f < running_best {
745 worse_than_best += 1;
746 }
747 if f > running_best {
748 running_best = f;
749 }
750 }
751 assert!(
752 worse_than_best >= 3,
753 "expected sustained worsening exploration at high temperature, saw {worse_than_best} \
754 worse-than-best evaluations"
755 );
756 }
757
758 #[test]
759 fn known_fitness_skips_exactly_the_seeding_eval() {
760 let searcher = SimulatedAnnealing;
765 let mut params = SimulatedAnnealingParams::default_for(BOUNDS);
766 params.max_iters = 10_000;
767 let start = vec![1.0_f32, 2.0, 3.0];
768
769 let refine_evals = {
770 let mut base = Flat;
771 let mut counting = Counting::new(&mut base);
772 let mut rng = StdRng::seed_from_u64(31);
773 let _ = LocalSearch::<TestBackend>::refine(
774 &searcher,
775 ¶ms,
776 start.clone(),
777 &mut counting,
778 &mut rng,
779 );
780 counting.calls
781 };
782 let hint_evals = {
783 let mut base = Flat;
784 let mut counting = Counting::new(&mut base);
785 let mut rng = StdRng::seed_from_u64(31);
786 let _ = LocalSearch::<TestBackend>::refine_with_known_fitness(
787 &searcher,
788 ¶ms,
789 start.clone(),
790 1.0, &mut counting,
792 &mut rng,
793 );
794 counting.calls
795 };
796 assert_eq!(
797 hint_evals + 1,
798 refine_evals,
799 "hint path must skip exactly the seeding eval ({hint_evals} vs {refine_evals})"
800 );
801 }
802
803 #[test]
804 fn nan_hint_does_not_propagate() {
805 let searcher = SimulatedAnnealing;
806 let params = SimulatedAnnealingParams::default_for(BOUNDS);
807 let mut fitness = NegSphere;
808 let mut rng = StdRng::seed_from_u64(32);
809 let start = vec![2.0_f32, -1.0];
810 let (g, fit) = LocalSearch::<TestBackend>::refine_with_known_fitness(
811 &searcher,
812 ¶ms,
813 start,
814 f32::NAN,
815 &mut fitness,
816 &mut rng,
817 );
818 assert!(fit.is_finite(), "NaN hint must be sanitized, got {fit}");
819 let fresh = fitness.evaluate_one(&g);
820 approx::assert_relative_eq!(fit, fresh, epsilon = 1e-6);
821 }
822}