Skip to main content

routik_solver/
anneal.rs

1//! Simulated-annealing metaheuristic over the local-search neighbourhoods.
2//!
3//! Starting from the Clarke-Wright construction, the search repeatedly proposes
4//! a random move, accepts every improving move and — with a temperature-decaying
5//! probability — some worsening ones, so it can climb out of local optima. Only
6//! capacity- and time-window-feasible moves are ever applied, so the returned
7//! solution is feasible by construction. Cooling is geometric; the run stops on
8//! an iteration and/or wall-clock budget.
9//!
10//! Determinism: all randomness comes from a seeded [`ChaCha8Rng`], so the same
11//! `(problem, matrix, config)` always yields the same solution.
12
13use crate::clarke_wright::{SolveError, clarke_wright};
14use crate::matrix::CostMatrix;
15use crate::model::{Problem, Solution};
16use crate::objective::Objective;
17use crate::search::State;
18use rand::{Rng, SeedableRng};
19use rand_chacha::ChaCha8Rng;
20use std::time::{Duration, Instant};
21
22/// How long the search may run: capped by iterations, wall-clock, or both
23/// (whichever bound is hit first). At least one bound should be set.
24#[derive(Debug, Clone, Copy)]
25pub struct Budget {
26    pub max_iterations: Option<u64>,
27    pub max_time: Option<Duration>,
28}
29
30impl Budget {
31    /// Stop after `n` iterations.
32    #[must_use]
33    pub fn iterations(n: u64) -> Self {
34        Self {
35            max_iterations: Some(n),
36            max_time: None,
37        }
38    }
39
40    /// Stop after `d` of wall-clock time.
41    #[must_use]
42    pub fn time(d: Duration) -> Self {
43        Self {
44            max_iterations: None,
45            max_time: Some(d),
46        }
47    }
48}
49
50impl Budget {
51    /// Divide the budget into `n` equal slices (one per restart). At least one
52    /// iteration / a non-zero duration per slice.
53    fn split(&self, n: usize) -> Self {
54        let n = n.max(1) as u64;
55        Self {
56            max_iterations: self.max_iterations.map(|m| (m / n).max(1)),
57            max_time: self.max_time.map(|d| d / n as u32),
58        }
59    }
60}
61
62impl Default for Budget {
63    fn default() -> Self {
64        Self::iterations(500_000)
65    }
66}
67
68/// Knobs for [`solve`]. [`Default`] minimises distance with a 100k-iteration
69/// budget and an auto-calibrated temperature schedule.
70#[derive(Debug, Clone, Copy)]
71pub struct SolverConfig {
72    /// Seed for the internal RNG — fixes the whole run.
73    pub seed: u64,
74    /// What to minimise.
75    pub objective: Objective,
76    /// Total run length, split evenly across [`Self::restarts`].
77    pub budget: Budget,
78    /// Number of independent multi-start cool-downs; the best is returned.
79    /// Diversifies the search so it doesn't get trapped in one local optimum.
80    pub restarts: usize,
81    /// Starting temperature; `None` auto-calibrates from sampled move deltas.
82    pub initial_temperature: Option<f64>,
83    /// Ending temperature; `None` uses `initial / 1000`.
84    pub final_temperature: Option<f64>,
85}
86
87impl Default for SolverConfig {
88    fn default() -> Self {
89        Self {
90            seed: 0x5EED,
91            objective: Objective::default(),
92            budget: Budget::default(),
93            restarts: 10,
94            initial_temperature: None,
95            final_temperature: None,
96        }
97    }
98}
99
100/// How often to poll the wall clock (polling every iteration is wasteful).
101const TIME_CHECK_STRIDE: u64 = 2048;
102
103/// Rolled-up run statistics from [`solve_with_stats`], for surfacing solver
104/// metadata (e.g. in an API response). Carries no solution data.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct SolveStats {
107    /// Total simulated-annealing iterations summed across every restart.
108    pub iterations: u64,
109    /// Number of multi-start restarts actually performed.
110    pub restarts: usize,
111}
112
113/// Optimise a problem with simulated annealing, returning a feasible solution.
114///
115/// The construction baseline is Clarke-Wright; the returned solution is never
116/// worse than it under the configured [`Objective`].
117///
118/// # Errors
119/// Propagates [`SolveError`] from construction (no vehicles, an unroutable
120/// stop, or more routes than vehicles).
121pub fn solve<M: CostMatrix>(
122    problem: &Problem,
123    matrix: &M,
124    config: &SolverConfig,
125) -> Result<Solution, SolveError> {
126    solve_with_stats(problem, matrix, config).map(|(solution, _)| solution)
127}
128
129/// Like [`solve`], but also returns [`SolveStats`] about the run.
130///
131/// # Errors
132/// Propagates [`SolveError`] from construction (no vehicles, an unroutable
133/// stop, or more routes than vehicles).
134pub fn solve_with_stats<M: CostMatrix>(
135    problem: &Problem,
136    matrix: &M,
137    config: &SolverConfig,
138) -> Result<(Solution, SolveStats), SolveError> {
139    let initial = clarke_wright(problem, matrix)?;
140
141    // Local search relocates/swaps stops *between* routes but never drops or adds
142    // one, so the set of unassigned stops is invariant across the whole search —
143    // capture it now and stamp it back onto the optimised solution at the end.
144    let unassigned = initial.unassigned.clone();
145
146    // Nothing to move around with 0 or 1 stop.
147    if problem.stops.len() <= 1 {
148        return Ok((
149            initial,
150            SolveStats {
151                iterations: 0,
152                restarts: 0,
153            },
154        ));
155    }
156
157    let capacity = problem
158        .vehicles
159        .iter()
160        .map(|v| v.capacity)
161        .min()
162        .unwrap_or(0);
163    let obj = &config.objective;
164    let restarts = config.restarts.max(1);
165    let per_start = config.budget.split(restarts);
166
167    // Multi-start: each restart cools fully from a fresh construction under its
168    // own RNG stream, so the runs both intensify (a full cool each) and
169    // diversify (different trajectories). Keeping the best is far more robust
170    // than a single cool, which can get trapped on clustered instances.
171    let mut best_routes: Option<Vec<Vec<usize>>> = None;
172    let mut best_cost = f64::INFINITY;
173    let mut iterations: u64 = 0;
174
175    for s in 0..restarts {
176        let seed = config
177            .seed
178            .wrapping_add((s as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
179        let mut rng = ChaCha8Rng::seed_from_u64(seed);
180        let mut state = State::from_solution(problem, matrix, capacity, &initial);
181        iterations += anneal_once(&mut state, &mut rng, config, &per_start, obj);
182        let cost = state.cost(obj);
183        if cost < best_cost {
184            best_cost = cost;
185            best_routes = Some(state.snapshot());
186        }
187    }
188
189    let mut state = State::from_solution(problem, matrix, capacity, &initial);
190    if let Some(routes) = best_routes {
191        state.restore(routes);
192    }
193    let stats = SolveStats {
194        iterations,
195        restarts,
196    };
197    let mut solution = state.to_solution();
198    solution.feasible = solution.feasible && unassigned.is_empty();
199    solution.unassigned = unassigned;
200    Ok((solution, stats))
201}
202
203/// One simulated-annealing cool-down. Leaves `state` at the best solution it
204/// found (always feasible, since only feasible moves are applied) and returns
205/// the number of iterations it ran.
206fn anneal_once<M: CostMatrix>(
207    state: &mut State<M>,
208    rng: &mut ChaCha8Rng,
209    config: &SolverConfig,
210    budget: &Budget,
211    obj: &Objective,
212) -> u64 {
213    let t0 = config
214        .initial_temperature
215        .unwrap_or_else(|| auto_initial_temperature(state, obj, rng));
216    let t_end = config.final_temperature.unwrap_or(t0 / 1000.0).max(1e-9);
217    let alpha = cooling_rate(t0, t_end, budget.max_iterations);
218
219    let mut temperature = t0;
220    let mut best_routes = state.snapshot();
221    let mut best_cost = state.cost(obj);
222
223    let start = Instant::now();
224    let mut iter: u64 = 0;
225    loop {
226        if budget.max_iterations.is_some_and(|m| iter >= m) {
227            break;
228        }
229        if iter.is_multiple_of(TIME_CHECK_STRIDE)
230            && budget
231                .max_time
232                .is_some_and(|limit| start.elapsed() >= limit)
233        {
234            break;
235        }
236
237        if let Some(mv) = state.random_move(rng) {
238            let delta = state.cost_delta(&mv, obj);
239            let accept = delta <= 0.0 || rng.random::<f64>() < (-delta / temperature).exp();
240            if accept && state.try_apply(&mv) {
241                let cost = state.cost(obj);
242                if cost < best_cost {
243                    best_cost = cost;
244                    best_routes = state.snapshot();
245                }
246            }
247        }
248
249        temperature = (temperature * alpha).max(t_end);
250        iter += 1;
251    }
252
253    state.restore(best_routes);
254    iter
255}
256
257/// Calibrate the starting temperature so a move that worsens the objective by
258/// the average sampled magnitude is accepted with probability ~0.5.
259fn auto_initial_temperature<M: CostMatrix>(
260    state: &State<M>,
261    obj: &Objective,
262    rng: &mut ChaCha8Rng,
263) -> f64 {
264    const SAMPLES: usize = 200;
265    let mut sum = 0.0;
266    let mut count = 0u32;
267    for _ in 0..SAMPLES {
268        if let Some(mv) = state.random_move(rng) {
269            sum += state.cost_delta(&mv, obj).abs();
270            count += 1;
271        }
272    }
273    if count == 0 {
274        return 1.0;
275    }
276    let mean = sum / f64::from(count);
277    (mean / std::f64::consts::LN_2).max(1e-6)
278}
279
280/// Per-iteration geometric cooling factor taking `t0` down to `t_end` over the
281/// iteration budget; a slow default when only a time budget is set.
282fn cooling_rate(t0: f64, t_end: f64, max_iterations: Option<u64>) -> f64 {
283    match max_iterations {
284        Some(iters) if iters > 1 => (t_end / t0).powf(1.0 / iters as f64),
285        _ => 0.99997,
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::matrix::EuclideanMatrix;
293    use crate::model::{Coord, Stop, StopId, TimeWindow, Vehicle, VehicleId};
294
295    /// Four spatial clusters of stops with generous windows: there is a clearly
296    /// better routing than Clarke-Wright tends to build, so SA should improve.
297    fn clustered_problem() -> Problem {
298        let centers = [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)];
299        let mut stops = Vec::new();
300        let mut id = 1u32;
301        for (cx, cy) in centers {
302            for k in 0..5 {
303                let off = f64::from(k) * 2.0;
304                stops.push(Stop {
305                    id: StopId(id),
306                    coord: Coord::new(cy + off, cx + off), // lat, lon
307                    demand: 5,
308                    time_window: Some(TimeWindow {
309                        start: 0.0,
310                        end: 100_000.0,
311                    }),
312                    service_time: 1.0,
313                });
314                id += 1;
315            }
316        }
317        let vehicles = (1..=8u32)
318            .map(|i| Vehicle {
319                id: VehicleId(i),
320                capacity: 30,
321            })
322            .collect();
323        Problem {
324            depot: Coord::new(50.0, 50.0),
325            stops,
326            vehicles,
327            depot_window: Some(TimeWindow {
328                start: 0.0,
329                end: 1_000_000.0,
330            }),
331        }
332    }
333
334    /// A single-vehicle, no-window instance over scattered points — a TSP where
335    /// the Clarke-Wright tour has crossings that 2-opt/relocate can iron out, so
336    /// SA reliably improves on the construction.
337    fn scattered_problem() -> Problem {
338        let pts = [
339            (20.0, 20.0),
340            (80.0, 25.0),
341            (15.0, 70.0),
342            (70.0, 80.0),
343            (45.0, 10.0),
344            (90.0, 55.0),
345            (30.0, 45.0),
346            (60.0, 30.0),
347            (10.0, 40.0),
348            (75.0, 65.0),
349            (40.0, 85.0),
350            (55.0, 60.0),
351        ];
352        let stops = pts
353            .iter()
354            .enumerate()
355            .map(|(i, &(x, y))| Stop {
356                id: StopId((i + 1) as u32),
357                coord: Coord::new(y, x),
358                demand: 1,
359                time_window: None,
360                service_time: 0.0,
361            })
362            .collect();
363        Problem {
364            depot: Coord::new(50.0, 50.0),
365            stops,
366            vehicles: vec![Vehicle {
367                id: VehicleId(1),
368                capacity: 1000,
369            }],
370            depot_window: None,
371        }
372    }
373
374    fn all_feasible(problem: &Problem, sol: &Solution) -> bool {
375        let served: usize = sol.routes.iter().map(|r| r.stop_ids.len()).sum();
376        sol.feasible && served == problem.stops.len()
377    }
378
379    #[test]
380    fn same_seed_is_deterministic() {
381        let p = clustered_problem();
382        let m = EuclideanMatrix::from_problem(&p);
383        let cfg = SolverConfig {
384            seed: 123,
385            budget: Budget::iterations(20_000),
386            ..Default::default()
387        };
388        let a = solve(&p, &m, &cfg).expect("feasible");
389        let b = solve(&p, &m, &cfg).expect("feasible");
390        assert_eq!(a.total_distance, b.total_distance);
391        let route_a: Vec<_> = a.routes.iter().map(|r| &r.stop_ids).collect();
392        let route_b: Vec<_> = b.routes.iter().map(|r| &r.stop_ids).collect();
393        assert_eq!(route_a, route_b);
394    }
395
396    #[test]
397    fn different_seeds_can_diverge_but_stay_feasible() {
398        let p = clustered_problem();
399        let m = EuclideanMatrix::from_problem(&p);
400        for seed in [1u64, 2, 99] {
401            let cfg = SolverConfig {
402                seed,
403                budget: Budget::iterations(20_000),
404                ..Default::default()
405            };
406            let sol = solve(&p, &m, &cfg).expect("feasible");
407            assert!(all_feasible(&p, &sol), "seed {seed} produced infeasible");
408        }
409    }
410
411    #[test]
412    fn improves_on_clarke_wright() {
413        let p = scattered_problem();
414        let m = EuclideanMatrix::from_problem(&p);
415        let cw = clarke_wright(&p, &m).expect("feasible");
416        let cfg = SolverConfig {
417            seed: 7,
418            budget: Budget::iterations(40_000),
419            ..Default::default()
420        };
421        let sol = solve(&p, &m, &cfg).expect("feasible");
422        assert!(all_feasible(&p, &sol));
423        assert!(
424            sol.total_distance < cw.total_distance,
425            "SA {} did not improve on CW {}",
426            sol.total_distance,
427            cw.total_distance
428        );
429    }
430
431    #[test]
432    fn respects_tight_time_windows() {
433        // Stops on a line, each only serviceable in a narrow staggered window,
434        // forcing a specific visiting order.
435        let stops = (1..=6u32)
436            .map(|i| Stop {
437                id: StopId(i),
438                coord: Coord::new(0.0, f64::from(i) * 10.0),
439                demand: 1,
440                time_window: Some(TimeWindow {
441                    start: f64::from(i) * 10.0 - 2.0,
442                    end: f64::from(i) * 10.0 + 2.0,
443                }),
444                service_time: 0.5,
445            })
446            .collect();
447        let p = Problem {
448            depot: Coord::new(0.0, 0.0),
449            stops,
450            vehicles: (1..=3u32)
451                .map(|i| Vehicle {
452                    id: VehicleId(i),
453                    capacity: 100,
454                })
455                .collect(),
456            depot_window: Some(TimeWindow {
457                start: 0.0,
458                end: 1000.0,
459            }),
460        };
461        let m = EuclideanMatrix::from_problem(&p);
462        let cfg = SolverConfig {
463            seed: 5,
464            budget: Budget::iterations(20_000),
465            ..Default::default()
466        };
467        let sol = solve(&p, &m, &cfg).expect("feasible");
468        assert!(all_feasible(&p, &sol));
469    }
470}