u_nesting_core/
alns.rs

1//! # Adaptive Large Neighborhood Search (ALNS) Framework
2//!
3//! Implementation of the ALNS metaheuristic based on Ropke & Pisinger (2006).
4//!
5//! ALNS combines:
6//! - **Destroy operators**: Remove items from current solution (multiple strategies)
7//! - **Repair operators**: Reinsert items to improve solution (multiple strategies)
8//! - **Adaptive weights**: Operators are selected based on past performance
9//! - **Simulated Annealing acceptance**: Probabilistic acceptance of worse solutions
10//!
11//! ## Key Features
12//!
13//! - Multiple destroy/repair operators with adaptive selection
14//! - Segment-based weight updates
15//! - Configurable acceptance criteria (SA, Hill-Climbing)
16//! - Progress callbacks for monitoring
17//!
18//! ## Usage
19//!
20//! ```rust,ignore
21//! use u_nesting_core::alns::{AlnsConfig, AlnsRunner, AlnsProblem};
22//!
23//! let config = AlnsConfig::default();
24//! let runner = AlnsRunner::new(config);
25//! let result = runner.run(&mut problem, progress_callback);
26//! ```
27
28use std::fmt::Debug;
29use std::time::Instant;
30
31/// Configuration for the ALNS algorithm.
32#[derive(Debug, Clone)]
33pub struct AlnsConfig {
34    /// Maximum number of iterations
35    pub max_iterations: usize,
36    /// Time limit in milliseconds (0 = no limit)
37    pub time_limit_ms: u64,
38    /// Number of iterations per segment (for weight updates)
39    pub segment_size: usize,
40    /// Score for finding new best solution
41    pub score_best: f64,
42    /// Score for finding better solution than current
43    pub score_better: f64,
44    /// Score for accepting worse solution
45    pub score_accepted: f64,
46    /// Reaction factor (how quickly weights adapt, 0-1)
47    pub reaction_factor: f64,
48    /// Minimum operator weight
49    pub min_weight: f64,
50    /// Initial temperature for SA acceptance
51    pub initial_temperature: f64,
52    /// Cooling rate for SA acceptance (0-1)
53    pub cooling_rate: f64,
54    /// Final temperature threshold
55    pub final_temperature: f64,
56    /// Random seed for reproducibility (None = random)
57    pub seed: Option<u64>,
58}
59
60impl Default for AlnsConfig {
61    fn default() -> Self {
62        Self {
63            max_iterations: 10000,
64            time_limit_ms: 60000, // 1 minute
65            segment_size: 100,
66            score_best: 33.0,
67            score_better: 9.0,
68            score_accepted: 3.0,
69            reaction_factor: 0.1,
70            min_weight: 0.1,
71            initial_temperature: 100.0,
72            cooling_rate: 0.9995,
73            final_temperature: 0.01,
74            seed: None,
75        }
76    }
77}
78
79impl AlnsConfig {
80    /// Create a new configuration with default values.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Set maximum iterations.
86    pub fn with_max_iterations(mut self, iterations: usize) -> Self {
87        self.max_iterations = iterations;
88        self
89    }
90
91    /// Set time limit in milliseconds.
92    pub fn with_time_limit_ms(mut self, ms: u64) -> Self {
93        self.time_limit_ms = ms;
94        self
95    }
96
97    /// Set segment size for weight updates.
98    pub fn with_segment_size(mut self, size: usize) -> Self {
99        self.segment_size = size.max(1);
100        self
101    }
102
103    /// Set scoring parameters.
104    pub fn with_scores(mut self, best: f64, better: f64, accepted: f64) -> Self {
105        self.score_best = best;
106        self.score_better = better;
107        self.score_accepted = accepted;
108        self
109    }
110
111    /// Set reaction factor.
112    pub fn with_reaction_factor(mut self, factor: f64) -> Self {
113        self.reaction_factor = factor.clamp(0.0, 1.0);
114        self
115    }
116
117    /// Set temperature parameters for SA acceptance.
118    pub fn with_temperature(mut self, initial: f64, cooling_rate: f64, final_temp: f64) -> Self {
119        self.initial_temperature = initial.max(0.01);
120        self.cooling_rate = cooling_rate.clamp(0.9, 1.0);
121        self.final_temperature = final_temp.max(0.001);
122        self
123    }
124
125    /// Set random seed for reproducibility.
126    pub fn with_seed(mut self, seed: u64) -> Self {
127        self.seed = Some(seed);
128        self
129    }
130}
131
132/// Statistics for an operator.
133#[derive(Debug, Clone)]
134pub struct OperatorStats {
135    /// Current weight
136    pub weight: f64,
137    /// Number of times used
138    pub times_used: usize,
139    /// Total score accumulated
140    pub total_score: f64,
141    /// Score in current segment
142    pub segment_score: f64,
143    /// Uses in current segment
144    pub segment_uses: usize,
145}
146
147impl Default for OperatorStats {
148    fn default() -> Self {
149        Self {
150            weight: 1.0,
151            times_used: 0,
152            total_score: 0.0,
153            segment_score: 0.0,
154            segment_uses: 0,
155        }
156    }
157}
158
159impl OperatorStats {
160    /// Create new stats with initial weight.
161    pub fn new(initial_weight: f64) -> Self {
162        Self {
163            weight: initial_weight,
164            ..Default::default()
165        }
166    }
167
168    /// Record operator usage with a score.
169    pub fn record_use(&mut self, score: f64) {
170        self.times_used += 1;
171        self.total_score += score;
172        self.segment_score += score;
173        self.segment_uses += 1;
174    }
175
176    /// Update weight at end of segment.
177    pub fn update_weight(&mut self, reaction_factor: f64, min_weight: f64) {
178        if self.segment_uses > 0 {
179            let segment_avg = self.segment_score / self.segment_uses as f64;
180            self.weight = self.weight * (1.0 - reaction_factor) + segment_avg * reaction_factor;
181            self.weight = self.weight.max(min_weight);
182        }
183        // Reset segment counters
184        self.segment_score = 0.0;
185        self.segment_uses = 0;
186    }
187}
188
189/// Identifies a destroy operator.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
191pub enum DestroyOperatorId {
192    /// Random removal
193    Random,
194    /// Worst removal (highest cost items)
195    Worst,
196    /// Related removal (similar/nearby items)
197    Related,
198    /// Shaw removal (clustering-based)
199    Shaw,
200    /// Custom operator by index
201    Custom(usize),
202}
203
204/// Identifies a repair operator.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206pub enum RepairOperatorId {
207    /// Greedy repair (best position)
208    Greedy,
209    /// Regret-based repair
210    Regret,
211    /// Random repair
212    Random,
213    /// BLF-based repair
214    BottomLeftFill,
215    /// Custom operator by index
216    Custom(usize),
217}
218
219/// Result of a destroy operation.
220#[derive(Debug, Clone)]
221pub struct DestroyResult {
222    /// Indices of removed items
223    pub removed_indices: Vec<usize>,
224    /// Operator used
225    pub operator: DestroyOperatorId,
226}
227
228/// Result of a repair operation.
229#[derive(Debug, Clone)]
230pub struct RepairResult {
231    /// Number of items successfully placed
232    pub placed_count: usize,
233    /// Number of items that could not be placed
234    pub unplaced_count: usize,
235    /// Operator used
236    pub operator: RepairOperatorId,
237}
238
239/// Progress information for ALNS callbacks.
240#[derive(Debug, Clone)]
241pub struct AlnsProgress {
242    /// Current iteration number
243    pub iteration: usize,
244    /// Best fitness found so far
245    pub best_fitness: f64,
246    /// Current fitness
247    pub current_fitness: f64,
248    /// Current temperature
249    pub temperature: f64,
250    /// Current segment number
251    pub segment: usize,
252    /// Elapsed time in milliseconds
253    pub elapsed_ms: u64,
254    /// Acceptance rate in current segment
255    pub acceptance_rate: f64,
256    /// Best destroy operator (by weight)
257    pub best_destroy: DestroyOperatorId,
258    /// Best repair operator (by weight)
259    pub best_repair: RepairOperatorId,
260}
261
262/// Result of ALNS optimization.
263#[derive(Debug, Clone)]
264pub struct AlnsResult<S> {
265    /// Best solution found
266    pub best_solution: S,
267    /// Best fitness value
268    pub best_fitness: f64,
269    /// Total iterations performed
270    pub iterations: usize,
271    /// Total time elapsed in milliseconds
272    pub elapsed_ms: u64,
273    /// Number of improvements found
274    pub improvements: usize,
275    /// Final temperature
276    pub final_temperature: f64,
277    /// Final operator weights
278    pub destroy_weights: Vec<(DestroyOperatorId, f64)>,
279    /// Final repair operator weights
280    pub repair_weights: Vec<(RepairOperatorId, f64)>,
281}
282
283/// Trait for solutions that can be optimized by ALNS.
284pub trait AlnsSolution: Clone + Debug {
285    /// Get the fitness value (lower is better, 0 = optimal).
286    fn fitness(&self) -> f64;
287
288    /// Get the number of placed items.
289    fn placed_count(&self) -> usize;
290
291    /// Get the total number of items.
292    fn total_count(&self) -> usize;
293}
294
295/// Trait for problems that can be solved by ALNS.
296pub trait AlnsProblem {
297    /// Solution type
298    type Solution: AlnsSolution;
299
300    /// Create an initial solution.
301    fn create_initial_solution(&mut self) -> Self::Solution;
302
303    /// Clone a solution.
304    fn clone_solution(&self, solution: &Self::Solution) -> Self::Solution;
305
306    /// Get available destroy operators.
307    fn destroy_operators(&self) -> Vec<DestroyOperatorId>;
308
309    /// Get available repair operators.
310    fn repair_operators(&self) -> Vec<RepairOperatorId>;
311
312    /// Apply a destroy operator.
313    fn destroy(
314        &mut self,
315        solution: &mut Self::Solution,
316        operator: DestroyOperatorId,
317        degree: f64,
318        rng: &mut rand::rngs::StdRng,
319    ) -> DestroyResult;
320
321    /// Apply a repair operator.
322    fn repair(
323        &mut self,
324        solution: &mut Self::Solution,
325        destroyed: &DestroyResult,
326        operator: RepairOperatorId,
327    ) -> RepairResult;
328
329    /// Calculate relatedness between two items (for Shaw/Related removal).
330    fn relatedness(&self, solution: &Self::Solution, i: usize, j: usize) -> f64 {
331        // Default: no relatedness information
332        let _ = (solution, i, j);
333        0.0
334    }
335}
336
337/// The ALNS algorithm runner.
338pub struct AlnsRunner {
339    config: AlnsConfig,
340}
341
342impl AlnsRunner {
343    /// Create a new ALNS runner with the given configuration.
344    pub fn new(config: AlnsConfig) -> Self {
345        Self { config }
346    }
347
348    /// Run the ALNS algorithm on the given problem.
349    pub fn run<P, F>(&self, problem: &mut P, mut progress_callback: F) -> AlnsResult<P::Solution>
350    where
351        P: AlnsProblem,
352        F: FnMut(&AlnsProgress),
353    {
354        use rand::prelude::*;
355        use rand::SeedableRng;
356
357        // Initialize RNG
358        let mut rng = match self.config.seed {
359            Some(seed) => rand::rngs::StdRng::seed_from_u64(seed),
360            None => rand::rngs::StdRng::from_entropy(),
361        };
362
363        let start_time = Instant::now();
364
365        // Create initial solution
366        let mut current = problem.create_initial_solution();
367        let mut best = problem.clone_solution(&current);
368        let mut best_fitness = best.fitness();
369
370        // Get operators
371        let destroy_ops = problem.destroy_operators();
372        let repair_ops = problem.repair_operators();
373
374        // Initialize operator statistics
375        let mut destroy_stats: Vec<(DestroyOperatorId, OperatorStats)> = destroy_ops
376            .iter()
377            .map(|&op| (op, OperatorStats::new(1.0)))
378            .collect();
379
380        let mut repair_stats: Vec<(RepairOperatorId, OperatorStats)> = repair_ops
381            .iter()
382            .map(|&op| (op, OperatorStats::new(1.0)))
383            .collect();
384
385        // Initialize temperature
386        let mut temperature = self.config.initial_temperature;
387
388        // Statistics
389        let mut iteration = 0;
390        let mut segment = 0;
391        let mut improvements = 0;
392        let mut segment_accepts = 0;
393        let mut segment_total = 0;
394
395        // Main loop
396        loop {
397            // Check termination conditions
398            let elapsed = start_time.elapsed();
399            let elapsed_ms = elapsed.as_millis() as u64;
400
401            if iteration >= self.config.max_iterations {
402                break;
403            }
404
405            if self.config.time_limit_ms > 0 && elapsed_ms >= self.config.time_limit_ms {
406                break;
407            }
408
409            // Select destroy operator using roulette wheel
410            let destroy_idx = self.select_operator_by_weight(&destroy_stats, &mut rng);
411            let destroy_op = destroy_stats[destroy_idx].0;
412
413            // Select repair operator using roulette wheel
414            let repair_idx = self.select_operator_by_weight(&repair_stats, &mut rng);
415            let repair_op = repair_stats[repair_idx].0;
416
417            // Clone current solution
418            let mut candidate = problem.clone_solution(&current);
419
420            // Apply destroy (remove 10-40% of items)
421            let degree = rng.gen_range(0.1..=0.4);
422            let destroy_result = problem.destroy(&mut candidate, destroy_op, degree, &mut rng);
423
424            // Apply repair
425            let _repair_result = problem.repair(&mut candidate, &destroy_result, repair_op);
426
427            let candidate_fitness = candidate.fitness();
428            let current_fitness = current.fitness();
429
430            // Determine acceptance and score
431            let (accepted, score) = if candidate_fitness < best_fitness {
432                // New best solution
433                best = problem.clone_solution(&candidate);
434                best_fitness = candidate_fitness;
435                improvements += 1;
436                (true, self.config.score_best)
437            } else if candidate_fitness < current_fitness {
438                // Better than current
439                (true, self.config.score_better)
440            } else {
441                // SA acceptance criterion
442                let delta = candidate_fitness - current_fitness;
443                let accept_prob = (-delta / temperature).exp();
444                if rng.gen::<f64>() < accept_prob {
445                    (true, self.config.score_accepted)
446                } else {
447                    (false, 0.0)
448                }
449            };
450
451            // Update current if accepted
452            if accepted {
453                current = candidate;
454                segment_accepts += 1;
455            }
456
457            // Record operator usage
458            destroy_stats[destroy_idx].1.record_use(score);
459            repair_stats[repair_idx].1.record_use(score);
460
461            segment_total += 1;
462
463            // Update temperature
464            temperature *= self.config.cooling_rate;
465            temperature = temperature.max(self.config.final_temperature);
466
467            // Check if segment is complete
468            if iteration > 0 && iteration % self.config.segment_size == 0 {
469                // Update weights
470                for (_, stats) in &mut destroy_stats {
471                    stats.update_weight(self.config.reaction_factor, self.config.min_weight);
472                }
473                for (_, stats) in &mut repair_stats {
474                    stats.update_weight(self.config.reaction_factor, self.config.min_weight);
475                }
476
477                segment += 1;
478                segment_accepts = 0;
479                segment_total = 0;
480            }
481
482            // Progress callback
483            let acceptance_rate = if segment_total > 0 {
484                segment_accepts as f64 / segment_total as f64
485            } else {
486                0.0
487            };
488
489            let best_destroy = destroy_stats
490                .iter()
491                .max_by(|a, b| a.1.weight.partial_cmp(&b.1.weight).unwrap())
492                .map(|(op, _)| *op)
493                .unwrap_or(DestroyOperatorId::Random);
494
495            let best_repair = repair_stats
496                .iter()
497                .max_by(|a, b| a.1.weight.partial_cmp(&b.1.weight).unwrap())
498                .map(|(op, _)| *op)
499                .unwrap_or(RepairOperatorId::Greedy);
500
501            let progress = AlnsProgress {
502                iteration,
503                best_fitness,
504                current_fitness: current.fitness(),
505                temperature,
506                segment,
507                elapsed_ms,
508                acceptance_rate,
509                best_destroy,
510                best_repair,
511            };
512
513            progress_callback(&progress);
514
515            iteration += 1;
516        }
517
518        let elapsed_ms = start_time.elapsed().as_millis() as u64;
519
520        AlnsResult {
521            best_solution: best,
522            best_fitness,
523            iterations: iteration,
524            elapsed_ms,
525            improvements,
526            final_temperature: temperature,
527            destroy_weights: destroy_stats
528                .iter()
529                .map(|(op, stats)| (*op, stats.weight))
530                .collect(),
531            repair_weights: repair_stats
532                .iter()
533                .map(|(op, stats)| (*op, stats.weight))
534                .collect(),
535        }
536    }
537
538    /// Select an operator index using roulette wheel selection.
539    fn select_operator_by_weight<T>(
540        &self,
541        stats: &[(T, OperatorStats)],
542        rng: &mut rand::rngs::StdRng,
543    ) -> usize {
544        use rand::prelude::*;
545
546        let total_weight: f64 = stats.iter().map(|(_, s)| s.weight).sum();
547        if total_weight <= 0.0 || stats.is_empty() {
548            return 0;
549        }
550
551        let mut roll = rng.gen::<f64>() * total_weight;
552        for (i, (_, stat)) in stats.iter().enumerate() {
553            roll -= stat.weight;
554            if roll <= 0.0 {
555                return i;
556            }
557        }
558
559        stats.len() - 1
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn test_alns_config_default() {
569        let config = AlnsConfig::default();
570        assert_eq!(config.max_iterations, 10000);
571        assert_eq!(config.time_limit_ms, 60000);
572        assert_eq!(config.segment_size, 100);
573        assert!((config.score_best - 33.0).abs() < 1e-9);
574    }
575
576    #[test]
577    fn test_alns_config_builder() {
578        let config = AlnsConfig::new()
579            .with_max_iterations(5000)
580            .with_time_limit_ms(30000)
581            .with_segment_size(50)
582            .with_scores(10.0, 5.0, 1.0)
583            .with_reaction_factor(0.2)
584            .with_temperature(50.0, 0.999, 0.001)
585            .with_seed(42);
586
587        assert_eq!(config.max_iterations, 5000);
588        assert_eq!(config.time_limit_ms, 30000);
589        assert_eq!(config.segment_size, 50);
590        assert!((config.score_best - 10.0).abs() < 1e-9);
591        assert!((config.score_better - 5.0).abs() < 1e-9);
592        assert!((config.score_accepted - 1.0).abs() < 1e-9);
593        assert!((config.reaction_factor - 0.2).abs() < 1e-9);
594        assert!((config.initial_temperature - 50.0).abs() < 1e-9);
595        assert!((config.cooling_rate - 0.999).abs() < 1e-9);
596        assert!((config.final_temperature - 0.001).abs() < 1e-9);
597        assert_eq!(config.seed, Some(42));
598    }
599
600    #[test]
601    fn test_operator_stats() {
602        let mut stats = OperatorStats::new(1.0);
603
604        stats.record_use(10.0);
605        stats.record_use(20.0);
606
607        assert_eq!(stats.times_used, 2);
608        assert!((stats.total_score - 30.0).abs() < 1e-9);
609        assert!((stats.segment_score - 30.0).abs() < 1e-9);
610        assert_eq!(stats.segment_uses, 2);
611
612        stats.update_weight(0.5, 0.1);
613
614        // New weight = 1.0 * 0.5 + 15.0 * 0.5 = 8.0
615        assert!((stats.weight - 8.0).abs() < 1e-9);
616        assert!((stats.segment_score - 0.0).abs() < 1e-9);
617        assert_eq!(stats.segment_uses, 0);
618    }
619
620    #[test]
621    fn test_destroy_operator_ids() {
622        let ops = [
623            DestroyOperatorId::Random,
624            DestroyOperatorId::Worst,
625            DestroyOperatorId::Related,
626            DestroyOperatorId::Shaw,
627            DestroyOperatorId::Custom(0),
628        ];
629
630        assert_eq!(ops.len(), 5);
631        assert_eq!(DestroyOperatorId::Random, DestroyOperatorId::Random);
632        assert_ne!(DestroyOperatorId::Random, DestroyOperatorId::Worst);
633    }
634
635    #[test]
636    fn test_repair_operator_ids() {
637        let ops = [
638            RepairOperatorId::Greedy,
639            RepairOperatorId::Regret,
640            RepairOperatorId::Random,
641            RepairOperatorId::BottomLeftFill,
642            RepairOperatorId::Custom(0),
643        ];
644
645        assert_eq!(ops.len(), 5);
646        assert_eq!(RepairOperatorId::Greedy, RepairOperatorId::Greedy);
647        assert_ne!(RepairOperatorId::Greedy, RepairOperatorId::Regret);
648    }
649
650    #[test]
651    fn test_destroy_result() {
652        let result = DestroyResult {
653            removed_indices: vec![0, 3, 5],
654            operator: DestroyOperatorId::Random,
655        };
656
657        assert_eq!(result.removed_indices.len(), 3);
658        assert_eq!(result.operator, DestroyOperatorId::Random);
659    }
660
661    #[test]
662    fn test_repair_result() {
663        let result = RepairResult {
664            placed_count: 8,
665            unplaced_count: 2,
666            operator: RepairOperatorId::Greedy,
667        };
668
669        assert_eq!(result.placed_count, 8);
670        assert_eq!(result.unplaced_count, 2);
671        assert_eq!(result.operator, RepairOperatorId::Greedy);
672    }
673
674    #[test]
675    fn test_alns_progress() {
676        let progress = AlnsProgress {
677            iteration: 100,
678            best_fitness: 0.85,
679            current_fitness: 0.90,
680            temperature: 50.0,
681            segment: 1,
682            elapsed_ms: 5000,
683            acceptance_rate: 0.45,
684            best_destroy: DestroyOperatorId::Worst,
685            best_repair: RepairOperatorId::Greedy,
686        };
687
688        assert_eq!(progress.iteration, 100);
689        assert!((progress.best_fitness - 0.85).abs() < 1e-9);
690        assert_eq!(progress.segment, 1);
691        assert_eq!(progress.best_destroy, DestroyOperatorId::Worst);
692        assert_eq!(progress.best_repair, RepairOperatorId::Greedy);
693    }
694
695    // Mock implementation for testing the runner
696    #[derive(Clone, Debug)]
697    struct MockSolution {
698        fitness: f64,
699        placed: usize,
700        total: usize,
701    }
702
703    impl AlnsSolution for MockSolution {
704        fn fitness(&self) -> f64 {
705            self.fitness
706        }
707
708        fn placed_count(&self) -> usize {
709            self.placed
710        }
711
712        fn total_count(&self) -> usize {
713            self.total
714        }
715    }
716
717    struct MockProblem {
718        improvement_per_iteration: f64,
719    }
720
721    impl AlnsProblem for MockProblem {
722        type Solution = MockSolution;
723
724        fn create_initial_solution(&mut self) -> MockSolution {
725            MockSolution {
726                fitness: 1.0,
727                placed: 8,
728                total: 10,
729            }
730        }
731
732        fn clone_solution(&self, solution: &MockSolution) -> MockSolution {
733            solution.clone()
734        }
735
736        fn destroy_operators(&self) -> Vec<DestroyOperatorId> {
737            vec![DestroyOperatorId::Random, DestroyOperatorId::Worst]
738        }
739
740        fn repair_operators(&self) -> Vec<RepairOperatorId> {
741            vec![RepairOperatorId::Greedy, RepairOperatorId::BottomLeftFill]
742        }
743
744        fn destroy(
745            &mut self,
746            _solution: &mut MockSolution,
747            operator: DestroyOperatorId,
748            _degree: f64,
749            _rng: &mut rand::rngs::StdRng,
750        ) -> DestroyResult {
751            DestroyResult {
752                removed_indices: vec![0, 1, 2],
753                operator,
754            }
755        }
756
757        fn repair(
758            &mut self,
759            solution: &mut MockSolution,
760            _destroyed: &DestroyResult,
761            operator: RepairOperatorId,
762        ) -> RepairResult {
763            // Simulate improvement
764            solution.fitness -= self.improvement_per_iteration;
765            solution.fitness = solution.fitness.max(0.1);
766            RepairResult {
767                placed_count: solution.placed,
768                unplaced_count: 0,
769                operator,
770            }
771        }
772    }
773
774    #[test]
775    fn test_alns_runner_basic() {
776        let config = AlnsConfig::new()
777            .with_max_iterations(100)
778            .with_time_limit_ms(5000)
779            .with_seed(42);
780
781        let mut problem = MockProblem {
782            improvement_per_iteration: 0.01,
783        };
784
785        let runner = AlnsRunner::new(config);
786        let mut last_progress: Option<AlnsProgress> = None;
787
788        let result = runner.run(&mut problem, |progress| {
789            last_progress = Some(progress.clone());
790        });
791
792        assert!(result.best_fitness <= 1.0);
793        assert_eq!(result.iterations, 100);
794        assert!(last_progress.is_some());
795        assert!(!result.destroy_weights.is_empty());
796        assert!(!result.repair_weights.is_empty());
797    }
798
799    #[test]
800    fn test_alns_runner_time_limit() {
801        let config = AlnsConfig::new()
802            .with_max_iterations(1_000_000)
803            .with_time_limit_ms(100)
804            .with_seed(42);
805
806        let mut problem = MockProblem {
807            improvement_per_iteration: 0.001,
808        };
809
810        let runner = AlnsRunner::new(config);
811        let result = runner.run(&mut problem, |_| {});
812
813        // Should have terminated due to time limit
814        assert!(result.iterations < 1_000_000);
815        assert!(result.elapsed_ms >= 100);
816    }
817
818    #[test]
819    fn test_alns_weight_adaptation() {
820        let config = AlnsConfig::new()
821            .with_max_iterations(200)
822            .with_segment_size(50)
823            .with_reaction_factor(0.5)
824            .with_seed(42);
825
826        let mut problem = MockProblem {
827            improvement_per_iteration: 0.01,
828        };
829
830        let runner = AlnsRunner::new(config);
831        let result = runner.run(&mut problem, |_| {});
832
833        // Weights should have changed from initial values
834        let _initial_weight_sum: f64 = 2.0; // 2 destroy operators, each starting at 1.0
835        let _final_destroy_sum: f64 = result.destroy_weights.iter().map(|(_, w)| *w).sum();
836
837        // At least some adaptation should have occurred
838        // (weights won't all be exactly 1.0 anymore)
839        let max_destroy_weight = result
840            .destroy_weights
841            .iter()
842            .map(|(_, w)| *w)
843            .fold(0.0, f64::max);
844
845        assert!(max_destroy_weight >= 0.1); // At least min_weight
846        assert!(result.iterations == 200);
847    }
848}