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::StdRng;
6use rand::{Rng, 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///
14/// Accepts improving moves unconditionally. For worsening moves, accepts with
15/// probability `exp(-delta / T)` where `delta` is the score degradation and
16/// `T` is the current temperature.
17///
18/// Temperature decays geometrically each step: `T *= decay_rate`.
19///
20/// # Example
21///
22/// ```
23/// use solverforge_solver::phase::localsearch::SimulatedAnnealingAcceptor;
24///
25/// // High initial temperature (explores broadly), slow cooling
26/// let acceptor = SimulatedAnnealingAcceptor::new(1.0, 0.9999);
27/// ```
28#[derive(Debug)]
29pub struct SimulatedAnnealingAcceptor {
30    /// Initial temperature.
31    starting_temperature: f64,
32    /// Current temperature.
33    current_temperature: f64,
34    /// Temperature decay rate per step.
35    decay_rate: f64,
36    /// High-quality RNG for acceptance decisions.
37    rng: StdRng,
38    /// Number of score levels, cached after phase_started.
39    level_count: usize,
40}
41
42impl SimulatedAnnealingAcceptor {
43    /// Creates a new simulated annealing acceptor.
44    ///
45    /// # Arguments
46    /// * `starting_temperature` - Initial temperature (higher = more exploration).
47    ///   Calibrate to ~20% of the initial hard score magnitude for best results.
48    /// * `decay_rate` - Multiplicative decay per step (e.g., 0.9999 for 30s runs
49    ///   at ~10k steps/s gives final T ≈ 0.05 * starting T).
50    pub fn new(starting_temperature: f64, decay_rate: f64) -> Self {
51        Self {
52            starting_temperature,
53            current_temperature: starting_temperature,
54            decay_rate,
55            rng: StdRng::from_os_rng(),
56            level_count: 0,
57        }
58    }
59
60    /// Creates a new SA acceptor with a fixed seed for reproducibility.
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: StdRng::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: StdRng::from_os_rng(),
81            level_count: 0,
82        }
83    }
84}
85
86impl Default for SimulatedAnnealingAcceptor {
87    fn default() -> Self {
88        // Auto-calibrate with a decay rate tuned for ~300k steps in 30s.
89        // At 300k steps, decay_rate^300000 ≈ 0.01 when decay_rate ≈ 0.999985.
90        Self::auto_calibrate(0.999985)
91    }
92}
93
94/// Converts a multi-level score difference to a single scalar for SA.
95///
96/// Hard levels are weighted exponentially more than soft levels so that
97/// hard constraint improvements always dominate the acceptance probability.
98///
99/// NOTE: This is only used by `auto_calibrate` during `phase_started`.
100/// The hot-path `is_accepted` uses `Score::to_scalar()` directly (zero alloc).
101fn score_delta_to_scalar(levels: &[i64]) -> f64 {
102    if levels.is_empty() {
103        return 0.0;
104    }
105    if levels.len() == 1 {
106        return levels[0] as f64;
107    }
108    let n = levels.len();
109    let mut scalar = 0.0f64;
110    for (i, &level) in levels.iter().enumerate() {
111        let weight = 10.0f64.powi(6 * (n - 1 - i) as i32);
112        scalar += level as f64 * weight;
113    }
114    scalar
115}
116
117impl<S: PlanningSolution> Acceptor<S> for SimulatedAnnealingAcceptor
118where
119    S::Score: Score,
120{
121    fn is_accepted(&mut self, last_step_score: &S::Score, move_score: &S::Score) -> bool {
122        // Always accept improving or equal moves
123        if *move_score >= *last_step_score {
124            return true;
125        }
126
127        if self.current_temperature <= 0.0 {
128            return false;
129        }
130
131        // Compute score difference: move_score - last_step_score (negative for worsening).
132        // Uses Score::to_scalar() directly — no Vec allocation.
133        let delta = move_score.to_scalar() - last_step_score.to_scalar();
134
135        // delta is negative (worsening move). Acceptance probability = exp(delta / T).
136        let probability = (delta / self.current_temperature).exp();
137        let roll: f64 = self.rng.random();
138
139        roll < probability
140    }
141
142    fn phase_started(&mut self, initial_score: &S::Score) {
143        self.level_count = S::Score::levels_count();
144
145        // Auto-calibrate temperature if it was set to 0 (from auto_calibrate())
146        if self.starting_temperature == 0.0 {
147            let levels = initial_score.to_level_numbers();
148            let magnitude = score_delta_to_scalar(&levels).abs();
149            // Set to 2% of score magnitude. For HardSoftScore, hard levels are
150            // weighted by 10^6, so this gives enough room to accept occasional
151            // hard-worsening moves at the start while cooling to pure hill-climbing.
152            self.starting_temperature = if magnitude > 0.0 {
153                magnitude * 0.02
154            } else {
155                1.0
156            };
157        }
158
159        self.current_temperature = self.starting_temperature;
160    }
161
162    fn step_ended(&mut self, _step_score: &S::Score) {
163        self.current_temperature *= self.decay_rate;
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use solverforge_core::domain::PlanningSolution;
171    use solverforge_core::score::{HardSoftScore, SimpleScore};
172
173    #[derive(Clone, Debug)]
174    struct SimpleSol {
175        score: Option<SimpleScore>,
176    }
177    impl PlanningSolution for SimpleSol {
178        type Score = SimpleScore;
179        fn score(&self) -> Option<Self::Score> {
180            self.score
181        }
182        fn set_score(&mut self, score: Option<Self::Score>) {
183            self.score = score;
184        }
185    }
186
187    #[derive(Clone, Debug)]
188    struct HardSoftSol {
189        score: Option<HardSoftScore>,
190    }
191    impl PlanningSolution for HardSoftSol {
192        type Score = HardSoftScore;
193        fn score(&self) -> Option<Self::Score> {
194            self.score
195        }
196        fn set_score(&mut self, score: Option<Self::Score>) {
197            self.score = score;
198        }
199    }
200
201    #[test]
202    fn accepts_improving_moves() {
203        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(1000.0, 0.99, 42);
204        let last = SimpleScore::of(-10);
205        let better = SimpleScore::of(-5);
206        assert!(Acceptor::<SimpleSol>::is_accepted(
207            &mut acceptor,
208            &last,
209            &better
210        ));
211    }
212
213    #[test]
214    fn accepts_equal_moves() {
215        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(1000.0, 0.99, 42);
216        let score = SimpleScore::of(-10);
217        assert!(Acceptor::<SimpleSol>::is_accepted(
218            &mut acceptor,
219            &score,
220            &score
221        ));
222    }
223
224    #[test]
225    fn rejects_at_zero_temperature() {
226        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(0.0, 0.99, 42);
227        acceptor.current_temperature = 0.0;
228        let last = SimpleScore::of(-10);
229        let worse = SimpleScore::of(-20);
230        assert!(!Acceptor::<SimpleSol>::is_accepted(
231            &mut acceptor,
232            &last,
233            &worse
234        ));
235    }
236
237    #[test]
238    fn high_temperature_accepts_most() {
239        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(1_000_000.0, 0.99, 42);
240        let last = SimpleScore::of(-10);
241        let worse = SimpleScore::of(-11);
242        let mut accepted = 0;
243        for _ in 0..100 {
244            if Acceptor::<SimpleSol>::is_accepted(&mut acceptor, &last, &worse) {
245                accepted += 1;
246            }
247        }
248        assert!(accepted > 90);
249    }
250
251    #[test]
252    fn low_temperature_rejects_most() {
253        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(0.001, 0.99, 42);
254        let last = SimpleScore::of(-10);
255        let worse = SimpleScore::of(-20);
256        let mut accepted = 0;
257        for _ in 0..100 {
258            if Acceptor::<SimpleSol>::is_accepted(&mut acceptor, &last, &worse) {
259                accepted += 1;
260            }
261        }
262        assert!(accepted < 5);
263    }
264
265    #[test]
266    fn temperature_decays_each_step() {
267        let mut acceptor = SimulatedAnnealingAcceptor::with_seed(100.0, 0.5, 42);
268        let score = SimpleScore::of(0);
269        Acceptor::<SimpleSol>::phase_started(&mut acceptor, &score);
270        assert!((acceptor.current_temperature - 100.0).abs() < f64::EPSILON);
271        Acceptor::<SimpleSol>::step_ended(&mut acceptor, &score);
272        assert!((acceptor.current_temperature - 50.0).abs() < f64::EPSILON);
273        Acceptor::<SimpleSol>::step_ended(&mut acceptor, &score);
274        assert!((acceptor.current_temperature - 25.0).abs() < f64::EPSILON);
275    }
276
277    #[test]
278    fn auto_calibrate_sets_temperature_from_initial_score() {
279        let mut acceptor = SimulatedAnnealingAcceptor::auto_calibrate(0.999);
280        let initial = HardSoftScore::of(-576, 0);
281        Acceptor::<HardSoftSol>::phase_started(&mut acceptor, &initial);
282        // Temperature should be ~2% of 576 * 1_000_000 = 11_520_000
283        assert!(acceptor.current_temperature > 10_000_000.0);
284        assert!(acceptor.current_temperature < 20_000_000.0);
285    }
286
287    #[test]
288    fn score_delta_to_scalar_simple() {
289        assert!((score_delta_to_scalar(&[-5]) - -5.0).abs() < f64::EPSILON);
290    }
291
292    #[test]
293    fn score_delta_to_scalar_hard_soft() {
294        let scalar = score_delta_to_scalar(&[-1, -50]);
295        assert!((scalar - -1_000_050.0).abs() < f64::EPSILON);
296    }
297}