solverforge_solver/phase/localsearch/acceptor/simulated_annealing.rs
1// Simulated annealing acceptor with true Boltzmann distribution.
2
3use std::fmt::Debug;
4
5use rand::rngs::SmallRng;
6use rand::{RngExt, SeedableRng};
7use solverforge_core::domain::PlanningSolution;
8use solverforge_core::score::Score;
9
10use super::Acceptor;
11
12/* Simulated annealing acceptor using the Boltzmann distribution.
13
14Accepts improving moves unconditionally. For worsening moves, accepts with
15probability `exp(-delta / T)` where `delta` is the score degradation and
16`T` is the current temperature.
17
18Temperature decays geometrically each step: `T *= decay_rate`.
19
20# Example
21
22```
23use solverforge_solver::phase::localsearch::SimulatedAnnealingAcceptor;
24
25// High initial temperature (explores broadly), slow cooling
26let acceptor = SimulatedAnnealingAcceptor::new(1.0, 0.9999);
27```
28*/
29#[derive(Debug, Clone)]
30pub struct SimulatedAnnealingAcceptor {
31 // Initial temperature.
32 starting_temperature: f64,
33 // Current temperature.
34 current_temperature: f64,
35 // Temperature decay rate per step.
36 decay_rate: f64,
37 // High-quality RNG for acceptance decisions.
38 rng: SmallRng,
39 // Number of score levels, cached after phase_started.
40 level_count: usize,
41}
42
43impl SimulatedAnnealingAcceptor {
44 /// Creates a new simulated annealing acceptor.
45 ///
46 /// # Arguments
47 /// * `starting_temperature` - Initial temperature (higher = more exploration).
48 /// Calibrate to ~20% of the initial hard score magnitude for best results.
49 /// * `decay_rate` - Multiplicative decay per step (e.g., 0.9999 for 30s runs
50 /// at ~10k steps/s gives final T ≈ 0.05 * starting T).
51 pub fn new(starting_temperature: f64, decay_rate: f64) -> Self {
52 Self {
53 starting_temperature,
54 current_temperature: starting_temperature,
55 decay_rate,
56 rng: SmallRng::from_rng(&mut rand::rng()),
57 level_count: 0,
58 }
59 }
60
61 pub fn with_seed(starting_temperature: f64, decay_rate: f64, seed: u64) -> Self {
62 Self {
63 starting_temperature,
64 current_temperature: starting_temperature,
65 decay_rate,
66 rng: SmallRng::seed_from_u64(seed),
67 level_count: 0,
68 }
69 }
70
71 /// Auto-calibrates starting temperature from the initial score.
72 ///
73 /// Sets temperature to 20% of the absolute initial score magnitude,
74 /// ensuring ~80% acceptance probability for moves with delta = |initial_score|.
75 pub fn auto_calibrate(decay_rate: f64) -> Self {
76 Self {
77 starting_temperature: 0.0, // Will be set in phase_started
78 current_temperature: 0.0,
79 decay_rate,
80 rng: SmallRng::from_rng(&mut rand::rng()),
81 level_count: 0,
82 }
83 }
84
85 pub(crate) fn auto_calibrate_with_seed(decay_rate: f64, seed: u64) -> Self {
86 Self {
87 starting_temperature: 0.0, // Will be set in phase_started
88 current_temperature: 0.0,
89 decay_rate,
90 rng: SmallRng::seed_from_u64(seed),
91 level_count: 0,
92 }
93 }
94}
95
96impl Default for SimulatedAnnealingAcceptor {
97 fn default() -> Self {
98 // Auto-calibrate with a decay rate tuned for ~300k steps in 30s.
99 // At 300k steps, decay_rate^300000 ≈ 0.01 when decay_rate ≈ 0.999985.
100 Self::auto_calibrate(0.999985)
101 }
102}
103
104/* Converts a multi-level score difference to a single scalar for SA.
105
106Hard levels are weighted exponentially more than soft levels so that
107hard constraint improvements always dominate the acceptance probability.
108
109NOTE: This is only used by `auto_calibrate` during `phase_started`.
110The hot-path `is_accepted` uses `Score::to_scalar()` directly (zero alloc).
111*/
112fn score_delta_to_scalar(levels: &[i64]) -> f64 {
113 if levels.is_empty() {
114 return 0.0;
115 }
116 if levels.len() == 1 {
117 return levels[0] as f64;
118 }
119 let n = levels.len();
120 let mut scalar = 0.0f64;
121 for (i, &level) in levels.iter().enumerate() {
122 let weight = 10.0f64.powi(6 * (n - 1 - i) as i32);
123 scalar += level as f64 * weight;
124 }
125 scalar
126}
127
128impl<S: PlanningSolution> Acceptor<S> for SimulatedAnnealingAcceptor
129where
130 S::Score: Score,
131{
132 fn is_accepted(&mut self, last_step_score: &S::Score, move_score: &S::Score) -> bool {
133 // Always accept improving or equal moves
134 if *move_score >= *last_step_score {
135 return true;
136 }
137
138 if self.current_temperature <= 0.0 {
139 return false;
140 }
141
142 // Compute score difference: move_score - last_step_score (negative for worsening).
143 // Uses Score::to_scalar() directly — no Vec allocation.
144 let delta = move_score.to_scalar() - last_step_score.to_scalar();
145
146 // delta is negative (worsening move). Acceptance probability = exp(delta / T).
147 let probability = (delta / self.current_temperature).exp();
148 let roll: f64 = self.rng.random::<f64>();
149
150 roll < probability
151 }
152
153 fn phase_started(&mut self, initial_score: &S::Score) {
154 self.level_count = S::Score::levels_count();
155
156 // Auto-calibrate temperature if it was set to 0 (from auto_calibrate())
157 if self.starting_temperature == 0.0 {
158 let levels = initial_score.to_level_numbers();
159 let magnitude = score_delta_to_scalar(&levels).abs();
160 /* Set to 2% of score magnitude. For HardSoftScore, hard levels are
161 weighted by 10^6, so this gives enough room to accept occasional
162 hard-worsening moves at the start while cooling to pure hill-climbing.
163 */
164 self.starting_temperature = if magnitude > 0.0 {
165 magnitude * 0.02
166 } else {
167 1.0
168 };
169 }
170
171 self.current_temperature = self.starting_temperature;
172 }
173
174 fn step_ended(&mut self, _step_score: &S::Score) {
175 self.current_temperature *= self.decay_rate;
176 }
177}
178
179#[cfg(test)]
180#[path = "simulated_annealing_tests.rs"]
181mod tests;