1use 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#[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 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;