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//! - Parallel multi-restart via `run_parallel()`
16//!
17//! The rand 0.9 API is shared with u-metaheur for ecosystem compatibility.
18
19use rand::prelude::*;
20#[cfg(feature = "parallel")]
21use rayon::prelude::*;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::time::Duration;
25
26use crate::timing::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    /// Initial temperature.
51    pub initial_temp: f64,
52    /// Final (minimum) temperature.
53    pub final_temp: f64,
54    /// Cooling rate (alpha for Geometric, delta for Linear, beta for LundyMees).
55    pub cooling_rate: f64,
56    /// Number of iterations at each temperature level.
57    pub iterations_per_temp: usize,
58    /// Maximum total iterations (None = temperature-based stopping only).
59    pub max_iterations: Option<u64>,
60    /// Cooling schedule type.
61    pub cooling_schedule: CoolingSchedule,
62    /// Maximum time limit (None = unlimited).
63    pub time_limit: Option<Duration>,
64    /// Target fitness to stop early (None = run until temperature limit).
65    pub target_fitness: Option<f64>,
66    /// Enable reheating when stagnation detected.
67    pub enable_reheating: bool,
68    /// Stagnation threshold for reheating.
69    pub reheat_threshold: u64,
70    /// Reheat factor (multiplier for current temperature).
71    pub reheat_factor: f64,
72}
73
74impl Default for SaConfig {
75    fn default() -> Self {
76        Self {
77            initial_temp: 1000.0,
78            final_temp: 0.001,
79            cooling_rate: 0.95,
80            iterations_per_temp: 100,
81            max_iterations: Some(100_000),
82            cooling_schedule: CoolingSchedule::Geometric,
83            time_limit: None,
84            target_fitness: None,
85            enable_reheating: false,
86            reheat_threshold: 1000,
87            reheat_factor: 2.0,
88        }
89    }
90}
91
92impl SaConfig {
93    /// Creates a new configuration with default values.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Sets the initial temperature.
99    pub fn with_initial_temp(mut self, temp: f64) -> Self {
100        self.initial_temp = temp.max(0.001);
101        self
102    }
103
104    /// Sets the final temperature.
105    pub fn with_final_temp(mut self, temp: f64) -> Self {
106        self.final_temp = temp.max(0.0001);
107        self
108    }
109
110    /// Sets the cooling rate.
111    pub fn with_cooling_rate(mut self, rate: f64) -> Self {
112        self.cooling_rate = rate.clamp(0.001, 0.9999);
113        self
114    }
115
116    /// Sets the iterations per temperature level.
117    pub fn with_iterations_per_temp(mut self, iterations: usize) -> Self {
118        self.iterations_per_temp = iterations.max(1);
119        self
120    }
121
122    /// Sets the maximum iterations.
123    pub fn with_max_iterations(mut self, iterations: u64) -> Self {
124        self.max_iterations = Some(iterations);
125        self
126    }
127
128    /// Sets the cooling schedule.
129    pub fn with_cooling_schedule(mut self, schedule: CoolingSchedule) -> Self {
130        self.cooling_schedule = schedule;
131        self
132    }
133
134    /// Sets the time limit.
135    pub fn with_time_limit(mut self, duration: Duration) -> Self {
136        self.time_limit = Some(duration);
137        self
138    }
139
140    /// Sets the target fitness.
141    pub fn with_target_fitness(mut self, fitness: f64) -> Self {
142        self.target_fitness = Some(fitness);
143        self
144    }
145
146    /// Enables reheating.
147    pub fn with_reheating(mut self, threshold: u64, factor: f64) -> Self {
148        self.enable_reheating = true;
149        self.reheat_threshold = threshold;
150        self.reheat_factor = factor.max(1.1);
151        self
152    }
153}
154
155/// Trait for solutions in Simulated Annealing.
156pub trait SaSolution: Clone + Send + Sync {
157    /// Returns the objective value (fitness) of this solution.
158    /// Higher values are better (maximization).
159    fn objective(&self) -> f64;
160
161    /// Sets the objective value.
162    fn set_objective(&mut self, value: f64);
163}
164
165/// Neighborhood operator types.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum NeighborhoodOperator {
168    /// Swap two elements.
169    Swap,
170    /// Relocate an element to a new position.
171    Relocate,
172    /// Reverse a segment (2-opt style).
173    Inversion,
174    /// Rotate/change orientation of an element.
175    Rotation,
176    /// Chain swap (3-opt style).
177    Chain,
178}
179
180/// Trait for problem-specific SA operations.
181pub trait SaProblem: Send + Sync {
182    /// The solution type for this problem.
183    type Solution: SaSolution;
184
185    /// Creates an initial solution.
186    fn initial_solution<R: Rng>(&self, rng: &mut R) -> Self::Solution;
187
188    /// Generates a neighbor solution using the specified operator.
189    fn neighbor<R: Rng>(
190        &self,
191        solution: &Self::Solution,
192        operator: NeighborhoodOperator,
193        rng: &mut R,
194    ) -> Self::Solution;
195
196    /// Evaluates the objective of a solution.
197    fn evaluate(&self, solution: &mut Self::Solution);
198
199    /// Returns available neighborhood operators for this problem.
200    fn available_operators(&self) -> Vec<NeighborhoodOperator> {
201        vec![
202            NeighborhoodOperator::Swap,
203            NeighborhoodOperator::Relocate,
204            NeighborhoodOperator::Inversion,
205        ]
206    }
207
208    /// Called after each temperature level (for progress reporting).
209    fn on_temperature_change(
210        &self,
211        _temperature: f64,
212        _iteration: u64,
213        _best: &Self::Solution,
214        _current: &Self::Solution,
215    ) {
216        // Default: do nothing
217    }
218}
219
220/// Progress information during SA execution.
221#[derive(Debug, Clone)]
222pub struct SaProgress {
223    /// Current temperature.
224    pub temperature: f64,
225    /// Current iteration number.
226    pub iteration: u64,
227    /// Best fitness so far.
228    pub best_fitness: f64,
229    /// Current fitness.
230    pub current_fitness: f64,
231    /// Acceptance rate at current temperature.
232    pub acceptance_rate: f64,
233    /// Elapsed time since start.
234    pub elapsed: Duration,
235    /// Whether the algorithm is still running.
236    pub running: bool,
237}
238
239/// Result of a SA run.
240#[derive(Debug, Clone)]
241pub struct SaResult<S: SaSolution> {
242    /// The best solution found.
243    pub best: S,
244    /// Final temperature reached.
245    pub final_temperature: f64,
246    /// Total iterations performed.
247    pub iterations: u64,
248    /// Total elapsed time.
249    pub elapsed: Duration,
250    /// Whether the target fitness was reached.
251    pub target_reached: bool,
252    /// Number of reheats performed.
253    pub reheat_count: u32,
254    /// Fitness history (sampled at temperature changes).
255    pub history: Vec<f64>,
256}
257
258/// Simulated Annealing runner.
259pub struct SaRunner<P: SaProblem> {
260    config: SaConfig,
261    problem: P,
262    cancelled: Arc<AtomicBool>,
263}
264
265impl<P: SaProblem> SaRunner<P> {
266    /// Creates a new SA runner.
267    pub fn new(config: SaConfig, problem: P) -> Self {
268        Self {
269            config,
270            problem,
271            cancelled: Arc::new(AtomicBool::new(false)),
272        }
273    }
274
275    /// Returns a handle to cancel the algorithm.
276    pub fn cancel_handle(&self) -> Arc<AtomicBool> {
277        self.cancelled.clone()
278    }
279
280    /// Runs the Simulated Annealing algorithm.
281    pub fn run(&self) -> SaResult<P::Solution> {
282        self.run_with_rng(&mut rand::rng())
283    }
284
285    /// Runs the Simulated Annealing algorithm with a specific RNG.
286    pub fn run_with_rng<R: Rng>(&self, rng: &mut R) -> SaResult<P::Solution> {
287        let start = Timer::now();
288        let mut history = Vec::new();
289
290        // Initialize
291        let mut current = self.problem.initial_solution(rng);
292        self.problem.evaluate(&mut current);
293        let mut best = current.clone();
294        let mut best_fitness = best.objective();
295
296        let mut temperature = self.config.initial_temp;
297        let mut iteration = 0u64;
298        let mut target_reached = false;
299        let mut reheat_count = 0u32;
300        let mut stagnation_count = 0u64;
301
302        let operators = self.problem.available_operators();
303        let temp_delta = if matches!(self.config.cooling_schedule, CoolingSchedule::Linear) {
304            (self.config.initial_temp - self.config.final_temp)
305                / (self.config.max_iterations.unwrap_or(10000) as f64
306                    / self.config.iterations_per_temp as f64)
307        } else {
308            0.0
309        };
310
311        // For adaptive cooling
312        let mut accepted_count = 0usize;
313        let mut total_count = 0usize;
314
315        while temperature > self.config.final_temp {
316            // Check cancellation
317            if self.cancelled.load(Ordering::Relaxed) {
318                break;
319            }
320
321            // Check time limit
322            if let Some(limit) = self.config.time_limit {
323                if start.elapsed() > limit {
324                    break;
325                }
326            }
327
328            // Check max iterations
329            if let Some(max) = self.config.max_iterations {
330                if iteration >= max {
331                    break;
332                }
333            }
334
335            // Check target fitness
336            if let Some(target) = self.config.target_fitness {
337                if best_fitness >= target {
338                    target_reached = true;
339                    break;
340                }
341            }
342
343            // Iterations at this temperature
344            for _ in 0..self.config.iterations_per_temp {
345                iteration += 1;
346                total_count += 1;
347
348                // Select random operator
349                let operator = operators[rng.random_range(0..operators.len())];
350
351                // Generate neighbor
352                let mut neighbor = self.problem.neighbor(&current, operator, rng);
353                self.problem.evaluate(&mut neighbor);
354
355                let current_obj = current.objective();
356                let neighbor_obj = neighbor.objective();
357                let delta = neighbor_obj - current_obj;
358
359                // Accept or reject
360                let accept = if delta >= 0.0 {
361                    // Better solution - always accept
362                    true
363                } else {
364                    // Worse solution - accept with probability exp(delta/T)
365                    let probability = (delta / temperature).exp();
366                    rng.random::<f64>() < probability
367                };
368
369                if accept {
370                    accepted_count += 1;
371                    current = neighbor;
372
373                    // Update best
374                    if current.objective() > best_fitness {
375                        best = current.clone();
376                        best_fitness = best.objective();
377                        stagnation_count = 0;
378                    } else {
379                        stagnation_count += 1;
380                    }
381                } else {
382                    stagnation_count += 1;
383                }
384
385                // Check max iterations inside inner loop
386                if let Some(max) = self.config.max_iterations {
387                    if iteration >= max {
388                        break;
389                    }
390                }
391            }
392
393            // Record history
394            history.push(best_fitness);
395
396            // Callback
397            self.problem
398                .on_temperature_change(temperature, iteration, &best, &current);
399
400            // Reheating check
401            if self.config.enable_reheating && stagnation_count >= self.config.reheat_threshold {
402                temperature *= self.config.reheat_factor;
403                temperature = temperature.min(self.config.initial_temp);
404                stagnation_count = 0;
405                reheat_count += 1;
406            }
407
408            // Cool down
409            temperature = self.cool_down(temperature, temp_delta, accepted_count, total_count);
410
411            // Reset adaptive counters
412            accepted_count = 0;
413            total_count = 0;
414        }
415
416        // Final history entry
417        history.push(best_fitness);
418
419        SaResult {
420            best,
421            final_temperature: temperature,
422            iterations: iteration,
423            elapsed: start.elapsed(),
424            target_reached,
425            reheat_count,
426            history,
427        }
428    }
429
430    /// Apply cooling schedule.
431    fn cool_down(&self, current_temp: f64, delta: f64, accepted: usize, total: usize) -> f64 {
432        match self.config.cooling_schedule {
433            CoolingSchedule::Geometric => current_temp * self.config.cooling_rate,
434            CoolingSchedule::Linear => (current_temp - delta).max(self.config.final_temp),
435            CoolingSchedule::Adaptive => {
436                // Adjust cooling rate based on acceptance rate
437                let acceptance_rate = if total > 0 {
438                    accepted as f64 / total as f64
439                } else {
440                    0.5
441                };
442
443                // If acceptance rate is high, cool faster; if low, cool slower
444                let adjusted_rate = if acceptance_rate > 0.5 {
445                    self.config.cooling_rate * 0.95 // Cool faster
446                } else if acceptance_rate < 0.1 {
447                    self.config.cooling_rate.powf(0.5) // Cool slower (sqrt)
448                } else {
449                    self.config.cooling_rate
450                };
451
452                current_temp * adjusted_rate
453            }
454            CoolingSchedule::LundyMees => {
455                // T_new = T / (1 + beta * T)
456                current_temp / (1.0 + self.config.cooling_rate * current_temp)
457            }
458        }
459    }
460
461    /// Runs multiple SA instances in parallel and returns the best result.
462    ///
463    /// This is useful for escaping local optima by exploring different regions
464    /// of the solution space simultaneously.
465    ///
466    /// # Arguments
467    /// * `num_restarts` - Number of parallel SA runs to perform
468    ///
469    /// # Returns
470    /// The best result among all parallel runs
471    ///
472    /// Requires the `parallel` feature to be enabled.
473    #[cfg(feature = "parallel")]
474    pub fn run_parallel(&self, num_restarts: usize) -> SaResult<P::Solution>
475    where
476        P: Clone,
477    {
478        let num_restarts = num_restarts.max(1);
479
480        // Run SA instances in parallel
481        let results: Vec<SaResult<P::Solution>> = (0..num_restarts)
482            .into_par_iter()
483            .map(|_| {
484                let mut rng = rand::rng();
485                self.run_with_rng(&mut rng)
486            })
487            .collect();
488
489        // Find the best result
490        results
491            .into_iter()
492            .max_by(|a, b| {
493                a.best
494                    .objective()
495                    .partial_cmp(&b.best.objective())
496                    .unwrap_or(std::cmp::Ordering::Equal)
497            })
498            .expect("At least one result should exist")
499    }
500}
501
502/// Permutation-based solution for SA.
503#[derive(Debug, Clone)]
504pub struct PermutationSolution {
505    /// The permutation (indices).
506    pub sequence: Vec<usize>,
507    /// Additional rotation/orientation values.
508    pub rotations: Vec<usize>,
509    /// Number of rotation options per item.
510    pub rotation_options: usize,
511    /// Cached objective value.
512    objective: f64,
513}
514
515impl PermutationSolution {
516    /// Creates a new permutation solution.
517    pub fn new(size: usize, rotation_options: usize) -> Self {
518        Self {
519            sequence: (0..size).collect(),
520            rotations: vec![0; size],
521            rotation_options,
522            objective: f64::NEG_INFINITY,
523        }
524    }
525
526    /// Creates a random permutation solution.
527    pub fn random<R: Rng>(size: usize, rotation_options: usize, rng: &mut R) -> Self {
528        let mut sequence: Vec<usize> = (0..size).collect();
529        sequence.shuffle(rng);
530
531        let rotations: Vec<usize> = (0..size)
532            .map(|_| rng.random_range(0..rotation_options.max(1)))
533            .collect();
534
535        Self {
536            sequence,
537            rotations,
538            rotation_options,
539            objective: f64::NEG_INFINITY,
540        }
541    }
542
543    /// Returns the length of the sequence.
544    pub fn len(&self) -> usize {
545        self.sequence.len()
546    }
547
548    /// Returns true if empty.
549    pub fn is_empty(&self) -> bool {
550        self.sequence.is_empty()
551    }
552
553    /// Applies swap operator: swaps two elements in sequence.
554    pub fn apply_swap<R: Rng>(&self, rng: &mut R) -> Self {
555        let mut result = self.clone();
556        if result.sequence.len() < 2 {
557            return result;
558        }
559
560        let i = rng.random_range(0..result.sequence.len());
561        let j = rng.random_range(0..result.sequence.len());
562        result.sequence.swap(i, j);
563        result.objective = f64::NEG_INFINITY;
564        result
565    }
566
567    /// Applies relocate operator: moves an element to a new position.
568    pub fn apply_relocate<R: Rng>(&self, rng: &mut R) -> Self {
569        let mut result = self.clone();
570        if result.sequence.len() < 2 {
571            return result;
572        }
573
574        let from = rng.random_range(0..result.sequence.len());
575        let to = rng.random_range(0..result.sequence.len());
576
577        if from != to {
578            let elem = result.sequence.remove(from);
579            let insert_pos = if to > from { to - 1 } else { to };
580            result
581                .sequence
582                .insert(insert_pos.min(result.sequence.len()), elem);
583        }
584
585        result.objective = f64::NEG_INFINITY;
586        result
587    }
588
589    /// Applies inversion operator: reverses a segment.
590    pub fn apply_inversion<R: Rng>(&self, rng: &mut R) -> Self {
591        let mut result = self.clone();
592        let n = result.sequence.len();
593        if n < 2 {
594            return result;
595        }
596
597        let (mut p1, mut p2) = (rng.random_range(0..n), rng.random_range(0..n));
598        if p1 > p2 {
599            std::mem::swap(&mut p1, &mut p2);
600        }
601
602        result.sequence[p1..=p2].reverse();
603        result.objective = f64::NEG_INFINITY;
604        result
605    }
606
607    /// Applies rotation operator: changes rotation of one element.
608    pub fn apply_rotation<R: Rng>(&self, rng: &mut R) -> Self {
609        let mut result = self.clone();
610        if result.rotations.is_empty() || result.rotation_options <= 1 {
611            return result;
612        }
613
614        let idx = rng.random_range(0..result.rotations.len());
615        result.rotations[idx] = rng.random_range(0..result.rotation_options);
616        result.objective = f64::NEG_INFINITY;
617        result
618    }
619
620    /// Applies chain operator: 3-opt style move.
621    pub fn apply_chain<R: Rng>(&self, rng: &mut R) -> Self {
622        let mut result = self.clone();
623        let n = result.sequence.len();
624        if n < 4 {
625            // Fall back to swap for small sequences
626            return self.apply_swap(rng);
627        }
628
629        // Select three distinct positions
630        let mut positions: Vec<usize> = (0..n).collect();
631        positions.shuffle(rng);
632        let mut selected: Vec<usize> = positions.into_iter().take(3).collect();
633        selected.sort();
634
635        let (p1, p2, p3) = (selected[0], selected[1], selected[2]);
636
637        // Rotate segments: [0..p1] [p1..p2] [p2..p3] [p3..n]
638        // New order: [0..p1] [p2..p3] [p1..p2] [p3..n]
639        let seg1: Vec<usize> = result.sequence[..p1].to_vec();
640        let seg2: Vec<usize> = result.sequence[p1..p2].to_vec();
641        let seg3: Vec<usize> = result.sequence[p2..p3].to_vec();
642        let seg4: Vec<usize> = result.sequence[p3..].to_vec();
643
644        result.sequence = [seg1, seg3, seg2, seg4].concat();
645        result.objective = f64::NEG_INFINITY;
646        result
647    }
648}
649
650impl SaSolution for PermutationSolution {
651    fn objective(&self) -> f64 {
652        self.objective
653    }
654
655    fn set_objective(&mut self, value: f64) {
656        self.objective = value;
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    struct SimpleMaxProblem {
665        size: usize,
666    }
667
668    impl SaProblem for SimpleMaxProblem {
669        type Solution = PermutationSolution;
670
671        fn initial_solution<R: Rng>(&self, rng: &mut R) -> Self::Solution {
672            PermutationSolution::random(self.size, 1, rng)
673        }
674
675        fn neighbor<R: Rng>(
676            &self,
677            solution: &Self::Solution,
678            operator: NeighborhoodOperator,
679            rng: &mut R,
680        ) -> Self::Solution {
681            match operator {
682                NeighborhoodOperator::Swap => solution.apply_swap(rng),
683                NeighborhoodOperator::Relocate => solution.apply_relocate(rng),
684                NeighborhoodOperator::Inversion => solution.apply_inversion(rng),
685                NeighborhoodOperator::Rotation => solution.apply_rotation(rng),
686                NeighborhoodOperator::Chain => solution.apply_chain(rng),
687            }
688        }
689
690        fn evaluate(&self, solution: &mut Self::Solution) {
691            // Maximize: sequence should be in ascending order
692            // Fitness = negative sum of inversions
693            let mut inversions = 0i64;
694            for i in 0..solution.sequence.len() {
695                for j in (i + 1)..solution.sequence.len() {
696                    if solution.sequence[i] > solution.sequence[j] {
697                        inversions += 1;
698                    }
699                }
700            }
701            solution.set_objective(-inversions as f64);
702        }
703    }
704
705    #[test]
706    fn test_sa_basic() {
707        let config = SaConfig::default()
708            .with_initial_temp(100.0)
709            .with_final_temp(0.1)
710            .with_cooling_rate(0.9)
711            .with_iterations_per_temp(50)
712            .with_max_iterations(5000);
713
714        let problem = SimpleMaxProblem { size: 10 };
715        let runner = SaRunner::new(config, problem);
716        let result = runner.run();
717
718        // Should find something reasonably good (fewer inversions)
719        assert!(result.best.objective() > -20.0);
720        assert!(result.iterations > 0);
721    }
722
723    #[test]
724    fn test_cooling_schedules() {
725        let problem = SimpleMaxProblem { size: 5 };
726
727        for schedule in [
728            CoolingSchedule::Geometric,
729            CoolingSchedule::Linear,
730            CoolingSchedule::Adaptive,
731            CoolingSchedule::LundyMees,
732        ] {
733            let config = SaConfig::default()
734                .with_cooling_schedule(schedule)
735                .with_max_iterations(1000);
736
737            let runner = SaRunner::new(config, problem.clone());
738            let result = runner.run();
739
740            // Should complete without panic
741            assert!(result.iterations > 0);
742        }
743    }
744
745    #[test]
746    fn test_neighborhood_operators() {
747        let mut rng = rand::rng();
748        let solution = PermutationSolution::random(10, 4, &mut rng);
749
750        // Test all operators produce valid permutations
751        let swap = solution.apply_swap(&mut rng);
752        let relocate = solution.apply_relocate(&mut rng);
753        let inversion = solution.apply_inversion(&mut rng);
754        let rotation = solution.apply_rotation(&mut rng);
755        let chain = solution.apply_chain(&mut rng);
756
757        for sol in [&swap, &relocate, &inversion, &rotation, &chain] {
758            let mut sorted = sol.sequence.clone();
759            sorted.sort();
760            assert_eq!(sorted, (0..10).collect::<Vec<_>>());
761        }
762    }
763
764    #[test]
765    fn test_reheating() {
766        let config = SaConfig::default()
767            .with_initial_temp(10.0)
768            .with_final_temp(0.1)
769            .with_max_iterations(500)
770            .with_reheating(50, 1.5);
771
772        let problem = SimpleMaxProblem { size: 8 };
773        let runner = SaRunner::new(config, problem);
774        let result = runner.run();
775
776        // Should complete (reheating may or may not trigger)
777        assert!(result.iterations > 0);
778    }
779
780    impl Clone for SimpleMaxProblem {
781        fn clone(&self) -> Self {
782            Self { size: self.size }
783        }
784    }
785}