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