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;