Skip to main content

u_nesting_core/
sa.rs

1//! Simulated Annealing framework for optimization.
2//!
3//! # Architecture
4//!
5//! This module maintains its own SA loop rather than delegating to u-metaheur's
6//! SA runner. u-metaheur uses `cost(&self, &Solution) -> f64` (immutable),
7//! while u-nesting uses `evaluate(&self, &mut Solution)` (mutable) for
8//! consistency with the GA/BRKGA evaluation pattern.
9//!
10//! Additionally, this module provides features not available in u-metaheur SA:
11//! - `NeighborhoodOperator` enum (5 operators) with `available_operators()`
12//! - `PermutationSolution` with built-in swap/relocate/inversion/rotation/chain
13//! - Reheating with configurable threshold and factor
14//! - Adaptive cooling schedule
15//!
16//! The rand 0.9 API is shared with u-metaheur for ecosystem compatibility.
17
18use rand::prelude::*;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::Duration;
22use u_metaheur::TemperatureScale;
23
24use crate::error::{check_above, check_at_least, check_range, Result};
25use crate::result::StopReason;
26use crate::timing::{expired, Timer};
27
28#[cfg(feature = "serde")]
29use serde::{Deserialize, Serialize};
30
31/// Cooling schedule types for Simulated Annealing.
32#[derive(Debug, Clone, Copy, Default)]
33#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
34pub enum CoolingSchedule {
35    /// Geometric cooling: T_new = T * alpha (alpha typically 0.95-0.99).
36    #[default]
37    Geometric,
38    /// Linear cooling: T_new = T - delta.
39    Linear,
40    /// Adaptive cooling: adjusts based on acceptance rate.
41    Adaptive,
42    /// Lundy-Mees: T_new = T / (1 + beta * T).
43    LundyMees,
44}
45
46/// Configuration for Simulated Annealing.
47#[derive(Debug, Clone)]
48#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
49pub struct SaConfig {
50    /// What the temperatures are measured in. The default measures the
51    /// objective's own scale first (see [`TemperatureScale::Calibrated`]), so one
52    /// configuration serves any unit of fitness;
53    /// [`with_initial_temp`](Self::with_initial_temp) sets an absolute
54    /// temperature, [`with_relative_temperature`](Self::with_relative_temperature)
55    /// a calibrated one.
56    #[cfg_attr(feature = "serde", serde(default))]
57    pub temperature_scale: TemperatureScale,
58    /// Initial temperature, in units of `temperature_scale`.
59    pub initial_temp: f64,
60    /// Final (minimum) temperature, in the same unit.
61    pub final_temp: f64,
62    /// Cooling rate (alpha for Geometric, delta for Linear, beta for LundyMees).
63    pub cooling_rate: f64,
64    /// Number of iterations at each temperature level.
65    pub iterations_per_temp: usize,
66    /// Maximum total iterations (None = temperature-based stopping only).
67    pub max_iterations: Option<u64>,
68    /// Cooling schedule type.
69    pub cooling_schedule: CoolingSchedule,
70    /// Maximum time limit (None = unlimited).
71    pub time_limit: Option<Duration>,
72    /// Target fitness to stop early (None = run until temperature limit).
73    pub target_fitness: Option<f64>,
74    /// Enable reheating when stagnation detected.
75    pub enable_reheating: bool,
76    /// Stagnation threshold for reheating.
77    pub reheat_threshold: u64,
78    /// Reheat factor (multiplier for current temperature).
79    pub reheat_factor: f64,
80}
81
82impl Default for SaConfig {
83    fn default() -> Self {
84        Self {
85            temperature_scale: TemperatureScale::default(),
86            initial_temp: 1.0,
87            final_temp: 0.001,
88            cooling_rate: 0.95,
89            iterations_per_temp: 100,
90            max_iterations: Some(100_000),
91            cooling_schedule: CoolingSchedule::Geometric,
92            time_limit: None,
93            target_fitness: None,
94            enable_reheating: false,
95            reheat_threshold: 1000,
96            reheat_factor: 2.0,
97        }
98    }
99}
100
101impl SaConfig {
102    /// Creates a new configuration with default values.
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Sets the initial temperature **in the unit of the fitness**
108    /// ([`TemperatureScale::Absolute`]).
109    pub fn with_initial_temp(mut self, temp: f64) -> Self {
110        self.temperature_scale = TemperatureScale::Absolute;
111        self.initial_temp = temp;
112        self
113    }
114
115    /// Sets the start and the floor as multiples of the fitness scale measured
116    /// over `samples` neighbours of the initial solution
117    /// ([`TemperatureScale::Calibrated`]). Each sample is a full evaluation, so a
118    /// problem whose evaluation is expensive wants few (the measurement stops at
119    /// the time limit).
120    pub fn with_relative_temperature(
121        mut self,
122        initial: f64,
123        final_temp: f64,
124        samples: usize,
125    ) -> Self {
126        self.temperature_scale = TemperatureScale::Calibrated { samples };
127        self.initial_temp = initial;
128        self.final_temp = final_temp;
129        self
130    }
131
132    /// Sets the final temperature.
133    pub fn with_final_temp(mut self, temp: f64) -> Self {
134        self.final_temp = temp;
135        self
136    }
137
138    /// Sets the cooling rate.
139    pub fn with_cooling_rate(mut self, rate: f64) -> Self {
140        self.cooling_rate = rate;
141        self
142    }
143
144    /// Checks every field against its range; the builders store what they are
145    /// given.
146    ///
147    /// # Errors
148    /// `ConfigError` naming the first field out of range: `cooling_rate`
149    /// outside `[0.001, 0.9999]`, temperatures that are not finite and
150    /// positive, `final_temp` above `initial_temp`, `iterations_per_temp`
151    /// below 1, `reheat_factor` below 1.
152    pub fn validate(&self) -> Result<()> {
153        check_range("cooling_rate", self.cooling_rate, 0.001, 0.9999)?;
154        check_above("initial_temp", self.initial_temp, 0.0)?;
155        check_above("final_temp", self.final_temp, 0.0)?;
156        check_range("final_temp", self.final_temp, 0.0, self.initial_temp)?;
157        check_at_least("iterations_per_temp", self.iterations_per_temp as f64, 1.0)?;
158        check_at_least("reheat_factor", self.reheat_factor, 1.0)?;
159        if let TemperatureScale::Calibrated { samples } = self.temperature_scale {
160            check_at_least("temperature_scale.samples", samples as f64, 1.0)?;
161        }
162        Ok(())
163    }
164
165    /// Sets the iterations per temperature level.
166    pub fn with_iterations_per_temp(mut self, iterations: usize) -> Self {
167        self.iterations_per_temp = iterations;
168        self
169    }
170
171    /// Sets the maximum iterations.
172    pub fn with_max_iterations(mut self, iterations: u64) -> Self {
173        self.max_iterations = Some(iterations);
174        self
175    }
176
177    /// Sets the cooling schedule.
178    pub fn with_cooling_schedule(mut self, schedule: CoolingSchedule) -> Self {
179        self.cooling_schedule = schedule;
180        self
181    }
182
183    /// Sets the time limit.
184    pub fn with_time_limit(mut self, duration: Duration) -> Self {
185        self.time_limit = Some(duration);
186        self
187    }
188
189    /// Sets the target fitness.
190    pub fn with_target_fitness(mut self, fitness: f64) -> Self {
191        self.target_fitness = Some(fitness);
192        self
193    }
194
195    /// Enables reheating.
196    pub fn with_reheating(mut self, threshold: u64, factor: f64) -> Self {
197        self.enable_reheating = true;
198        self.reheat_threshold = threshold;
199        self.reheat_factor = factor;
200        self
201    }
202}
203
204/// Trait for solutions in Simulated Annealing.
205pub trait SaSolution: Clone + Send + Sync {
206    /// Returns the objective value (fitness) of this solution.
207    /// Higher values are better (maximization).
208    fn objective(&self) -> f64;
209
210    /// Sets the objective value.
211    fn set_objective(&mut self, value: f64);
212}
213
214/// Neighborhood operator types.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum NeighborhoodOperator {
217    /// Swap two elements.
218    Swap,
219    /// Relocate an element to a new position.
220    Relocate,
221    /// Reverse a segment (2-opt style).
222    Inversion,
223    /// Rotate/change orientation of an element.
224    Rotation,
225    /// Chain swap (3-opt style).
226    Chain,
227    /// Flip an element's mirror flag (`allow_flip` support, 2D nesting only
228    /// — a solution's `mirrors` vector is harmless-if-unused by problems
229    /// with no mirror concept, same as an all-`false` `rotations` vector is
230    /// for a problem with a single rotation option).
231    MirrorFlip,
232}
233
234/// Trait for problem-specific SA operations.
235pub trait SaProblem: Send + Sync {
236    /// The solution type for this problem.
237    type Solution: SaSolution;
238
239    /// Creates an initial solution.
240    fn initial_solution<R: Rng>(&self, rng: &mut R) -> Self::Solution;
241
242    /// Generates a neighbor solution using the specified operator.
243    fn neighbor<R: Rng>(
244        &self,
245        solution: &Self::Solution,
246        operator: NeighborhoodOperator,
247        rng: &mut R,
248    ) -> Self::Solution;
249
250    /// Evaluates the objective of a solution.
251    fn evaluate(&self, solution: &mut Self::Solution);
252
253    /// Returns available neighborhood operators for this problem.
254    fn available_operators(&self) -> Vec<NeighborhoodOperator> {
255        vec![
256            NeighborhoodOperator::Swap,
257            NeighborhoodOperator::Relocate,
258            NeighborhoodOperator::Inversion,
259        ]
260    }
261
262    /// Called after each temperature level (for progress reporting).
263    fn on_temperature_change(
264        &self,
265        _temperature: f64,
266        _iteration: u64,
267        _best: &Self::Solution,
268        _current: &Self::Solution,
269    ) {
270        // Default: do nothing
271    }
272}
273
274/// Progress information during SA execution.
275#[derive(Debug, Clone)]
276pub struct SaProgress {
277    /// Current temperature.
278    pub temperature: f64,
279    /// Current iteration number.
280    pub iteration: u64,
281    /// Best fitness so far.
282    pub best_fitness: f64,
283    /// Current fitness.
284    pub current_fitness: f64,
285    /// Acceptance rate at current temperature.
286    pub acceptance_rate: f64,
287    /// Elapsed time since start.
288    pub elapsed: Duration,
289    /// Whether the algorithm is still running.
290    pub running: bool,
291}
292
293/// Result of a SA run.
294#[derive(Debug, Clone)]
295pub struct SaResult<S: SaSolution> {
296    /// The best solution found.
297    pub best: S,
298    /// Final temperature reached, in the unit of the fitness.
299    pub final_temperature: f64,
300    /// The factor the configured temperatures were multiplied by: the measured
301    /// fitness scale of a calibrated run, `1` for an absolute one.
302    pub temperature_unit: f64,
303    /// Total iterations performed.
304    pub iterations: u64,
305    /// Total elapsed time.
306    pub elapsed: Duration,
307    /// Why the run stopped: [`StopReason::Completed`] when the schedule
308    /// cooled to its final temperature.
309    pub stop_reason: StopReason,
310    /// Number of reheats performed.
311    pub reheat_count: u32,
312    /// Fitness history (sampled at temperature changes).
313    pub history: Vec<f64>,
314}
315
316/// Simulated Annealing runner.
317pub struct SaRunner<P: SaProblem> {
318    config: SaConfig,
319    problem: P,
320    cancelled: Arc<AtomicBool>,
321}
322
323impl<P: SaProblem> SaRunner<P> {
324    /// Creates a new SA runner.
325    ///
326    /// # Errors
327    /// As [`SaConfig::validate`].
328    pub fn new(config: SaConfig, problem: P) -> Result<Self> {
329        Self::with_cancellation(config, problem, Arc::new(AtomicBool::new(false)))
330    }
331
332    /// Creates a runner that stops when `cancelled` is set — the caller's own
333    /// flag, observed directly, so no thread is needed to forward it.
334    ///
335    /// # Errors
336    /// As [`SaConfig::validate`].
337    pub fn with_cancellation(
338        config: SaConfig,
339        problem: P,
340        cancelled: Arc<AtomicBool>,
341    ) -> Result<Self> {
342        config.validate()?;
343        Ok(Self {
344            config,
345            problem,
346            cancelled,
347        })
348    }
349
350    /// Returns a handle to cancel the algorithm.
351    pub fn cancel_handle(&self) -> Arc<AtomicBool> {
352        self.cancelled.clone()
353    }
354
355    /// Runs the Simulated Annealing algorithm.
356    pub fn run(&self) -> SaResult<P::Solution> {
357        self.run_with_rng(&mut rand::rng())
358    }
359
360    /// Runs the Simulated Annealing algorithm with a specific RNG.
361    pub fn run_with_rng<R: Rng>(&self, rng: &mut R) -> SaResult<P::Solution> {
362        self.run_with_rng_and_progress::<R, fn(SaProgress)>(rng, None)
363    }
364
365    /// Runs the Simulated Annealing algorithm with a specific RNG, reporting
366    /// each step to `progress_callback` when one is given. The
367    /// callback only observes: the run is the same with or without it.
368    pub fn run_with_rng_and_progress<R: Rng, F>(
369        &self,
370        rng: &mut R,
371        progress_callback: Option<F>,
372    ) -> SaResult<P::Solution>
373    where
374        F: Fn(SaProgress),
375    {
376        let start = Timer::now();
377        let report = |progress: SaProgress| {
378            if let Some(ref callback) = progress_callback {
379                callback(progress);
380            }
381        };
382        let mut history = Vec::new();
383
384        // Initialize
385        let mut current = self.problem.initial_solution(rng);
386        self.problem.evaluate(&mut current);
387        let mut best = current.clone();
388        let mut best_fitness = best.objective();
389
390        // Temperatures are configured in units of `scale` (1 when absolute); a
391        // calibrated run measures it on neighbours of the start, which are not
392        // iterations and stop at the time limit.
393        let operators = self.problem.available_operators();
394        let scale = match self.config.temperature_scale {
395            TemperatureScale::Absolute => 1.0,
396            TemperatureScale::Calibrated { samples } => {
397                let from = current.objective();
398                let mut worsenings = Vec::with_capacity(samples);
399                for _ in 0..samples {
400                    if expired(&start, self.config.time_limit)
401                        || self.cancelled.load(Ordering::Relaxed)
402                    {
403                        break;
404                    }
405                    let operator = operators[rng.random_range(0..operators.len())];
406                    let mut neighbor = self.problem.neighbor(&current, operator, rng);
407                    self.problem.evaluate(&mut neighbor);
408                    worsenings.push(from - neighbor.objective());
409                }
410                TemperatureScale::cost_scale(worsenings)
411            }
412        };
413        let initial_temp = self.config.initial_temp * scale;
414        let final_temp = self.config.final_temp * scale;
415        let mut temperature = initial_temp;
416        let mut iteration = 0u64;
417        let reached = |fitness: f64| self.config.target_fitness.is_some_and(|t| fitness >= t);
418        let mut reheat_count = 0u32;
419        let mut stagnation_count = 0u64;
420
421        let temp_delta = if matches!(self.config.cooling_schedule, CoolingSchedule::Linear) {
422            (initial_temp - final_temp)
423                / (self.config.max_iterations.unwrap_or(10000) as f64
424                    / self.config.iterations_per_temp as f64)
425        } else {
426            0.0
427        };
428
429        // For adaptive cooling
430        let mut accepted_count = 0usize;
431        let mut total_count = 0usize;
432
433        // Each exit names its reason; the target is judged first, so a target
434        // met by the last step is reported as met.
435        let stop_reason = loop {
436            if reached(best_fitness) {
437                break StopReason::TargetReached;
438            }
439            if temperature <= final_temp {
440                break StopReason::Completed;
441            }
442            if self
443                .config
444                .max_iterations
445                .is_some_and(|max| iteration >= max)
446            {
447                break StopReason::IterationLimit;
448            }
449            if self.cancelled.load(Ordering::Relaxed) {
450                break StopReason::Cancelled;
451            }
452            if expired(&start, self.config.time_limit) {
453                break StopReason::TimeLimit;
454            }
455
456            // Iterations at this temperature — each evaluation can be a full
457            // placement, so the time limit is checked per iteration, not per step
458            for _ in 0..self.config.iterations_per_temp {
459                if expired(&start, self.config.time_limit) {
460                    break;
461                }
462                iteration += 1;
463                total_count += 1;
464
465                // Select random operator
466                let operator = operators[rng.random_range(0..operators.len())];
467
468                // Generate neighbor
469                let mut neighbor = self.problem.neighbor(&current, operator, rng);
470                self.problem.evaluate(&mut neighbor);
471
472                let current_obj = current.objective();
473                let neighbor_obj = neighbor.objective();
474                let delta = neighbor_obj - current_obj;
475
476                // Accept or reject
477                let accept = if delta >= 0.0 {
478                    // Better solution - always accept
479                    true
480                } else {
481                    // Worse solution - accept with probability exp(delta/T)
482                    let probability = (delta / temperature).exp();
483                    rng.random::<f64>() < probability
484                };
485
486                if accept {
487                    accepted_count += 1;
488                    current = neighbor;
489
490                    // Update best
491                    if current.objective() > best_fitness {
492                        best = current.clone();
493                        best_fitness = best.objective();
494                        stagnation_count = 0;
495                    } else {
496                        stagnation_count += 1;
497                    }
498                } else {
499                    stagnation_count += 1;
500                }
501
502                // A report per step: one step of a large problem can take a
503                // while, and a caller that cancels from its callback has to
504                // be heard between them.
505                report(SaProgress {
506                    temperature,
507                    iteration,
508                    best_fitness,
509                    current_fitness: current.objective(),
510                    acceptance_rate: accepted_count as f64 / total_count.max(1) as f64,
511                    elapsed: start.elapsed(),
512                    running: true,
513                });
514
515                // Check max iterations inside inner loop
516                if let Some(max) = self.config.max_iterations {
517                    if iteration >= max {
518                        break;
519                    }
520                }
521            }
522
523            // Record history
524            history.push(best_fitness);
525
526            // Callback
527            self.problem
528                .on_temperature_change(temperature, iteration, &best, &current);
529
530            // Reheating check
531            if self.config.enable_reheating && stagnation_count >= self.config.reheat_threshold {
532                temperature *= self.config.reheat_factor;
533                temperature = temperature.min(initial_temp);
534                stagnation_count = 0;
535                reheat_count += 1;
536            }
537
538            // Cool down
539            temperature = self.cool_down(
540                temperature,
541                temp_delta,
542                (final_temp, scale),
543                accepted_count,
544                total_count,
545            );
546
547            // Reset adaptive counters
548            accepted_count = 0;
549            total_count = 0;
550        };
551
552        // Final history entry
553        history.push(best_fitness);
554        report(SaProgress {
555            temperature,
556            iteration,
557            best_fitness,
558            current_fitness: current.objective(),
559            acceptance_rate: 0.0,
560            elapsed: start.elapsed(),
561            running: false,
562        });
563
564        SaResult {
565            best,
566            final_temperature: temperature,
567            temperature_unit: scale,
568            iterations: iteration,
569            elapsed: start.elapsed(),
570            stop_reason,
571            reheat_count,
572            history,
573        }
574    }
575
576    /// Apply cooling schedule.
577    fn cool_down(
578        &self,
579        current_temp: f64,
580        delta: f64,
581        (final_temp, scale): (f64, f64),
582        accepted: usize,
583        total: usize,
584    ) -> f64 {
585        match self.config.cooling_schedule {
586            CoolingSchedule::Geometric => current_temp * self.config.cooling_rate,
587            CoolingSchedule::Linear => (current_temp - delta).max(final_temp),
588            CoolingSchedule::Adaptive => {
589                // Adjust cooling rate based on acceptance rate
590                let acceptance_rate = if total > 0 {
591                    accepted as f64 / total as f64
592                } else {
593                    0.5
594                };
595
596                // If acceptance rate is high, cool faster; if low, cool slower
597                let adjusted_rate = if acceptance_rate > 0.5 {
598                    self.config.cooling_rate * 0.95 // Cool faster
599                } else if acceptance_rate < 0.1 {
600                    self.config.cooling_rate.powf(0.5) // Cool slower (sqrt)
601                } else {
602                    self.config.cooling_rate
603                };
604
605                current_temp * adjusted_rate
606            }
607            CoolingSchedule::LundyMees => {
608                // T_new = T / (1 + beta * T)
609                // `cooling_rate` has the unit of 1 / temperature, so per `scale`.
610                current_temp / (1.0 + self.config.cooling_rate / scale * current_temp)
611            }
612        }
613    }
614}
615
616/// One of `count` options other than `current`, uniformly (`count` > 1).
617pub fn another_option<R: Rng>(current: usize, count: usize, rng: &mut R) -> usize {
618    let pick = rng.random_range(0..count - 1);
619    if pick >= current.min(count - 1) {
620        pick + 1
621    } else {
622        pick
623    }
624}
625
626/// Permutation-based solution for SA.
627#[derive(Debug, Clone)]
628pub struct PermutationSolution {
629    /// The permutation (indices).
630    pub sequence: Vec<usize>,
631    /// Rotation/orientation index per item (by item, not by sequence
632    /// position — a reorder moves items, not their rotations).
633    pub rotations: Vec<usize>,
634    /// How many rotation/orientation options each item has: `rotations[i]`
635    /// is always below `rotation_counts[i]`.
636    pub rotation_counts: std::sync::Arc<[usize]>,
637    /// Mirror flag per item (`allow_flip` support, 2D nesting only — see
638    /// [`NeighborhoodOperator::MirrorFlip`]).
639    pub mirrors: Vec<bool>,
640    /// Cached objective value.
641    objective: f64,
642}
643
644impl PermutationSolution {
645    /// The identity order, every item at its first rotation; one item per
646    /// entry of `rotation_counts` (each item's number of options).
647    pub fn new(rotation_counts: impl Into<std::sync::Arc<[usize]>>) -> Self {
648        let rotation_counts = rotation_counts.into();
649        let size = rotation_counts.len();
650        Self {
651            sequence: (0..size).collect(),
652            rotations: vec![0; size],
653            rotation_counts,
654            mirrors: vec![false; size],
655            objective: f64::NEG_INFINITY,
656        }
657    }
658
659    /// A random order, each item at a rotation drawn uniformly from its own
660    /// options.
661    pub fn random<R: Rng>(
662        rotation_counts: impl Into<std::sync::Arc<[usize]>>,
663        rng: &mut R,
664    ) -> Self {
665        let rotation_counts = rotation_counts.into();
666        let size = rotation_counts.len();
667        let mut sequence: Vec<usize> = (0..size).collect();
668        sequence.shuffle(rng);
669
670        let rotations: Vec<usize> = rotation_counts
671            .iter()
672            .map(|&k| rng.random_range(0..k.max(1)))
673            .collect();
674
675        let mirrors: Vec<bool> = (0..size).map(|_| rng.random()).collect();
676
677        Self {
678            sequence,
679            rotations,
680            rotation_counts,
681            mirrors,
682            objective: f64::NEG_INFINITY,
683        }
684    }
685
686    /// Returns the length of the sequence.
687    pub fn len(&self) -> usize {
688        self.sequence.len()
689    }
690
691    /// Returns true if empty.
692    pub fn is_empty(&self) -> bool {
693        self.sequence.is_empty()
694    }
695
696    /// Applies swap operator: swaps two elements in sequence.
697    pub fn apply_swap<R: Rng>(&self, rng: &mut R) -> Self {
698        let mut result = self.clone();
699        if result.sequence.len() < 2 {
700            return result;
701        }
702
703        let i = rng.random_range(0..result.sequence.len());
704        let j = rng.random_range(0..result.sequence.len());
705        result.sequence.swap(i, j);
706        result.objective = f64::NEG_INFINITY;
707        result
708    }
709
710    /// Applies relocate operator: moves an element to a new position.
711    pub fn apply_relocate<R: Rng>(&self, rng: &mut R) -> Self {
712        let mut result = self.clone();
713        if result.sequence.len() < 2 {
714            return result;
715        }
716
717        let from = rng.random_range(0..result.sequence.len());
718        let to = rng.random_range(0..result.sequence.len());
719
720        if from != to {
721            let elem = result.sequence.remove(from);
722            let insert_pos = if to > from { to - 1 } else { to };
723            result
724                .sequence
725                .insert(insert_pos.min(result.sequence.len()), elem);
726        }
727
728        result.objective = f64::NEG_INFINITY;
729        result
730    }
731
732    /// Applies inversion operator: reverses a segment.
733    pub fn apply_inversion<R: Rng>(&self, rng: &mut R) -> Self {
734        let mut result = self.clone();
735        let n = result.sequence.len();
736        if n < 2 {
737            return result;
738        }
739
740        let (mut p1, mut p2) = (rng.random_range(0..n), rng.random_range(0..n));
741        if p1 > p2 {
742            std::mem::swap(&mut p1, &mut p2);
743        }
744
745        result.sequence[p1..=p2].reverse();
746        result.objective = f64::NEG_INFINITY;
747        result
748    }
749
750    /// Applies rotation operator: moves one item that has more than one
751    /// option to another of its options, drawn uniformly. An item with one
752    /// option is never chosen, so the move always changes the solution.
753    pub fn apply_rotation<R: Rng>(&self, rng: &mut R) -> Self {
754        let mut result = self.clone();
755        let turnable: Vec<usize> = (0..result.rotations.len())
756            .filter(|&i| result.rotation_counts.get(i).copied().unwrap_or(1) > 1)
757            .collect();
758        if turnable.is_empty() {
759            return result;
760        }
761
762        let idx = turnable[rng.random_range(0..turnable.len())];
763        result.rotations[idx] =
764            another_option(result.rotations[idx], result.rotation_counts[idx], rng);
765        result.objective = f64::NEG_INFINITY;
766        result
767    }
768
769    /// Applies mirror-flip operator: flips one element's mirror bit
770    /// (`allow_flip` support). Same shape as `apply_rotation` — unconditional
771    /// on the gene, the consuming problem masks it against its own
772    /// per-item mirror-eligibility (e.g. `Geometry2D::allow_flip()`).
773    pub fn apply_mirror_flip<R: Rng>(&self, rng: &mut R) -> Self {
774        let mut result = self.clone();
775        if result.mirrors.is_empty() {
776            return result;
777        }
778
779        let idx = rng.random_range(0..result.mirrors.len());
780        result.mirrors[idx] = !result.mirrors[idx];
781        result.objective = f64::NEG_INFINITY;
782        result
783    }
784
785    /// Applies chain operator: 3-opt style move.
786    pub fn apply_chain<R: Rng>(&self, rng: &mut R) -> Self {
787        let mut result = self.clone();
788        let n = result.sequence.len();
789        if n < 4 {
790            // Fall back to swap for small sequences
791            return self.apply_swap(rng);
792        }
793
794        // Select three distinct positions
795        let mut positions: Vec<usize> = (0..n).collect();
796        positions.shuffle(rng);
797        let mut selected: Vec<usize> = positions.into_iter().take(3).collect();
798        selected.sort();
799
800        let (p1, p2, p3) = (selected[0], selected[1], selected[2]);
801
802        // Rotate segments: [0..p1] [p1..p2] [p2..p3] [p3..n]
803        // New order: [0..p1] [p2..p3] [p1..p2] [p3..n]
804        let seg1: Vec<usize> = result.sequence[..p1].to_vec();
805        let seg2: Vec<usize> = result.sequence[p1..p2].to_vec();
806        let seg3: Vec<usize> = result.sequence[p2..p3].to_vec();
807        let seg4: Vec<usize> = result.sequence[p3..].to_vec();
808
809        result.sequence = [seg1, seg3, seg2, seg4].concat();
810        result.objective = f64::NEG_INFINITY;
811        result
812    }
813}
814
815impl SaSolution for PermutationSolution {
816    fn objective(&self) -> f64 {
817        self.objective
818    }
819
820    fn set_objective(&mut self, value: f64) {
821        self.objective = value;
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn a_rotation_move_turns_only_items_with_options_within_their_own() {
831        use rand::rngs::StdRng;
832        use rand::SeedableRng;
833        let mut rng = StdRng::seed_from_u64(7);
834        let counts: Vec<usize> = vec![1, 3, 1, 8];
835        let start = PermutationSolution::random(counts.clone(), &mut rng);
836        for (r, k) in start.rotations.iter().zip(&counts) {
837            assert!(r < k);
838        }
839        let mut reached = vec![std::collections::HashSet::new(); counts.len()];
840        let mut current = start;
841        for _ in 0..2000 {
842            let next = current.apply_rotation(&mut rng);
843            let changed: Vec<usize> = (0..counts.len())
844                .filter(|&i| next.rotations[i] != current.rotations[i])
845                .collect();
846            assert_eq!(changed.len(), 1, "a move changes exactly one item");
847            assert!(counts[changed[0]] > 1, "a fixed item was turned");
848            for (i, &r) in next.rotations.iter().enumerate() {
849                assert!(r < counts[i]);
850                reached[i].insert(r);
851            }
852            current = next;
853        }
854        // Every option of every item is reachable: 3 and 8 of them.
855        assert_eq!(reached[1].len(), 3);
856        assert_eq!(reached[3].len(), 8);
857        assert_eq!(reached[0].len(), 1);
858        // Nothing to turn: the move leaves the solution as it is.
859        let fixed = PermutationSolution::new(vec![1; 4]);
860        assert_eq!(fixed.apply_rotation(&mut rng).rotations, vec![0; 4]);
861    }
862
863    struct SimpleMaxProblem {
864        size: usize,
865    }
866
867    impl SaProblem for SimpleMaxProblem {
868        type Solution = PermutationSolution;
869
870        fn initial_solution<R: Rng>(&self, rng: &mut R) -> Self::Solution {
871            PermutationSolution::random(vec![1; self.size], rng)
872        }
873
874        fn neighbor<R: Rng>(
875            &self,
876            solution: &Self::Solution,
877            operator: NeighborhoodOperator,
878            rng: &mut R,
879        ) -> Self::Solution {
880            match operator {
881                NeighborhoodOperator::Swap => solution.apply_swap(rng),
882                NeighborhoodOperator::Relocate => solution.apply_relocate(rng),
883                NeighborhoodOperator::Inversion => solution.apply_inversion(rng),
884                NeighborhoodOperator::Rotation => solution.apply_rotation(rng),
885                NeighborhoodOperator::Chain => solution.apply_chain(rng),
886                NeighborhoodOperator::MirrorFlip => solution.apply_mirror_flip(rng),
887            }
888        }
889
890        fn evaluate(&self, solution: &mut Self::Solution) {
891            // Maximize: sequence should be in ascending order
892            // Fitness = negative sum of inversions
893            let mut inversions = 0i64;
894            for i in 0..solution.sequence.len() {
895                for j in (i + 1)..solution.sequence.len() {
896                    if solution.sequence[i] > solution.sequence[j] {
897                        inversions += 1;
898                    }
899                }
900            }
901            solution.set_objective(-inversions as f64);
902        }
903    }
904
905    #[test]
906    fn test_sa_basic() {
907        let config = SaConfig::default()
908            .with_initial_temp(100.0)
909            .with_final_temp(0.1)
910            .with_cooling_rate(0.9)
911            .with_iterations_per_temp(50)
912            .with_max_iterations(5000);
913
914        let problem = SimpleMaxProblem { size: 10 };
915        let runner = SaRunner::new(config, problem).expect("valid config");
916        let result = runner.run();
917
918        // Should find something reasonably good (fewer inversions)
919        assert!(result.best.objective() > -20.0);
920        assert!(result.iterations > 0);
921    }
922
923    /// `SimpleMaxProblem` with every objective multiplied by `unit`.
924    struct ScaledMax {
925        inner: SimpleMaxProblem,
926        unit: f64,
927    }
928
929    impl SaProblem for ScaledMax {
930        type Solution = PermutationSolution;
931
932        fn initial_solution<R: Rng>(&self, rng: &mut R) -> Self::Solution {
933            self.inner.initial_solution(rng)
934        }
935
936        fn neighbor<R: Rng>(
937            &self,
938            solution: &Self::Solution,
939            operator: NeighborhoodOperator,
940            rng: &mut R,
941        ) -> Self::Solution {
942            self.inner.neighbor(solution, operator, rng)
943        }
944
945        fn evaluate(&self, solution: &mut Self::Solution) {
946            self.inner.evaluate(solution);
947            solution.set_objective(solution.objective() * self.unit);
948        }
949    }
950
951    /// A calibrated temperature is relative to the objective: the same
952    /// configuration on the same problem with every objective multiplied by a
953    /// power of two (exact in floating point) follows the same trajectory,
954    /// which an absolute temperature cannot do.
955    #[test]
956    fn a_calibrated_run_does_not_depend_on_the_unit_of_the_objective() {
957        use rand::rngs::StdRng;
958        let config = SaConfig::default()
959            .with_relative_temperature(1.0, 0.01, 20)
960            .with_iterations_per_temp(10)
961            .with_max_iterations(1500);
962        let run = |unit: f64| {
963            let problem = ScaledMax {
964                inner: SimpleMaxProblem { size: 12 },
965                unit,
966            };
967            let runner = SaRunner::new(config.clone(), problem).expect("valid config");
968            runner.run_with_rng(&mut StdRng::seed_from_u64(9))
969        };
970        let (small, large) = (run(1.0), run(1024.0));
971        assert_eq!(small.best.sequence, large.best.sequence);
972        assert_eq!(small.iterations, large.iterations);
973        assert_eq!(small.best.objective() * 1024.0, large.best.objective());
974        assert_eq!(small.temperature_unit * 1024.0, large.temperature_unit);
975    }
976
977    /// An absolute temperature is used as configured.
978    #[test]
979    fn an_absolute_temperature_is_in_the_unit_of_the_objective() {
980        let config = SaConfig::default()
981            .with_initial_temp(10.0)
982            .with_final_temp(0.5)
983            .with_max_iterations(200);
984        let runner = SaRunner::new(config, SimpleMaxProblem { size: 6 }).expect("valid config");
985        let result = runner.run();
986        assert_eq!(result.temperature_unit, 1.0);
987        assert!(result.final_temperature <= 10.0);
988    }
989
990    #[test]
991    fn test_cooling_schedules() {
992        let problem = SimpleMaxProblem { size: 5 };
993
994        for schedule in [
995            CoolingSchedule::Geometric,
996            CoolingSchedule::Linear,
997            CoolingSchedule::Adaptive,
998            CoolingSchedule::LundyMees,
999        ] {
1000            let config = SaConfig::default()
1001                .with_cooling_schedule(schedule)
1002                .with_max_iterations(1000);
1003
1004            let runner = SaRunner::new(config, problem.clone()).expect("valid config");
1005            let result = runner.run();
1006
1007            // Should complete without panic
1008            assert!(result.iterations > 0);
1009        }
1010    }
1011
1012    #[test]
1013    fn test_neighborhood_operators() {
1014        let mut rng = rand::rng();
1015        let solution = PermutationSolution::random(vec![4; 10], &mut rng);
1016
1017        // Test all operators produce valid permutations
1018        let swap = solution.apply_swap(&mut rng);
1019        let relocate = solution.apply_relocate(&mut rng);
1020        let inversion = solution.apply_inversion(&mut rng);
1021        let rotation = solution.apply_rotation(&mut rng);
1022        let chain = solution.apply_chain(&mut rng);
1023
1024        for sol in [&swap, &relocate, &inversion, &rotation, &chain] {
1025            let mut sorted = sol.sequence.clone();
1026            sorted.sort();
1027            assert_eq!(sorted, (0..10).collect::<Vec<_>>());
1028        }
1029    }
1030
1031    #[test]
1032    fn test_reheating() {
1033        let config = SaConfig::default()
1034            .with_initial_temp(10.0)
1035            .with_final_temp(0.1)
1036            .with_max_iterations(500)
1037            .with_reheating(50, 1.5);
1038
1039        let problem = SimpleMaxProblem { size: 8 };
1040        let runner = SaRunner::new(config, problem).expect("valid config");
1041        let result = runner.run();
1042
1043        // Should complete (reheating may or may not trigger)
1044        assert!(result.iterations > 0);
1045    }
1046
1047    impl Clone for SimpleMaxProblem {
1048        fn clone(&self) -> Self {
1049            Self { size: self.size }
1050        }
1051    }
1052}