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