Skip to main content

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)]
180mod tests {
181    use super::*;
182    use solverforge_core::domain::PlanningSolution;
183    use solverforge_core::score::{HardSoftScore, SoftScore};
184
185    #[derive(Clone, Debug)]
186    struct SimpleSol {
187        score: Option<SoftScore>,
188    }
189    impl PlanningSolution for SimpleSol {
190        type Score = SoftScore;
191        fn score(&self) -> Option<Self::Score> {
192            self.score
193        }
194        fn set_score(&mut self, score: Option<Self::Score>) {
195            self.score = score;
196        }
197    }
198
199    #[derive(Clone, Debug)]
200    struct HardSoftSol {
201        score: Option<HardSoftScore>,
202    }
203    impl PlanningSolution for HardSoftSol {
204        type Score = HardSoftScore;
205        fn score(&self) -> Option<Self::Score> {
206            self.score
207        }
208        fn set_score(&mut self, score: Option<Self::Score>) {
209            self.score = score;
210        }
211    }
212
213    #[test]
214    fn accepts_improving_moves() {
215        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(1000.0, 0.99, 42);
216        let last = SoftScore::of(-10);
217        let better = SoftScore::of(-5);
218        assert!(Acceptor::<SimpleSol>::is_accepted(
219            &mut acceptor,
220            &last,
221            &better
222        ));
223    }
224
225    #[test]
226    fn accepts_equal_moves() {
227        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(1000.0, 0.99, 42);
228        let score = SoftScore::of(-10);
229        assert!(Acceptor::<SimpleSol>::is_accepted(
230            &mut acceptor,
231            &score,
232            &score
233        ));
234    }
235
236    #[test]
237    fn rejects_at_zero_temperature() {
238        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(0.0, 0.99, 42);
239        acceptor.current_temperature = 0.0;
240        let last = SoftScore::of(-10);
241        let worse = SoftScore::of(-20);
242        assert!(!Acceptor::<SimpleSol>::is_accepted(
243            &mut acceptor,
244            &last,
245            &worse
246        ));
247    }
248
249    #[test]
250    fn high_temperature_accepts_most() {
251        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(1_000_000.0, 0.99, 42);
252        let last = SoftScore::of(-10);
253        let worse = SoftScore::of(-11);
254        let mut accepted = 0;
255        for _ in 0..100 {
256            if Acceptor::<SimpleSol>::is_accepted(&mut acceptor, &last, &worse) {
257                accepted += 1;
258            }
259        }
260        assert!(accepted > 90);
261    }
262
263    #[test]
264    fn low_temperature_rejects_most() {
265        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(0.001, 0.99, 42);
266        let last = SoftScore::of(-10);
267        let worse = SoftScore::of(-20);
268        let mut accepted = 0;
269        for _ in 0..100 {
270            if Acceptor::<SimpleSol>::is_accepted(&mut acceptor, &last, &worse) {
271                accepted += 1;
272            }
273        }
274        assert!(accepted < 5);
275    }
276
277    #[test]
278    fn temperature_decays_each_step() {
279        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(100.0, 0.5, 42);
280        let score = SoftScore::of(0);
281        Acceptor::<SimpleSol>::phase_started(&mut acceptor, &score);
282        assert!((acceptor.current_temperature - 100.0).abs() < f64::EPSILON);
283        Acceptor::<SimpleSol>::step_ended(&mut acceptor, &score);
284        assert!((acceptor.current_temperature - 50.0).abs() < f64::EPSILON);
285        Acceptor::<SimpleSol>::step_ended(&mut acceptor, &score);
286        assert!((acceptor.current_temperature - 25.0).abs() < f64::EPSILON);
287    }
288
289    #[test]
290    fn auto_calibrate_sets_temperature_from_initial_score() {
291        let mut acceptor = SimulatedAnnealingAcceptor::auto_calibrate(0.999);
292        let initial = HardSoftScore::of(-576, 0);
293        Acceptor::<HardSoftSol>::phase_started(&mut acceptor, &initial);
294        // Temperature should be ~2% of 576 * 1_000_000 = 11_520_000
295        assert!(acceptor.current_temperature > 10_000_000.0);
296        assert!(acceptor.current_temperature < 20_000_000.0);
297    }
298
299    #[test]
300    fn seeded_auto_calibrate_matches_unseeded_temperature() {
301        let initial = HardSoftScore::of(-576, 0);
302        let mut seeded = SimulatedAnnealingAcceptor::auto_calibrate_with_seed(0.999, 42);
303        let mut unseeded = SimulatedAnnealingAcceptor::auto_calibrate(0.999);
304
305        Acceptor::<HardSoftSol>::phase_started(&mut seeded, &initial);
306        Acceptor::<HardSoftSol>::phase_started(&mut unseeded, &initial);
307
308        assert_eq!(seeded.starting_temperature, unseeded.starting_temperature);
309        assert_eq!(seeded.current_temperature, unseeded.current_temperature);
310    }
311
312    #[test]
313    fn score_delta_to_scalar_simple() {
314        assert!((score_delta_to_scalar(&[-5]) - -5.0).abs() < f64::EPSILON);
315    }
316
317    #[test]
318    fn score_delta_to_scalar_hard_soft() {
319        let scalar = score_delta_to_scalar(&[-1, -50]);
320        assert!((scalar - -1_000_050.0).abs() < f64::EPSILON);
321    }
322}