Skip to main content

scirs2_optimize/metaheuristics/
sa.rs

1//! # Simulated Annealing (SA) Metaheuristic
2//!
3//! A comprehensive implementation of Simulated Annealing with:
4//! - Configurable cooling schedules (linear, exponential, logarithmic, adaptive)
5//! - Metropolis acceptance criterion
6//! - Reheating strategy for escaping local optima
7//! - Multi-start SA for improved global search
8//! - Constraint handling via penalty functions
9
10use crate::error::{OptimizeError, OptimizeResult};
11use crate::result::OptimizeResults;
12use scirs2_core::ndarray::{Array1, ArrayView1};
13use scirs2_core::random::rngs::StdRng;
14use scirs2_core::random::{rng, Rng, SeedableRng};
15use scirs2_core::RngExt;
16
17// ---------------------------------------------------------------------------
18// Cooling Schedules
19// ---------------------------------------------------------------------------
20
21/// Cooling schedule for temperature reduction
22#[derive(Debug, Clone)]
23pub enum CoolingSchedule {
24    /// Linear: T(k) = T0 - k * (T0 - Tf) / max_iter
25    Linear,
26    /// Exponential: T(k) = T0 * alpha^k
27    Exponential {
28        /// Cooling rate (0 < alpha < 1)
29        alpha: f64,
30    },
31    /// Logarithmic (Cauchy): T(k) = T0 / (1 + c * ln(1 + k))
32    Logarithmic {
33        /// Scaling constant c > 0
34        c: f64,
35    },
36    /// Adaptive: adjusts cooling rate based on acceptance ratio
37    Adaptive {
38        /// Target acceptance ratio (e.g. 0.44 for Metropolis)
39        target_acceptance: f64,
40        /// Adjustment factor (> 1 speeds up, < 1 slows down cooling)
41        adjustment_factor: f64,
42    },
43}
44
45impl Default for CoolingSchedule {
46    fn default() -> Self {
47        CoolingSchedule::Exponential { alpha: 0.95 }
48    }
49}
50
51/// State for adaptive cooling schedule
52#[derive(Debug, Clone)]
53pub struct AdaptiveCoolingState {
54    /// Number of accepted moves in current window
55    pub accepted: usize,
56    /// Number of total moves in current window
57    pub total: usize,
58    /// Current effective alpha
59    pub effective_alpha: f64,
60    /// Window size for acceptance ratio computation
61    pub window_size: usize,
62}
63
64impl Default for AdaptiveCoolingState {
65    fn default() -> Self {
66        Self {
67            accepted: 0,
68            total: 0,
69            effective_alpha: 0.95,
70            window_size: 100,
71        }
72    }
73}
74
75impl AdaptiveCoolingState {
76    /// Record an acceptance/rejection and return updated alpha
77    fn update(&mut self, accepted: bool, target: f64, adjustment: f64) -> f64 {
78        if accepted {
79            self.accepted += 1;
80        }
81        self.total += 1;
82
83        if self.total >= self.window_size {
84            let ratio = self.accepted as f64 / self.total as f64;
85            // If acceptance too high => cool faster; if too low => cool slower
86            if ratio > target {
87                self.effective_alpha *= adjustment; // speed up cooling
88            } else {
89                self.effective_alpha /= adjustment; // slow down cooling
90            }
91            // Clamp alpha to [0.5, 0.999]
92            self.effective_alpha = self.effective_alpha.clamp(0.5, 0.999);
93            self.accepted = 0;
94            self.total = 0;
95        }
96        self.effective_alpha
97    }
98}
99
100// ---------------------------------------------------------------------------
101// Reheating Strategy
102// ---------------------------------------------------------------------------
103
104/// Reheating strategy for escaping local optima
105#[derive(Debug, Clone)]
106pub enum ReheatingStrategy {
107    /// No reheating
108    None,
109    /// Reheat periodically every N iterations to a fraction of initial temperature
110    Periodic {
111        /// Interval (in iterations) between reheats
112        interval: usize,
113        /// Fraction of initial temperature to reheat to (0, 1]
114        fraction: f64,
115    },
116    /// Reheat when stagnation detected (no improvement for N iterations)
117    Stagnation {
118        /// Number of iterations without improvement to trigger reheat
119        patience: usize,
120        /// Fraction of initial temperature to reheat to
121        fraction: f64,
122    },
123}
124
125impl Default for ReheatingStrategy {
126    fn default() -> Self {
127        ReheatingStrategy::None
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Constraint Handling
133// ---------------------------------------------------------------------------
134
135/// Penalty-based constraint for SA
136#[derive(Debug, Clone)]
137pub struct PenaltyConstraint {
138    /// Penalty coefficient (multiplier for constraint violation)
139    pub penalty_coeff: f64,
140    /// Whether to increase penalty over time
141    pub adaptive: bool,
142    /// Maximum penalty coefficient (for adaptive mode)
143    pub max_penalty: f64,
144}
145
146impl Default for PenaltyConstraint {
147    fn default() -> Self {
148        Self {
149            penalty_coeff: 1000.0,
150            adaptive: false,
151            max_penalty: 1e8,
152        }
153    }
154}
155
156/// Constraint handler for SA
157#[derive(Debug, Clone)]
158pub struct ConstraintHandler {
159    /// Inequality constraints: g_i(x) <= 0
160    /// Each function returns a vector of constraint violations
161    /// Positive values indicate violation
162    pub penalty: PenaltyConstraint,
163}
164
165impl Default for ConstraintHandler {
166    fn default() -> Self {
167        Self {
168            penalty: PenaltyConstraint::default(),
169        }
170    }
171}
172
173impl ConstraintHandler {
174    /// Compute penalty given constraint violation values
175    pub fn compute_penalty(&self, violations: &[f64], iteration: usize, max_iter: usize) -> f64 {
176        let coeff = if self.penalty.adaptive {
177            let progress = iteration as f64 / max_iter.max(1) as f64;
178            let scaled = self.penalty.penalty_coeff * (1.0 + progress * 10.0);
179            scaled.min(self.penalty.max_penalty)
180        } else {
181            self.penalty.penalty_coeff
182        };
183
184        violations
185            .iter()
186            .map(|v| {
187                if *v > 0.0 {
188                    coeff * v * v // quadratic penalty
189                } else {
190                    0.0
191                }
192            })
193            .sum()
194    }
195}
196
197// ---------------------------------------------------------------------------
198// SA Options and Result
199// ---------------------------------------------------------------------------
200
201/// Options for metaheuristic SA optimizer
202#[derive(Debug, Clone)]
203pub struct MetaheuristicSaOptions {
204    /// Maximum number of iterations
205    pub max_iter: usize,
206    /// Initial temperature
207    pub initial_temp: f64,
208    /// Final (minimum) temperature
209    pub final_temp: f64,
210    /// Cooling schedule
211    pub cooling: CoolingSchedule,
212    /// Number of candidate solutions tried at each temperature level
213    pub steps_per_temp: usize,
214    /// Perturbation step size (scaled by dimension)
215    pub step_size: f64,
216    /// Reheating strategy
217    pub reheating: ReheatingStrategy,
218    /// Random seed (None for non-deterministic)
219    pub seed: Option<u64>,
220    /// Search bounds per dimension: (lower, upper)
221    pub bounds: Option<Vec<(f64, f64)>>,
222    /// Tolerance for convergence (stop if best value changes less than this)
223    pub tol: f64,
224}
225
226impl Default for MetaheuristicSaOptions {
227    fn default() -> Self {
228        Self {
229            max_iter: 10_000,
230            initial_temp: 100.0,
231            final_temp: 1e-10,
232            cooling: CoolingSchedule::default(),
233            steps_per_temp: 50,
234            step_size: 1.0,
235            reheating: ReheatingStrategy::None,
236            seed: None,
237            bounds: None,
238            tol: 1e-12,
239        }
240    }
241}
242
243/// Result from SA optimization
244#[derive(Debug, Clone)]
245pub struct MetaheuristicSaResult {
246    /// Best solution found
247    pub x: Array1<f64>,
248    /// Objective value at best solution
249    pub fun: f64,
250    /// Number of function evaluations
251    pub nfev: usize,
252    /// Number of iterations performed
253    pub nit: usize,
254    /// Whether the optimization was successful
255    pub success: bool,
256    /// Termination message
257    pub message: String,
258    /// Final temperature
259    pub final_temperature: f64,
260    /// Acceptance ratio over the run
261    pub acceptance_ratio: f64,
262}
263
264impl MetaheuristicSaResult {
265    /// Convert to standard OptimizeResults
266    pub fn to_optimize_results(&self) -> OptimizeResults<f64> {
267        OptimizeResults {
268            x: self.x.clone(),
269            fun: self.fun,
270            jac: None,
271            hess: None,
272            constr: None,
273            nit: self.nit,
274            nfev: self.nfev,
275            njev: 0,
276            nhev: 0,
277            maxcv: 0,
278            message: self.message.clone(),
279            success: self.success,
280            status: if self.success { 0 } else { 1 },
281        }
282    }
283}
284
285// ---------------------------------------------------------------------------
286// Core SA Optimizer
287// ---------------------------------------------------------------------------
288
289/// Simulated Annealing optimizer
290pub struct SimulatedAnnealingOptimizer {
291    options: MetaheuristicSaOptions,
292    rng: StdRng,
293}
294
295impl SimulatedAnnealingOptimizer {
296    /// Create a new SA optimizer with the given options
297    pub fn new(options: MetaheuristicSaOptions) -> Self {
298        let seed = options.seed.unwrap_or_else(|| rng().random());
299        Self {
300            options,
301            rng: StdRng::seed_from_u64(seed),
302        }
303    }
304
305    /// Compute the next temperature given the schedule
306    fn next_temperature(
307        &self,
308        current_temp: f64,
309        iteration: usize,
310        adaptive_state: &mut AdaptiveCoolingState,
311        accepted: bool,
312    ) -> f64 {
313        match &self.options.cooling {
314            CoolingSchedule::Linear => {
315                let t0 = self.options.initial_temp;
316                let tf = self.options.final_temp;
317                let max_iter = self.options.max_iter;
318                let step = (t0 - tf) / max_iter.max(1) as f64;
319                (current_temp - step).max(tf)
320            }
321            CoolingSchedule::Exponential { alpha } => {
322                (current_temp * alpha).max(self.options.final_temp)
323            }
324            CoolingSchedule::Logarithmic { c } => {
325                let t0 = self.options.initial_temp;
326                let denom = 1.0 + c * ((1.0 + iteration as f64).ln());
327                (t0 / denom).max(self.options.final_temp)
328            }
329            CoolingSchedule::Adaptive {
330                target_acceptance,
331                adjustment_factor,
332            } => {
333                let alpha = adaptive_state.update(accepted, *target_acceptance, *adjustment_factor);
334                (current_temp * alpha).max(self.options.final_temp)
335            }
336        }
337    }
338
339    /// Generate a neighbor solution by perturbing x
340    fn generate_neighbor(&mut self, x: &Array1<f64>) -> Array1<f64> {
341        let ndim = x.len();
342        let mut neighbor = x.clone();
343        // Perturb a random dimension
344        let dim_idx = self.rng.random_range(0..ndim);
345        let perturbation = (self.rng.random::<f64>() * 2.0 - 1.0) * self.options.step_size;
346        neighbor[dim_idx] += perturbation;
347
348        // Enforce bounds if present
349        if let Some(ref bounds) = self.options.bounds {
350            for (i, (lo, hi)) in bounds.iter().enumerate() {
351                neighbor[i] = neighbor[i].clamp(*lo, *hi);
352            }
353        }
354        neighbor
355    }
356
357    /// Metropolis acceptance criterion
358    fn accept(&mut self, delta_e: f64, temperature: f64) -> bool {
359        if delta_e < 0.0 {
360            true // always accept improvements
361        } else if temperature <= 0.0 {
362            false
363        } else {
364            let prob = (-delta_e / temperature).exp();
365            self.rng.random::<f64>() < prob
366        }
367    }
368
369    /// Apply reheating if conditions are met
370    fn maybe_reheat(
371        &self,
372        current_temp: f64,
373        iteration: usize,
374        stagnation_count: usize,
375    ) -> Option<f64> {
376        match &self.options.reheating {
377            ReheatingStrategy::None => None,
378            ReheatingStrategy::Periodic { interval, fraction } => {
379                if *interval > 0 && iteration > 0 && iteration % interval == 0 {
380                    Some(self.options.initial_temp * fraction)
381                } else {
382                    None
383                }
384            }
385            ReheatingStrategy::Stagnation { patience, fraction } => {
386                if stagnation_count >= *patience {
387                    // Only reheat if we've really stagnated and temperature is low
388                    if current_temp < self.options.initial_temp * 0.1 {
389                        Some(self.options.initial_temp * fraction)
390                    } else {
391                        None
392                    }
393                } else {
394                    None
395                }
396            }
397        }
398    }
399
400    /// Run SA optimization on an unconstrained problem
401    pub fn optimize<F>(
402        &mut self,
403        func: F,
404        x0: &Array1<f64>,
405    ) -> OptimizeResult<MetaheuristicSaResult>
406    where
407        F: Fn(&ArrayView1<f64>) -> f64,
408    {
409        self.optimize_constrained(func, x0, None::<fn(&ArrayView1<f64>) -> Vec<f64>>)
410    }
411
412    /// Run SA optimization with optional constraint functions
413    ///
414    /// `constraints_fn` returns a vector of g_i(x) values where g_i(x) > 0 means violation.
415    pub fn optimize_constrained<F, G>(
416        &mut self,
417        func: F,
418        x0: &Array1<f64>,
419        constraints_fn: Option<G>,
420    ) -> OptimizeResult<MetaheuristicSaResult>
421    where
422        F: Fn(&ArrayView1<f64>) -> f64,
423        G: Fn(&ArrayView1<f64>) -> Vec<f64>,
424    {
425        let constraint_handler = ConstraintHandler::default();
426        self.optimize_with_handler(func, x0, constraints_fn, &constraint_handler)
427    }
428
429    /// Run SA optimization with full constraint handler configuration
430    pub fn optimize_with_handler<F, G>(
431        &mut self,
432        func: F,
433        x0: &Array1<f64>,
434        constraints_fn: Option<G>,
435        constraint_handler: &ConstraintHandler,
436    ) -> OptimizeResult<MetaheuristicSaResult>
437    where
438        F: Fn(&ArrayView1<f64>) -> f64,
439        G: Fn(&ArrayView1<f64>) -> Vec<f64>,
440    {
441        if x0.is_empty() {
442            return Err(OptimizeError::InvalidInput(
443                "Initial point must not be empty".to_string(),
444            ));
445        }
446        if self.options.initial_temp <= 0.0 {
447            return Err(OptimizeError::InvalidParameter(
448                "Initial temperature must be positive".to_string(),
449            ));
450        }
451
452        let max_iter = self.options.max_iter;
453
454        let evaluate = |x: &Array1<f64>, iter: usize| -> f64 {
455            let obj = func(&x.view());
456            if let Some(ref cf) = constraints_fn {
457                let violations = cf(&x.view());
458                let penalty = constraint_handler.compute_penalty(&violations, iter, max_iter);
459                obj + penalty
460            } else {
461                obj
462            }
463        };
464
465        let mut current_x = x0.clone();
466        // Enforce initial bounds
467        if let Some(ref bounds) = self.options.bounds {
468            for (i, (lo, hi)) in bounds.iter().enumerate() {
469                if i < current_x.len() {
470                    current_x[i] = current_x[i].clamp(*lo, *hi);
471                }
472            }
473        }
474
475        let mut current_val = evaluate(&current_x, 0);
476        let mut best_x = current_x.clone();
477        let mut best_val = current_val;
478        let mut temperature = self.options.initial_temp;
479        let mut nfev: usize = 1;
480        let mut nit: usize = 0;
481        let mut total_accepted: usize = 0;
482        let mut total_tried: usize = 0;
483        let mut stagnation_count: usize = 0;
484        let mut adaptive_state = AdaptiveCoolingState::default();
485
486        for iteration in 0..self.options.max_iter {
487            nit = iteration + 1;
488            let mut step_accepted = false;
489
490            for _step in 0..self.options.steps_per_temp {
491                let neighbor = self.generate_neighbor(&current_x);
492                let neighbor_val = evaluate(&neighbor, iteration);
493                nfev += 1;
494
495                let delta_e = neighbor_val - current_val;
496                total_tried += 1;
497
498                if self.accept(delta_e, temperature) {
499                    current_x = neighbor;
500                    current_val = neighbor_val;
501                    total_accepted += 1;
502                    step_accepted = true;
503
504                    if current_val < best_val {
505                        best_x = current_x.clone();
506                        best_val = current_val;
507                        stagnation_count = 0;
508                    }
509                }
510            }
511
512            if !step_accepted {
513                stagnation_count += 1;
514            }
515
516            // Update temperature
517            temperature =
518                self.next_temperature(temperature, iteration, &mut adaptive_state, step_accepted);
519
520            // Check for reheating
521            if let Some(new_temp) = self.maybe_reheat(temperature, iteration, stagnation_count) {
522                temperature = new_temp;
523                stagnation_count = 0;
524            }
525
526            // Check convergence
527            if temperature <= self.options.final_temp {
528                break;
529            }
530        }
531
532        let acceptance_ratio = if total_tried > 0 {
533            total_accepted as f64 / total_tried as f64
534        } else {
535            0.0
536        };
537
538        Ok(MetaheuristicSaResult {
539            x: best_x,
540            fun: best_val,
541            nfev,
542            nit,
543            success: true,
544            message: format!(
545                "SA completed: {} iterations, {} evaluations, acceptance ratio {:.4}",
546                nit, nfev, acceptance_ratio
547            ),
548            final_temperature: temperature,
549            acceptance_ratio,
550        })
551    }
552}
553
554// ---------------------------------------------------------------------------
555// Multi-Start SA
556// ---------------------------------------------------------------------------
557
558/// Options for multi-start SA
559#[derive(Debug, Clone)]
560pub struct MultiStartSaOptions {
561    /// Number of independent SA runs
562    pub n_starts: usize,
563    /// Per-run SA options
564    pub sa_options: MetaheuristicSaOptions,
565    /// Random seed for generating start points (None for non-deterministic)
566    pub seed: Option<u64>,
567}
568
569impl Default for MultiStartSaOptions {
570    fn default() -> Self {
571        Self {
572            n_starts: 5,
573            sa_options: MetaheuristicSaOptions::default(),
574            seed: None,
575        }
576    }
577}
578
579/// Run multi-start SA: perform multiple independent SA runs from different starting points
580/// and return the best result.
581///
582/// Requires bounds to generate random starting points.
583pub fn multi_start_sa<F>(
584    func: F,
585    bounds: &[(f64, f64)],
586    options: MultiStartSaOptions,
587) -> OptimizeResult<MetaheuristicSaResult>
588where
589    F: Fn(&ArrayView1<f64>) -> f64,
590{
591    if bounds.is_empty() {
592        return Err(OptimizeError::InvalidInput(
593            "Bounds must not be empty for multi-start SA".to_string(),
594        ));
595    }
596    if options.n_starts == 0 {
597        return Err(OptimizeError::InvalidParameter(
598            "n_starts must be at least 1".to_string(),
599        ));
600    }
601
602    let ndim = bounds.len();
603    let outer_seed = options.seed.unwrap_or_else(|| rng().random());
604    let mut outer_rng = StdRng::seed_from_u64(outer_seed);
605
606    let mut best_result: Option<MetaheuristicSaResult> = None;
607    let mut total_nfev: usize = 0;
608
609    for start_idx in 0..options.n_starts {
610        // Generate random starting point within bounds
611        let x0 = Array1::from_vec(
612            bounds
613                .iter()
614                .map(|(lo, hi)| lo + outer_rng.random::<f64>() * (hi - lo))
615                .collect::<Vec<_>>(),
616        );
617
618        let mut sa_opts = options.sa_options.clone();
619        sa_opts.bounds = Some(bounds.to_vec());
620        // Give each run a unique seed derived from the outer seed
621        sa_opts.seed = Some(outer_rng.random::<u64>());
622
623        let mut optimizer = SimulatedAnnealingOptimizer::new(sa_opts);
624        match optimizer.optimize(&func, &x0) {
625            Ok(result) => {
626                total_nfev += result.nfev;
627                let is_better = best_result
628                    .as_ref()
629                    .map_or(true, |best| result.fun < best.fun);
630                if is_better {
631                    best_result = Some(result);
632                }
633            }
634            Err(_) => {
635                // Skip failed runs
636                continue;
637            }
638        }
639    }
640
641    match best_result {
642        Some(mut result) => {
643            result.nfev = total_nfev;
644            result.message = format!(
645                "Multi-start SA: best of {} starts, {} total evaluations",
646                options.n_starts, total_nfev
647            );
648            Ok(result)
649        }
650        None => Err(OptimizeError::ComputationError(
651            "All multi-start SA runs failed".to_string(),
652        )),
653    }
654}
655
656// ---------------------------------------------------------------------------
657// Tests
658// ---------------------------------------------------------------------------
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use scirs2_core::ndarray::array;
664
665    fn sphere(x: &ArrayView1<f64>) -> f64 {
666        x.iter().map(|xi| xi * xi).sum()
667    }
668
669    fn rosenbrock(x: &ArrayView1<f64>) -> f64 {
670        let mut sum = 0.0;
671        for i in 0..x.len() - 1 {
672            sum += 100.0 * (x[i + 1] - x[i] * x[i]).powi(2) + (1.0 - x[i]).powi(2);
673        }
674        sum
675    }
676
677    fn rastrigin(x: &ArrayView1<f64>) -> f64 {
678        let n = x.len() as f64;
679        10.0 * n
680            + x.iter()
681                .map(|xi| xi * xi - 10.0 * (2.0 * std::f64::consts::PI * xi).cos())
682                .sum::<f64>()
683    }
684
685    // --- Basic SA tests ---
686
687    #[test]
688    fn test_sa_sphere_exponential() {
689        let opts = MetaheuristicSaOptions {
690            max_iter: 5000,
691            initial_temp: 100.0,
692            final_temp: 1e-10,
693            cooling: CoolingSchedule::Exponential { alpha: 0.99 },
694            steps_per_temp: 20,
695            step_size: 0.5,
696            seed: Some(42),
697            bounds: Some(vec![(-5.0, 5.0); 2]),
698            ..Default::default()
699        };
700
701        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
702        let x0 = array![3.0, -2.0];
703        let result = optimizer.optimize(sphere, &x0).expect("SA should succeed");
704
705        assert!(
706            result.fun < 1.0,
707            "Sphere minimum should be near 0, got {}",
708            result.fun
709        );
710        assert!(result.success);
711        assert!(result.nfev > 0);
712    }
713
714    #[test]
715    fn test_sa_sphere_linear_cooling() {
716        let opts = MetaheuristicSaOptions {
717            max_iter: 5000,
718            initial_temp: 50.0,
719            final_temp: 1e-8,
720            cooling: CoolingSchedule::Linear,
721            steps_per_temp: 20,
722            step_size: 0.3,
723            seed: Some(123),
724            bounds: Some(vec![(-5.0, 5.0); 2]),
725            ..Default::default()
726        };
727
728        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
729        let x0 = array![2.0, 2.0];
730        let result = optimizer.optimize(sphere, &x0).expect("SA should succeed");
731
732        assert!(
733            result.fun < 2.0,
734            "Sphere should converge near 0, got {}",
735            result.fun
736        );
737    }
738
739    #[test]
740    fn test_sa_sphere_logarithmic_cooling() {
741        let opts = MetaheuristicSaOptions {
742            max_iter: 5000,
743            initial_temp: 100.0,
744            final_temp: 1e-10,
745            cooling: CoolingSchedule::Logarithmic { c: 1.0 },
746            steps_per_temp: 20,
747            step_size: 0.5,
748            seed: Some(7),
749            bounds: Some(vec![(-5.0, 5.0); 2]),
750            ..Default::default()
751        };
752
753        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
754        let x0 = array![4.0, -3.0];
755        let result = optimizer.optimize(sphere, &x0).expect("SA should succeed");
756
757        // Logarithmic is slow cooling so still should get a decent result
758        assert!(result.fun < 5.0, "Log cooling on sphere got {}", result.fun);
759    }
760
761    #[test]
762    fn test_sa_adaptive_cooling() {
763        let opts = MetaheuristicSaOptions {
764            max_iter: 5000,
765            initial_temp: 100.0,
766            final_temp: 1e-10,
767            cooling: CoolingSchedule::Adaptive {
768                target_acceptance: 0.44,
769                adjustment_factor: 1.02,
770            },
771            steps_per_temp: 20,
772            step_size: 0.5,
773            seed: Some(99),
774            bounds: Some(vec![(-5.0, 5.0); 2]),
775            ..Default::default()
776        };
777
778        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
779        let x0 = array![3.0, 3.0];
780        let result = optimizer.optimize(sphere, &x0).expect("SA should succeed");
781
782        assert!(result.fun < 5.0, "Adaptive SA on sphere got {}", result.fun);
783    }
784
785    // --- Reheating tests ---
786
787    #[test]
788    fn test_sa_periodic_reheating() {
789        let opts = MetaheuristicSaOptions {
790            max_iter: 3000,
791            initial_temp: 50.0,
792            final_temp: 1e-8,
793            cooling: CoolingSchedule::Exponential { alpha: 0.99 },
794            steps_per_temp: 10,
795            step_size: 0.3,
796            reheating: ReheatingStrategy::Periodic {
797                interval: 500,
798                fraction: 0.3,
799            },
800            seed: Some(55),
801            bounds: Some(vec![(-5.0, 5.0); 2]),
802            ..Default::default()
803        };
804
805        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
806        let x0 = array![4.0, -4.0];
807        let result = optimizer
808            .optimize(sphere, &x0)
809            .expect("SA with reheating should succeed");
810
811        assert!(result.success);
812        assert!(result.fun < 5.0, "Periodic reheating got {}", result.fun);
813    }
814
815    #[test]
816    fn test_sa_stagnation_reheating() {
817        let opts = MetaheuristicSaOptions {
818            max_iter: 3000,
819            initial_temp: 50.0,
820            final_temp: 1e-8,
821            cooling: CoolingSchedule::Exponential { alpha: 0.99 },
822            steps_per_temp: 10,
823            step_size: 0.3,
824            reheating: ReheatingStrategy::Stagnation {
825                patience: 200,
826                fraction: 0.5,
827            },
828            seed: Some(77),
829            bounds: Some(vec![(-5.0, 5.0); 2]),
830            ..Default::default()
831        };
832
833        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
834        let x0 = array![3.0, 3.0];
835        let result = optimizer
836            .optimize(sphere, &x0)
837            .expect("SA with stagnation reheating should succeed");
838        assert!(result.success);
839    }
840
841    // --- Constraint handling tests ---
842
843    #[test]
844    fn test_sa_with_constraints() {
845        // Minimize x^2 + y^2 subject to x + y >= 2  (i.e. -(x+y-2) <= 0)
846        let constraints = |x: &ArrayView1<f64>| -> Vec<f64> {
847            let sum = x[0] + x[1];
848            vec![2.0 - sum] // violation when sum < 2
849        };
850
851        let opts = MetaheuristicSaOptions {
852            max_iter: 5000,
853            initial_temp: 50.0,
854            final_temp: 1e-10,
855            cooling: CoolingSchedule::Exponential { alpha: 0.995 },
856            steps_per_temp: 20,
857            step_size: 0.2,
858            seed: Some(42),
859            bounds: Some(vec![(-5.0, 5.0); 2]),
860            ..Default::default()
861        };
862
863        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
864        let x0 = array![1.5, 1.5];
865        let result = optimizer
866            .optimize_constrained(sphere, &x0, Some(constraints))
867            .expect("Constrained SA should succeed");
868
869        // Optimal: x = y = 1, f = 2
870        let sum = result.x[0] + result.x[1];
871        assert!(
872            sum >= 1.5,
873            "Constraint should be approximately satisfied: sum = {}",
874            sum
875        );
876    }
877
878    #[test]
879    fn test_sa_adaptive_penalty() {
880        let constraints = |x: &ArrayView1<f64>| -> Vec<f64> {
881            vec![1.0 - x[0], 1.0 - x[1]] // x >= 1, y >= 1
882        };
883
884        let handler = ConstraintHandler {
885            penalty: PenaltyConstraint {
886                penalty_coeff: 100.0,
887                adaptive: true,
888                max_penalty: 1e6,
889            },
890        };
891
892        let opts = MetaheuristicSaOptions {
893            max_iter: 3000,
894            initial_temp: 50.0,
895            final_temp: 1e-8,
896            cooling: CoolingSchedule::Exponential { alpha: 0.995 },
897            steps_per_temp: 20,
898            step_size: 0.3,
899            seed: Some(42),
900            bounds: Some(vec![(-5.0, 5.0); 2]),
901            ..Default::default()
902        };
903
904        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
905        let x0 = array![2.0, 2.0];
906        let result = optimizer
907            .optimize_with_handler(sphere, &x0, Some(constraints), &handler)
908            .expect("Adaptive penalty SA should succeed");
909
910        // Both x and y should be >= ~1
911        assert!(
912            result.x[0] > 0.5 && result.x[1] > 0.5,
913            "Constrained result: x={:.4}, y={:.4}",
914            result.x[0],
915            result.x[1]
916        );
917    }
918
919    // --- Multi-start SA tests ---
920
921    #[test]
922    fn test_multi_start_sa() {
923        let bounds = vec![(-5.0, 5.0); 2];
924        let ms_opts = MultiStartSaOptions {
925            n_starts: 5,
926            sa_options: MetaheuristicSaOptions {
927                max_iter: 2000,
928                initial_temp: 50.0,
929                final_temp: 1e-8,
930                cooling: CoolingSchedule::Exponential { alpha: 0.99 },
931                steps_per_temp: 10,
932                step_size: 0.3,
933                seed: None,
934                ..Default::default()
935            },
936            seed: Some(42),
937        };
938
939        let result =
940            multi_start_sa(sphere, &bounds, ms_opts).expect("Multi-start SA should succeed");
941
942        assert!(result.fun < 2.0, "Multi-start on sphere got {}", result.fun);
943        assert!(result.success);
944    }
945
946    #[test]
947    fn test_multi_start_sa_rastrigin() {
948        let bounds = vec![(-5.12, 5.12); 3];
949        let ms_opts = MultiStartSaOptions {
950            n_starts: 10,
951            sa_options: MetaheuristicSaOptions {
952                max_iter: 3000,
953                initial_temp: 100.0,
954                final_temp: 1e-8,
955                cooling: CoolingSchedule::Exponential { alpha: 0.995 },
956                steps_per_temp: 20,
957                step_size: 0.5,
958                seed: None,
959                ..Default::default()
960            },
961            seed: Some(123),
962        };
963
964        let result =
965            multi_start_sa(rastrigin, &bounds, ms_opts).expect("Multi-start SA on rastrigin");
966
967        // Rastrigin global min is 0 at origin; multi-start should get close
968        assert!(
969            result.fun < 20.0,
970            "Multi-start rastrigin got {}",
971            result.fun
972        );
973    }
974
975    // --- Edge case tests ---
976
977    #[test]
978    fn test_sa_empty_input_error() {
979        let opts = MetaheuristicSaOptions {
980            seed: Some(1),
981            ..Default::default()
982        };
983        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
984        let x0 = Array1::<f64>::zeros(0);
985        let result = optimizer.optimize(sphere, &x0);
986        assert!(result.is_err());
987    }
988
989    #[test]
990    fn test_sa_invalid_temp_error() {
991        let opts = MetaheuristicSaOptions {
992            initial_temp: -1.0,
993            seed: Some(1),
994            ..Default::default()
995        };
996        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
997        let x0 = array![1.0, 1.0];
998        let result = optimizer.optimize(sphere, &x0);
999        assert!(result.is_err());
1000    }
1001
1002    #[test]
1003    fn test_sa_single_dimension() {
1004        let opts = MetaheuristicSaOptions {
1005            max_iter: 2000,
1006            initial_temp: 50.0,
1007            final_temp: 1e-8,
1008            cooling: CoolingSchedule::Exponential { alpha: 0.99 },
1009            steps_per_temp: 10,
1010            step_size: 0.5,
1011            seed: Some(42),
1012            bounds: Some(vec![(-10.0, 10.0)]),
1013            ..Default::default()
1014        };
1015
1016        let mut optimizer = SimulatedAnnealingOptimizer::new(opts);
1017        let x0 = array![5.0];
1018        let result = optimizer
1019            .optimize(|x: &ArrayView1<f64>| x[0] * x[0], &x0)
1020            .expect("1D SA");
1021        assert!(result.fun < 1.0, "1D SA got {}", result.fun);
1022    }
1023
1024    #[test]
1025    fn test_sa_to_optimize_results() {
1026        let sa_result = MetaheuristicSaResult {
1027            x: array![1.0, 2.0],
1028            fun: 5.0,
1029            nfev: 1000,
1030            nit: 500,
1031            success: true,
1032            message: "test".to_string(),
1033            final_temperature: 0.001,
1034            acceptance_ratio: 0.4,
1035        };
1036        let opt_results = sa_result.to_optimize_results();
1037        assert_eq!(opt_results.nfev, 1000);
1038        assert_eq!(opt_results.nit, 500);
1039        assert!(opt_results.success);
1040    }
1041
1042    #[test]
1043    fn test_multi_start_sa_empty_bounds_error() {
1044        let ms_opts = MultiStartSaOptions {
1045            n_starts: 3,
1046            seed: Some(1),
1047            ..Default::default()
1048        };
1049        let result = multi_start_sa(sphere, &[], ms_opts);
1050        assert!(result.is_err());
1051    }
1052
1053    #[test]
1054    fn test_multi_start_sa_zero_starts_error() {
1055        let ms_opts = MultiStartSaOptions {
1056            n_starts: 0,
1057            seed: Some(1),
1058            ..Default::default()
1059        };
1060        let result = multi_start_sa(sphere, &[(-1.0, 1.0)], ms_opts);
1061        assert!(result.is_err());
1062    }
1063
1064    #[test]
1065    fn test_constraint_handler_no_violation() {
1066        let handler = ConstraintHandler::default();
1067        let violations = vec![-1.0, -0.5]; // no violations
1068        let penalty = handler.compute_penalty(&violations, 0, 100);
1069        assert!((penalty - 0.0).abs() < 1e-15);
1070    }
1071
1072    #[test]
1073    fn test_constraint_handler_with_violation() {
1074        let handler = ConstraintHandler {
1075            penalty: PenaltyConstraint {
1076                penalty_coeff: 100.0,
1077                adaptive: false,
1078                max_penalty: 1e8,
1079            },
1080        };
1081        let violations = vec![2.0]; // violation of 2
1082        let penalty = handler.compute_penalty(&violations, 0, 100);
1083        assert!((penalty - 400.0).abs() < 1e-10); // 100 * 2^2 = 400
1084    }
1085
1086    #[test]
1087    fn test_adaptive_cooling_state_update() {
1088        let mut state = AdaptiveCoolingState {
1089            window_size: 5,
1090            ..Default::default()
1091        };
1092        // All accepted -> should speed up cooling
1093        for _ in 0..5 {
1094            state.update(true, 0.44, 1.05);
1095        }
1096        assert!(state.effective_alpha > 0.95); // alpha should increase
1097    }
1098}