Skip to main content

solverforge_solver/phase/localsearch/acceptor/
simulated_annealing.rs

1// Simulated annealing acceptor with lexicographic score-level temperatures.
2
3use rand::rngs::SmallRng;
4use rand::{RngExt, SeedableRng};
5use solverforge_core::domain::PlanningSolution;
6use solverforge_core::score::{Score, ScoreLevel};
7
8use super::Acceptor;
9use crate::heuristic::r#move::MoveTabuSignature;
10
11const DEFAULT_DECAY_RATE: f64 = 0.999985;
12const DEFAULT_HILL_CLIMBING_TEMPERATURE: f64 = 1.0e-9;
13const DEFAULT_CALIBRATION_SAMPLE_SIZE: usize = 128;
14const DEFAULT_TARGET_ACCEPTANCE_PROBABILITY: f64 = 0.80;
15const DEFAULT_FALLBACK_TEMPERATURE: f64 = 1.0;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum HardRegressionPolicy {
19    TemperatureControlled,
20    NeverAcceptHardRegression,
21}
22
23#[derive(Debug, Clone, Copy)]
24pub struct SimulatedAnnealingCalibration {
25    pub sample_size: usize,
26    pub target_acceptance_probability: f64,
27    pub fallback_temperature: f64,
28}
29
30impl Default for SimulatedAnnealingCalibration {
31    fn default() -> Self {
32        Self {
33            sample_size: DEFAULT_CALIBRATION_SAMPLE_SIZE,
34            target_acceptance_probability: DEFAULT_TARGET_ACCEPTANCE_PROBABILITY,
35            fallback_temperature: DEFAULT_FALLBACK_TEMPERATURE,
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41enum TemperatureSeed {
42    Single(f64),
43    PerLevel(Vec<f64>),
44    Calibrated(SimulatedAnnealingCalibration),
45}
46
47#[derive(Debug, Clone)]
48struct CalibrationState {
49    config: SimulatedAnnealingCalibration,
50    samples_by_level: Vec<Vec<i64>>,
51    samples_seen: usize,
52}
53
54impl CalibrationState {
55    fn new(level_count: usize, config: SimulatedAnnealingCalibration) -> Self {
56        let reserve_per_level = (config.sample_size / level_count.max(1)).max(1);
57        Self {
58            config,
59            samples_by_level: (0..level_count)
60                .map(|_| Vec::with_capacity(reserve_per_level))
61                .collect(),
62            samples_seen: 0,
63        }
64    }
65
66    fn record(&mut self, level: usize, delta_abs: i64) -> bool {
67        self.samples_by_level[level].push(delta_abs);
68        self.samples_seen += 1;
69        self.samples_seen >= self.config.sample_size
70    }
71
72    fn temperatures(&self) -> Vec<f64> {
73        let denominator = -self.config.target_acceptance_probability.ln();
74        self.samples_by_level
75            .iter()
76            .map(|samples| {
77                if samples.is_empty() {
78                    self.config.fallback_temperature
79                } else {
80                    let total: i128 = samples.iter().map(|&value| i128::from(value)).sum();
81                    let mean = total as f64 / samples.len() as f64;
82                    (mean / denominator).max(self.config.fallback_temperature)
83                }
84            })
85            .collect()
86    }
87}
88
89/* Simulated annealing acceptor using first-differing-level Boltzmann acceptance.
90
91Improving or equal moves are accepted unconditionally. Worsening moves are
92classified by the first score level that differs, and the acceptance probability
93uses only that level's delta and temperature. Lower-priority levels never mask
94higher-priority regressions.
95*/
96#[derive(Debug, Clone)]
97pub struct SimulatedAnnealingAcceptor {
98    temperature_seed: TemperatureSeed,
99    starting_temperatures: Vec<f64>,
100    current_temperatures: Vec<f64>,
101    decay_rate: f64,
102    hill_climbing_temperature: f64,
103    hard_regression_policy: HardRegressionPolicy,
104    calibration_state: Option<CalibrationState>,
105    rng: SmallRng,
106    level_count: usize,
107}
108
109impl SimulatedAnnealingAcceptor {
110    /// Creates an acceptor with the same delta temperature for every score level.
111    pub fn new(starting_temperature: f64, decay_rate: f64) -> Self {
112        Self::from_seed(
113            TemperatureSeed::Single(starting_temperature),
114            decay_rate,
115            DEFAULT_HILL_CLIMBING_TEMPERATURE,
116            HardRegressionPolicy::TemperatureControlled,
117            SmallRng::from_rng(&mut rand::rng()),
118        )
119    }
120
121    pub fn with_seed(starting_temperature: f64, decay_rate: f64, seed: u64) -> Self {
122        Self::from_seed(
123            TemperatureSeed::Single(starting_temperature),
124            decay_rate,
125            DEFAULT_HILL_CLIMBING_TEMPERATURE,
126            HardRegressionPolicy::TemperatureControlled,
127            SmallRng::seed_from_u64(seed),
128        )
129    }
130
131    pub fn with_level_temperatures(level_temperatures: Vec<f64>, decay_rate: f64) -> Self {
132        Self::with_level_temperatures_and_rng(
133            level_temperatures,
134            decay_rate,
135            DEFAULT_HILL_CLIMBING_TEMPERATURE,
136            HardRegressionPolicy::TemperatureControlled,
137        )
138    }
139
140    pub(crate) fn with_level_temperatures_and_seed(
141        level_temperatures: Vec<f64>,
142        decay_rate: f64,
143        hill_climbing_temperature: f64,
144        hard_regression_policy: HardRegressionPolicy,
145        seed: u64,
146    ) -> Self {
147        Self::from_seed(
148            TemperatureSeed::PerLevel(level_temperatures),
149            decay_rate,
150            hill_climbing_temperature,
151            hard_regression_policy,
152            SmallRng::seed_from_u64(seed),
153        )
154    }
155
156    pub(crate) fn with_level_temperatures_and_rng(
157        level_temperatures: Vec<f64>,
158        decay_rate: f64,
159        hill_climbing_temperature: f64,
160        hard_regression_policy: HardRegressionPolicy,
161    ) -> Self {
162        Self::from_seed(
163            TemperatureSeed::PerLevel(level_temperatures),
164            decay_rate,
165            hill_climbing_temperature,
166            hard_regression_policy,
167            SmallRng::from_rng(&mut rand::rng()),
168        )
169    }
170
171    pub fn auto_calibrate(decay_rate: f64) -> Self {
172        Self::with_calibration(
173            decay_rate,
174            DEFAULT_HILL_CLIMBING_TEMPERATURE,
175            HardRegressionPolicy::TemperatureControlled,
176            SimulatedAnnealingCalibration::default(),
177        )
178    }
179
180    pub(crate) fn auto_calibrate_with_seed(decay_rate: f64, seed: u64) -> Self {
181        Self::with_calibration_and_seed(
182            decay_rate,
183            DEFAULT_HILL_CLIMBING_TEMPERATURE,
184            HardRegressionPolicy::TemperatureControlled,
185            SimulatedAnnealingCalibration::default(),
186            seed,
187        )
188    }
189
190    pub(crate) fn with_calibration(
191        decay_rate: f64,
192        hill_climbing_temperature: f64,
193        hard_regression_policy: HardRegressionPolicy,
194        calibration: SimulatedAnnealingCalibration,
195    ) -> Self {
196        Self::from_seed(
197            TemperatureSeed::Calibrated(calibration),
198            decay_rate,
199            hill_climbing_temperature,
200            hard_regression_policy,
201            SmallRng::from_rng(&mut rand::rng()),
202        )
203    }
204
205    pub(crate) fn with_calibration_and_seed(
206        decay_rate: f64,
207        hill_climbing_temperature: f64,
208        hard_regression_policy: HardRegressionPolicy,
209        calibration: SimulatedAnnealingCalibration,
210        seed: u64,
211    ) -> Self {
212        Self::from_seed(
213            TemperatureSeed::Calibrated(calibration),
214            decay_rate,
215            hill_climbing_temperature,
216            hard_regression_policy,
217            SmallRng::seed_from_u64(seed),
218        )
219    }
220
221    fn from_seed(
222        temperature_seed: TemperatureSeed,
223        decay_rate: f64,
224        hill_climbing_temperature: f64,
225        hard_regression_policy: HardRegressionPolicy,
226        rng: SmallRng,
227    ) -> Self {
228        Self {
229            temperature_seed,
230            starting_temperatures: Vec::new(),
231            current_temperatures: Vec::new(),
232            decay_rate,
233            hill_climbing_temperature,
234            hard_regression_policy,
235            calibration_state: None,
236            rng,
237            level_count: 0,
238        }
239    }
240
241    #[cfg(test)]
242    pub(crate) fn current_temperature_for_level(&self, level: usize) -> f64 {
243        self.current_temperatures[level]
244    }
245
246    fn install_temperatures(&mut self, temperatures: Vec<f64>) {
247        debug_assert_eq!(temperatures.len(), self.level_count);
248        self.starting_temperatures = temperatures.clone();
249        self.current_temperatures = temperatures;
250    }
251
252    fn finalize_calibration_if_ready(&mut self) {
253        let Some(state) = self.calibration_state.take() else {
254            return;
255        };
256        if state.samples_seen >= state.config.sample_size {
257            self.install_temperatures(state.temperatures());
258        } else {
259            self.calibration_state = Some(state);
260        }
261    }
262}
263
264impl Default for SimulatedAnnealingAcceptor {
265    fn default() -> Self {
266        Self::auto_calibrate(DEFAULT_DECAY_RATE)
267    }
268}
269
270fn first_differing_level<Sc: Score>(last_step_score: &Sc, move_score: &Sc) -> Option<usize> {
271    (0..Sc::levels_count())
272        .find(|&level| last_step_score.level_number(level) != move_score.level_number(level))
273}
274
275fn worsening_delta_at_first_difference<Sc: Score>(
276    last_step_score: &Sc,
277    move_score: &Sc,
278) -> Option<(usize, i64)> {
279    let level = first_differing_level(last_step_score, move_score)?;
280    let delta = move_score.level_number(level) - last_step_score.level_number(level);
281    (delta < 0).then_some((level, delta))
282}
283
284fn assert_temperature(value: f64, field: &str) {
285    assert!(
286        value.is_finite() && value >= 0.0,
287        "{field} must be finite and non-negative"
288    );
289}
290
291pub(crate) fn assert_simulated_annealing_parameters(
292    level_temperatures: Option<&[f64]>,
293    level_count: usize,
294    decay_rate: f64,
295    hill_climbing_temperature: f64,
296    calibration: Option<SimulatedAnnealingCalibration>,
297) {
298    assert!(
299        decay_rate.is_finite() && decay_rate > 0.0 && decay_rate <= 1.0,
300        "simulated_annealing decay_rate must be finite and in (0, 1]"
301    );
302    assert_temperature(
303        hill_climbing_temperature,
304        "simulated_annealing hill_climbing_temperature",
305    );
306    if let Some(temperatures) = level_temperatures {
307        assert_eq!(
308            temperatures.len(),
309            level_count,
310            "simulated_annealing level_temperatures length must match score level count"
311        );
312        for temperature in temperatures {
313            assert_temperature(*temperature, "simulated_annealing level_temperatures");
314        }
315    }
316    if let Some(calibration) = calibration {
317        assert!(
318            calibration.sample_size > 0,
319            "simulated_annealing calibration sample_size must be greater than 0"
320        );
321        assert!(
322            calibration.target_acceptance_probability.is_finite()
323                && calibration.target_acceptance_probability > 0.0
324                && calibration.target_acceptance_probability < 1.0,
325            "simulated_annealing calibration target_acceptance_probability must be in (0, 1)"
326        );
327        assert_temperature(
328            calibration.fallback_temperature,
329            "simulated_annealing calibration fallback_temperature",
330        );
331    }
332}
333
334impl<S: PlanningSolution> Acceptor<S> for SimulatedAnnealingAcceptor
335where
336    S::Score: Score,
337{
338    fn is_accepted(
339        &mut self,
340        last_step_score: &S::Score,
341        move_score: &S::Score,
342        _move_signature: Option<&MoveTabuSignature>,
343    ) -> bool {
344        if *move_score >= *last_step_score {
345            return true;
346        }
347
348        let Some((level, delta)) = worsening_delta_at_first_difference(last_step_score, move_score)
349        else {
350            return false;
351        };
352
353        if self.hard_regression_policy == HardRegressionPolicy::NeverAcceptHardRegression
354            && S::Score::level_label(level) == ScoreLevel::Hard
355        {
356            return false;
357        }
358
359        if let Some(state) = self.calibration_state.as_mut() {
360            let ready = state.record(level, delta.saturating_abs());
361            if ready {
362                self.finalize_calibration_if_ready();
363            } else {
364                return false;
365            }
366        }
367
368        let temperature = self.current_temperatures[level];
369        if temperature <= self.hill_climbing_temperature {
370            return false;
371        }
372
373        let probability = ((delta as f64) / temperature).exp();
374        self.rng.random::<f64>() < probability
375    }
376
377    fn phase_started(&mut self, _initial_score: &S::Score) {
378        self.level_count = S::Score::levels_count();
379        self.calibration_state = None;
380        match self.temperature_seed.clone() {
381            TemperatureSeed::Single(temperature) => {
382                let temperatures = vec![temperature; self.level_count];
383                assert_simulated_annealing_parameters(
384                    Some(&temperatures),
385                    self.level_count,
386                    self.decay_rate,
387                    self.hill_climbing_temperature,
388                    None,
389                );
390                self.install_temperatures(temperatures);
391            }
392            TemperatureSeed::PerLevel(temperatures) => {
393                assert_simulated_annealing_parameters(
394                    Some(&temperatures),
395                    self.level_count,
396                    self.decay_rate,
397                    self.hill_climbing_temperature,
398                    None,
399                );
400                self.install_temperatures(temperatures);
401            }
402            TemperatureSeed::Calibrated(calibration) => {
403                assert_simulated_annealing_parameters(
404                    None,
405                    self.level_count,
406                    self.decay_rate,
407                    self.hill_climbing_temperature,
408                    Some(calibration),
409                );
410                self.install_temperatures(vec![0.0; self.level_count]);
411                self.calibration_state = Some(CalibrationState::new(self.level_count, calibration));
412            }
413        }
414    }
415
416    fn step_ended(
417        &mut self,
418        _step_score: &S::Score,
419        _accepted_move_signature: Option<&MoveTabuSignature>,
420    ) {
421        if self.calibration_state.is_some() {
422            return;
423        }
424        for temperature in &mut self.current_temperatures {
425            *temperature *= self.decay_rate;
426            if *temperature < self.hill_climbing_temperature {
427                *temperature = self.hill_climbing_temperature;
428            }
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests;