Skip to main content

routik_solver/
clarke_wright.rs

1//! Clarke-Wright savings construction heuristic.
2//!
3//! Produces an initial **capacity-feasible** solution: each stop starts on its
4//! own out-and-back route, then routes are merged in decreasing order of the
5//! savings `s(i,j) = d(depot,i) + d(depot,j) - d(i,j)` as long as the combined
6//! load fits a vehicle. Time windows are **not** enforced yet.
7//!
8//! The fleet is treated as homogeneous: the binding capacity is that of the
9//! smallest vehicle, so any route built here fits any vehicle.
10
11use crate::feasibility::{build_solution, evaluate_route};
12use crate::matrix::CostMatrix;
13use crate::model::{LocationId, Problem, Solution, Unassigned, UnassignedCode};
14
15/// A structural error that stops construction before it can begin.
16///
17/// Over-constrained instances are **not** errors: stops that cannot be placed
18/// are returned in [`Solution::unassigned`](crate::Solution::unassigned) with a
19/// diagnostic reason, and the routable stops are still solved. The only thing
20/// left here is a problem with no fleet at all.
21#[derive(Debug, thiserror::Error, PartialEq, Eq)]
22pub enum SolveError {
23    /// The problem has no vehicles to assign routes to.
24    #[error("no vehicles in the problem")]
25    NoVehicles,
26}
27
28/// Build a solution with the Clarke-Wright savings heuristic, diagnosing any
29/// stops it cannot place.
30///
31/// `matrix` must be indexed to match `problem` (depot at
32/// [`LocationId::DEPOT`](crate::model::LocationId::DEPOT), stops at `1..=n`);
33/// [`HaversineMatrix::from_problem`](crate::matrix::HaversineMatrix::from_problem)
34/// guarantees that layout.
35///
36/// Over-constrained instances do not fail: a stop that cannot sit on any route
37/// (its demand exceeds capacity, its window is unreachable) or that the fleet has
38/// no room for is returned in [`Solution::unassigned`](crate::Solution::unassigned)
39/// with an indicative reason, while the routable stops are still routed.
40///
41/// # Errors
42/// Returns [`SolveError::NoVehicles`] when the fleet is empty — the one
43/// structural case with nothing to solve over.
44pub fn clarke_wright(problem: &Problem, matrix: &impl CostMatrix) -> Result<Solution, SolveError> {
45    if problem.vehicles.is_empty() {
46        return Err(SolveError::NoVehicles);
47    }
48    let n = problem.stops.len();
49    let fleet = problem.vehicles.len();
50
51    // Homogeneous-fleet capacity = the smallest vehicle (safe lower bound).
52    let capacity = problem
53        .vehicles
54        .iter()
55        .map(|v| v.capacity)
56        .min()
57        .unwrap_or(0);
58
59    // Pass 1 — single-stop infeasibility. A stop whose demand exceeds capacity,
60    // or that cannot be reached and serviced within its own window, can never
61    // sit on any feasible route. These reasons are exact, not heuristic.
62    let mut unassigned: Vec<Unassigned> = Vec::new();
63    let mut excluded = vec![false; n];
64    for (idx, stop) in problem.stops.iter().enumerate() {
65        if stop.demand > capacity {
66            excluded[idx] = true;
67            unassigned.push(Unassigned {
68                stop: stop.id,
69                code: UnassignedCode::CapacityExceeded,
70                detail: format!("demand {} exceeds vehicle capacity {capacity}", stop.demand),
71            });
72        } else if evaluate_route(problem, matrix, capacity, &[idx + 1]).is_none() {
73            excluded[idx] = true;
74            unassigned.push(Unassigned {
75                stop: stop.id,
76                code: UnassignedCode::TimeWindowInfeasible,
77                detail: "cannot be reached and serviced within its time window, then \
78                         returned before the depot closes — even alone on an empty route"
79                    .to_string(),
80            });
81        }
82    }
83
84    // Working state, over the *routable* stops only. Locations are 1..=n; route
85    // `r` is an ordered list of those indices. `route_of[loc-1]` is the route
86    // currently holding `loc`. Excluded stops start with an empty route and are
87    // never merged, so they fall out below.
88    let mut routes: Vec<Vec<usize>> = (1..=n)
89        .map(|loc| {
90            if excluded[loc - 1] {
91                Vec::new()
92            } else {
93                vec![loc]
94            }
95        })
96        .collect();
97    let mut route_of: Vec<usize> = (0..n).collect();
98    let mut load: Vec<u32> = problem.stops.iter().map(|s| s.demand).collect();
99
100    // Savings for every unordered pair of routable stops.
101    let depot = LocationId::DEPOT;
102    let mut savings: Vec<(f64, usize, usize)> = Vec::with_capacity(n * n / 2);
103    for i in 1..=n {
104        if excluded[i - 1] {
105            continue;
106        }
107        let d_di = matrix.distance(depot, LocationId(i));
108        for j in (i + 1)..=n {
109            if excluded[j - 1] {
110                continue;
111            }
112            let s = d_di + matrix.distance(depot, LocationId(j))
113                - matrix.distance(LocationId(i), LocationId(j));
114            savings.push((s, i, j));
115        }
116    }
117    // Descending savings; total_cmp keeps it total and clippy-clean on f64.
118    savings.sort_by(|a, b| b.0.total_cmp(&a.0));
119
120    for (s, i, j) in savings {
121        // No benefit (or a penalty) from here on out.
122        if s <= 0.0 {
123            break;
124        }
125        let ri = route_of[i - 1];
126        let rj = route_of[j - 1];
127        if ri == rj {
128            continue;
129        }
130        // Compare in u64: a merged demand can exceed u32 on adversarial input, and
131        // an overflowing add would panic. The summed routes stay u32 once merged
132        // (the merge only proceeds when the sum is ≤ capacity ≤ u32::MAX).
133        if u64::from(load[ri]) + u64::from(load[rj]) > u64::from(capacity) {
134            continue;
135        }
136
137        // Merge only if i and j sit at the ends of their routes, so they can
138        // become interior-adjacent without reordering anyone else.
139        let i_first = routes[ri].first().copied() == Some(i);
140        let i_last = routes[ri].last().copied() == Some(i);
141        let j_first = routes[rj].first().copied() == Some(j);
142        let j_last = routes[rj].last().copied() == Some(j);
143        if !(i_first || i_last) || !(j_first || j_last) {
144            continue;
145        }
146
147        // Build the candidate [..i] ++ [j..] (i and j adjacent) without
148        // mutating yet, so we can drop it if it breaks a time window.
149        let mut left = routes[ri].clone();
150        let mut right = routes[rj].clone();
151        if left.first().copied() == Some(i) {
152            left.reverse();
153        }
154        if right.last().copied() == Some(j) {
155            right.reverse();
156        }
157        left.extend_from_slice(&right);
158
159        // Capacity is already checked; reject the merge if it breaks a window.
160        if evaluate_route(problem, matrix, capacity, &left).is_none() {
161            continue;
162        }
163
164        for &loc in &left {
165            route_of[loc - 1] = ri;
166        }
167        load[ri] += load[rj];
168        load[rj] = 0;
169        routes[ri] = left;
170        routes[rj].clear(); // merged away; stays empty
171    }
172
173    let mut final_routes: Vec<Vec<usize>> = routes.into_iter().filter(|r| !r.is_empty()).collect();
174
175    // Pass 2 — fleet exhaustion. Construction merged the routable stops into as
176    // few routes as the windows and capacity allow; if that is still more than
177    // the fleet, some stops cannot be served. Keep the routes that cover the most
178    // stops, then try to re-insert each dropped stop into a kept route (the last
179    // failed attempt decides its reason). `sort_by` is stable, so equal-length
180    // routes keep their construction order and the result stays deterministic.
181    if final_routes.len() > fleet {
182        final_routes.sort_by_key(|r| std::cmp::Reverse(r.len()));
183        let surplus = final_routes.split_off(fleet);
184        for route in surplus {
185            for loc in route {
186                place_or_diagnose(
187                    problem,
188                    matrix,
189                    capacity,
190                    &mut final_routes,
191                    loc,
192                    &mut unassigned,
193                );
194            }
195        }
196    }
197
198    let mut solution = build_solution(problem, matrix, capacity, &final_routes);
199    solution.feasible = solution.feasible && unassigned.is_empty();
200    solution.unassigned = unassigned;
201    Ok(solution)
202}
203
204/// Try to insert stop `loc` into the best feasible slot of an already-built
205/// `kept` route; on success the route is updated in place. On failure — the
206/// fleet has no room for it — record the stop as unassigned, reporting whether
207/// capacity or the time windows blocked every attempted insertion (the
208/// jsprit-style "reason of the last failed attempt").
209fn place_or_diagnose(
210    problem: &Problem,
211    matrix: &impl CostMatrix,
212    capacity: u32,
213    kept: &mut [Vec<usize>],
214    loc: usize,
215    unassigned: &mut Vec<Unassigned>,
216) {
217    let stop = &problem.stops[loc - 1];
218    let mut all_capacity_blocked = true;
219
220    for route in kept.iter_mut() {
221        // u64 sum: a route's demand can exceed u32 on adversarial input, so both
222        // the accumulation and the comparison run in u64 to avoid an overflow panic.
223        let route_load: u64 = route
224            .iter()
225            .map(|&l| u64::from(problem.stops[l - 1].demand))
226            .sum();
227        if route_load + u64::from(stop.demand) > u64::from(capacity) {
228            continue; // no room on this route; a capacity block
229        }
230        all_capacity_blocked = false;
231        for pos in 0..=route.len() {
232            let mut candidate = route.clone();
233            candidate.insert(pos, loc);
234            if evaluate_route(problem, matrix, capacity, &candidate).is_some() {
235                *route = candidate;
236                return; // placed
237            }
238        }
239    }
240
241    // The stop is fine on its own (it passed pass 1) but no remaining vehicle can
242    // take it: the fleet is exhausted. Name the dominant blocker in the detail.
243    let blocker = if all_capacity_blocked {
244        "every assigned route is already at capacity"
245    } else {
246        "no assigned route has a time-feasible slot for it"
247    };
248    unassigned.push(Unassigned {
249        stop: stop.id,
250        code: UnassignedCode::NoCompatibleVehicle,
251        detail: format!(
252            "the fleet of {} vehicle(s) is fully assigned and this stop could not be \
253             inserted into any existing route ({blocker})",
254            kept.len()
255        ),
256    });
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::matrix::HaversineMatrix;
263    use crate::model::{Coord, Stop, StopId, TimeWindow, Vehicle, VehicleId};
264    use std::collections::HashSet;
265
266    fn stop(id: u32, lat: f64, lon: f64, demand: u32) -> Stop {
267        Stop {
268            id: StopId(id),
269            coord: Coord::new(lat, lon),
270            demand,
271            time_window: None,
272            service_time: 0.1,
273        }
274    }
275
276    fn sample_problem() -> Problem {
277        Problem {
278            depot: Coord::new(48.8566, 2.3522),
279            stops: vec![
280                stop(1, 48.8606, 2.3376, 10),
281                stop(2, 48.8530, 2.3499, 15),
282                stop(3, 48.8738, 2.2950, 20),
283                stop(4, 48.8462, 2.3372, 12),
284                stop(5, 48.8867, 2.3431, 8),
285                stop(6, 48.8330, 2.3708, 25),
286            ],
287            vehicles: vec![
288                Vehicle {
289                    id: VehicleId(1),
290                    capacity: 50,
291                },
292                Vehicle {
293                    id: VehicleId(2),
294                    capacity: 50,
295                },
296                Vehicle {
297                    id: VehicleId(3),
298                    capacity: 50,
299                },
300            ],
301            depot_window: None,
302        }
303    }
304
305    #[test]
306    fn each_route_respects_capacity() {
307        let problem = sample_problem();
308        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
309        let solution = clarke_wright(&problem, &matrix).expect("feasible");
310
311        assert!(solution.feasible);
312        for route in &solution.routes {
313            assert!(route.load <= 50, "route load {} > 50", route.load);
314        }
315    }
316
317    #[test]
318    fn total_load_equals_total_demand() {
319        let problem = sample_problem();
320        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
321        let solution = clarke_wright(&problem, &matrix).expect("feasible");
322
323        let demand: u32 = problem.stops.iter().map(|s| s.demand).sum();
324        let load: u32 = solution.routes.iter().map(|r| r.load).sum();
325        assert_eq!(load, demand);
326    }
327
328    #[test]
329    fn every_stop_served_exactly_once() {
330        let problem = sample_problem();
331        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
332        let solution = clarke_wright(&problem, &matrix).expect("feasible");
333
334        let mut seen: Vec<StopId> = solution
335            .routes
336            .iter()
337            .flat_map(|r| r.stop_ids.iter().copied())
338            .collect();
339        let unique: HashSet<StopId> = seen.iter().copied().collect();
340
341        assert_eq!(
342            seen.len(),
343            problem.stops.len(),
344            "wrong number of stops served"
345        );
346        assert_eq!(unique.len(), problem.stops.len(), "a stop was duplicated");
347
348        seen.sort();
349        let mut expected: Vec<StopId> = problem.stops.iter().map(|s| s.id).collect();
350        expected.sort();
351        assert_eq!(seen, expected);
352    }
353
354    #[test]
355    fn oversized_demand_is_unassigned() {
356        // A stop bigger than any truck can never be placed; the rest still are.
357        let mut problem = sample_problem();
358        problem.stops[0].demand = 999;
359        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
360        let solution = clarke_wright(&problem, &matrix).expect("routable stops still solved");
361
362        assert!(
363            !solution.feasible,
364            "an unassigned stop must flip feasible to false"
365        );
366        assert_eq!(solution.unassigned.len(), 1);
367        let u = &solution.unassigned[0];
368        assert_eq!(u.stop, StopId(1));
369        assert_eq!(u.code, UnassignedCode::CapacityExceeded);
370        assert!(u.detail.contains("999"), "detail should quote the demand");
371
372        // The other five stops are routed, and stop 1 appears in no route.
373        let served: usize = solution.routes.iter().map(|r| r.stop_ids.len()).sum();
374        assert_eq!(served, problem.stops.len() - 1);
375        assert!(
376            solution
377                .routes
378                .iter()
379                .flat_map(|r| &r.stop_ids)
380                .all(|s| *s != StopId(1))
381        );
382    }
383
384    #[test]
385    fn unreachable_window_is_unassigned() {
386        // Stop 3 sits kilometres from the depot, but its window slams shut at
387        // t=0.001 h — unreachable even alone on an empty route.
388        let mut problem = sample_problem();
389        problem.stops[2].time_window = Some(TimeWindow {
390            start: 0.0,
391            end: 0.001,
392        });
393        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
394        let solution = clarke_wright(&problem, &matrix).expect("other stops solved");
395
396        let u = solution
397            .unassigned
398            .iter()
399            .find(|u| u.stop == StopId(3))
400            .expect("stop 3 should be unassigned");
401        assert_eq!(u.code, UnassignedCode::TimeWindowInfeasible);
402        assert!(!solution.feasible);
403    }
404
405    #[test]
406    fn fleet_exhaustion_unassigns_with_no_compatible_vehicle() {
407        // Four full-truck stops (demand == capacity) need four routes, but only
408        // two vehicles exist: two stops cannot be served by any remaining truck.
409        let problem = Problem {
410            depot: Coord::new(48.8566, 2.3522),
411            stops: vec![
412                stop(1, 48.8606, 2.3376, 30),
413                stop(2, 48.8530, 2.3499, 30),
414                stop(3, 48.8738, 2.2950, 30),
415                stop(4, 48.8462, 2.3372, 30),
416            ],
417            vehicles: vec![
418                Vehicle {
419                    id: VehicleId(1),
420                    capacity: 30,
421                },
422                Vehicle {
423                    id: VehicleId(2),
424                    capacity: 30,
425                },
426            ],
427            depot_window: None,
428        };
429        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
430        let solution = clarke_wright(&problem, &matrix).expect("two stops solved");
431
432        assert!(!solution.feasible);
433        assert_eq!(solution.routes.len(), 2);
434        assert_eq!(solution.unassigned.len(), 2);
435        assert!(
436            solution
437                .unassigned
438                .iter()
439                .all(|u| u.code == UnassignedCode::NoCompatibleVehicle)
440        );
441
442        // Nothing vanishes: every stop is either routed or explicitly unassigned.
443        let routed: usize = solution.routes.iter().map(|r| r.stop_ids.len()).sum();
444        assert_eq!(routed + solution.unassigned.len(), problem.stops.len());
445    }
446
447    #[test]
448    fn rejects_empty_fleet() {
449        let mut problem = sample_problem();
450        problem.vehicles.clear();
451        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
452        assert_eq!(
453            clarke_wright(&problem, &matrix),
454            Err(SolveError::NoVehicles)
455        );
456    }
457
458    #[test]
459    fn merges_into_fewer_routes_than_stops() {
460        // With slack capacity, savings merges should beat one-route-per-stop.
461        let problem = sample_problem();
462        let matrix = HaversineMatrix::from_problem(&problem, 50.0);
463        let solution = clarke_wright(&problem, &matrix).expect("feasible");
464        assert!(solution.routes.len() < problem.stops.len());
465    }
466}