Skip to main content

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