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