Skip to main content

converge_optimization/suggestors/
time_window_routing.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Greedy single-vehicle routing with time windows.
5//!
6//! Reads a [`VrptwRequest`] from context and proposes a [`VrptwPlan`]. This is
7//! the portable pure Rust baseline for Ferrox native CP-SAT VRPTW solving.
8
9use async_trait::async_trait;
10use converge_pack::ProvenanceSource;
11use converge_pack::{
12    AgentEffect, Context, ContextKey, DiagnosticPayload, FactPayload, ProposedFact, Suggestor,
13};
14use serde::{Deserialize, Serialize};
15use std::time::Instant;
16
17// -- Request -----------------------------------------------------------------
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct VrptwCustomer {
22    pub id: usize,
23    pub name: String,
24    pub x: f64,
25    pub y: f64,
26    pub window_open: i64,
27    pub window_close: i64,
28    pub service_time: i64,
29}
30
31impl VrptwCustomer {
32    pub fn travel_to(&self, other: &Self) -> f64 {
33        euclidean((self.x, self.y), (other.x, other.y))
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct VrptwDepot {
40    pub x: f64,
41    pub y: f64,
42    pub ready_time: i64,
43    pub due_time: i64,
44}
45
46impl VrptwDepot {
47    pub fn travel_to_customer(&self, customer: &VrptwCustomer) -> f64 {
48        euclidean((self.x, self.y), (customer.x, customer.y))
49    }
50}
51
52/// Seed under [`ContextKey::Seeds`] with id prefix `"vrptw-request:"`.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct VrptwRequest {
56    pub id: String,
57    pub depot: VrptwDepot,
58    pub customers: Vec<VrptwCustomer>,
59    #[serde(default = "default_time_limit")]
60    pub time_limit_seconds: f64,
61}
62
63impl FactPayload for VrptwRequest {
64    const FAMILY: &'static str = "converge.optimization.vrptw.request";
65    const VERSION: u16 = 1;
66}
67
68fn default_time_limit() -> f64 {
69    30.0
70}
71
72// -- Plan --------------------------------------------------------------------
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct RouteStop {
77    pub customer_id: usize,
78    pub customer_name: String,
79    pub arrival: i64,
80    pub departure: i64,
81}
82
83/// Written to [`ContextKey::Strategies`] with id prefix `"vrptw-plan-greedy:"`.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct VrptwPlan {
87    pub request_id: String,
88    pub route: Vec<RouteStop>,
89    pub customers_total: usize,
90    pub customers_visited: usize,
91    pub total_distance: f64,
92    pub return_time: i64,
93    pub solver: String,
94    pub status: String,
95    pub wall_time_seconds: f64,
96}
97
98impl FactPayload for VrptwPlan {
99    const FAMILY: &'static str = "converge.optimization.vrptw.plan";
100    const VERSION: u16 = 1;
101}
102
103impl VrptwPlan {
104    #[allow(clippy::cast_precision_loss)]
105    pub fn visit_ratio(&self) -> f64 {
106        if self.customers_total == 0 {
107            return 0.0;
108        }
109        self.customers_visited as f64 / self.customers_total as f64
110    }
111}
112
113// -- Suggestor ---------------------------------------------------------------
114
115const REQUEST_PREFIX: &str = "vrptw-request:";
116const PLAN_PREFIX: &str = "vrptw-plan-greedy:";
117const ERROR_PREFIX: &str = "vrptw-request-error:";
118
119/// Routes a single vehicle via nearest-neighbor search with time-window
120/// feasibility checks.
121pub struct NearestNeighborTimeWindowRoutingSuggestor;
122
123#[async_trait]
124impl Suggestor for NearestNeighborTimeWindowRoutingSuggestor {
125    fn name(&self) -> &str {
126        "NearestNeighborTimeWindowRoutingSuggestor"
127    }
128
129    fn dependencies(&self) -> &[ContextKey] {
130        &[ContextKey::Seeds]
131    }
132
133    fn complexity_hint(&self) -> Option<&'static str> {
134        Some("O(n^2) nearest-neighbor with time-window feasibility")
135    }
136
137    fn accepts(&self, ctx: &dyn Context) -> bool {
138        ctx.get(ContextKey::Seeds).iter().any(|f| {
139            f.id().as_str().starts_with(REQUEST_PREFIX)
140                && match f.payload::<VrptwRequest>() {
141                    Some(_) => !plan_exists(ctx, req_id(f.id().as_str())),
142                    None => !error_exists(ctx, f.id().as_str()),
143                }
144        })
145    }
146
147    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
148        let mut proposals = Vec::new();
149
150        for fact in ctx
151            .get(ContextKey::Seeds)
152            .iter()
153            .filter(|f| f.id().as_str().starts_with(REQUEST_PREFIX))
154        {
155            match fact.payload::<VrptwRequest>() {
156                Some(req) => {
157                    if plan_exists(ctx, req_id(fact.id().as_str())) {
158                        continue;
159                    }
160                    let plan = solve_nearest_neighbor_time_windows(req);
161                    let confidence = (plan.visit_ratio() * 0.60).min(0.60);
162                    proposals.push(
163                        ProposedFact::new(
164                            ContextKey::Strategies,
165                            format!("{}{}", PLAN_PREFIX, plan.request_id),
166                            plan.clone(),
167                            self.name().to_string(),
168                        )
169                        .with_confidence(confidence),
170                    );
171                }
172                None => {
173                    if error_exists(ctx, fact.id().as_str()) {
174                        continue;
175                    }
176                    proposals.push(
177                        ProposedFact::new(
178                            ContextKey::Diagnostic,
179                            format!("{}{}", ERROR_PREFIX, fact.id()),
180                            DiagnosticPayload::new(
181                                self.name(),
182                                format!(
183                                    "malformed vrptw request '{}': expected {} v{} payload",
184                                    fact.id(),
185                                    VrptwRequest::FAMILY,
186                                    VrptwRequest::VERSION
187                                ),
188                            ),
189                            self.name().to_string(),
190                        )
191                        .with_confidence(1.0),
192                    );
193                }
194            }
195        }
196
197        if proposals.is_empty() {
198            AgentEffect::empty()
199        } else {
200            AgentEffect::with_proposals(proposals)
201        }
202    }
203
204    fn provenance(&self) -> &'static str {
205        super::CONVERGE_OPTIMIZATION_PROVENANCE.as_str()
206    }
207}
208
209// -- Core logic --------------------------------------------------------------
210
211pub fn solve_nearest_neighbor_time_windows(req: &VrptwRequest) -> VrptwPlan {
212    let t0 = Instant::now();
213    let n = req.customers.len();
214    let mut visited = vec![false; n];
215    let mut route = Vec::new();
216
217    let mut cur_x = req.depot.x;
218    let mut cur_y = req.depot.y;
219    let mut cur_t = req.depot.ready_time;
220    let mut total_distance = 0.0_f64;
221
222    loop {
223        let best = (0..n)
224            .filter(|&i| !visited[i])
225            .filter_map(|i| {
226                let customer = &req.customers[i];
227                if customer.service_time < 0 || customer.window_close < customer.window_open {
228                    return None;
229                }
230
231                let distance = euclidean((cur_x, cur_y), (customer.x, customer.y));
232                #[allow(clippy::cast_possible_truncation)]
233                let travel_time = distance.ceil() as i64;
234                let arrival = cur_t + travel_time;
235                let departure = arrival.max(customer.window_open) + customer.service_time;
236                let return_distance =
237                    euclidean((customer.x, customer.y), (req.depot.x, req.depot.y));
238                #[allow(clippy::cast_possible_truncation)]
239                let return_time = return_distance.ceil() as i64;
240
241                if arrival <= customer.window_close && departure + return_time <= req.depot.due_time
242                {
243                    Some((i, distance, arrival, departure))
244                } else {
245                    None
246                }
247            })
248            .min_by(|a, b| a.1.total_cmp(&b.1));
249
250        match best {
251            Some((i, distance, arrival, departure)) => {
252                let customer = &req.customers[i];
253                visited[i] = true;
254                total_distance += distance;
255                cur_x = customer.x;
256                cur_y = customer.y;
257                cur_t = departure;
258                route.push(RouteStop {
259                    customer_id: customer.id,
260                    customer_name: customer.name.clone(),
261                    arrival: arrival.max(customer.window_open),
262                    departure,
263                });
264            }
265            None => break,
266        }
267    }
268
269    let return_distance = euclidean((cur_x, cur_y), (req.depot.x, req.depot.y));
270    total_distance += return_distance;
271    #[allow(clippy::cast_possible_truncation)]
272    let return_time = cur_t + return_distance.ceil() as i64;
273    let customers_visited = route.len();
274    let status = if n == 0 || customers_visited > 0 {
275        "feasible"
276    } else {
277        "infeasible"
278    };
279
280    VrptwPlan {
281        request_id: req.id.clone(),
282        route,
283        customers_total: n,
284        customers_visited,
285        total_distance,
286        return_time,
287        solver: "nearest-neighbor-tw".to_string(),
288        status: status.to_string(),
289        wall_time_seconds: t0.elapsed().as_secs_f64(),
290    }
291}
292
293fn euclidean(a: (f64, f64), b: (f64, f64)) -> f64 {
294    let dx = a.0 - b.0;
295    let dy = a.1 - b.1;
296    (dx * dx + dy * dy).sqrt()
297}
298
299fn req_id(fact_id: &str) -> &str {
300    fact_id.trim_start_matches(REQUEST_PREFIX)
301}
302
303fn plan_exists(ctx: &dyn Context, request_id: &str) -> bool {
304    let id = format!("{}{}", PLAN_PREFIX, request_id);
305    ctx.get(ContextKey::Strategies)
306        .iter()
307        .any(|f| f.id().as_str() == id)
308}
309
310fn error_exists(ctx: &dyn Context, fact_id: &str) -> bool {
311    let id = format!("{}{}", ERROR_PREFIX, fact_id);
312    ctx.get(ContextKey::Diagnostic)
313        .iter()
314        .any(|f| f.id().as_str() == id)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use converge_core::{ContextState, Engine};
321    use converge_pack::TextPayload;
322    use proptest::prelude::*;
323    use std::collections::BTreeSet;
324
325    fn depot(due_time: i64) -> VrptwDepot {
326        VrptwDepot {
327            x: 0.0,
328            y: 0.0,
329            ready_time: 0,
330            due_time,
331        }
332    }
333
334    fn customer(id: usize, x: f64, open: i64, close: i64) -> VrptwCustomer {
335        VrptwCustomer {
336            id,
337            name: format!("customer-{id}"),
338            x,
339            y: 0.0,
340            window_open: open,
341            window_close: close,
342            service_time: 1,
343        }
344    }
345
346    fn req(customers: Vec<VrptwCustomer>, due_time: i64) -> VrptwRequest {
347        VrptwRequest {
348            id: "route-1".to_string(),
349            depot: depot(due_time),
350            customers,
351            time_limit_seconds: 1.0,
352        }
353    }
354
355    #[tokio::test]
356    async fn suggestor_emits_time_window_route() {
357        let request = req(
358            vec![customer(1, 1.0, 0, 100), customer(2, 2.0, 0, 100)],
359            1000,
360        );
361
362        let mut engine = Engine::new();
363        engine.register_suggestor(NearestNeighborTimeWindowRoutingSuggestor);
364
365        let mut ctx = ContextState::new();
366        ctx.add_proposal(ProposedFact::new(
367            ContextKey::Seeds,
368            "vrptw-request:route-1",
369            request,
370            "test",
371        ))
372        .unwrap();
373
374        let result = engine.run(ctx).await.unwrap();
375        let facts = result.context.get(ContextKey::Strategies);
376        assert_eq!(facts.len(), 1);
377        assert_eq!(facts[0].id().as_str(), "vrptw-plan-greedy:route-1");
378        let plan = facts[0].require_payload::<VrptwPlan>().unwrap();
379        assert_eq!(plan.customers_visited, 2);
380    }
381
382    #[tokio::test]
383    async fn malformed_request_emits_diagnostic() {
384        let mut engine = Engine::new();
385        engine.register_suggestor(NearestNeighborTimeWindowRoutingSuggestor);
386
387        let mut ctx = ContextState::new();
388        ctx.add_proposal(ProposedFact::new(
389            ContextKey::Seeds,
390            "vrptw-request:bad",
391            TextPayload::new("not a vrptw request"),
392            "test",
393        ))
394        .unwrap();
395
396        let result = engine.run(ctx).await.unwrap();
397        assert!(result.context.get(ContextKey::Strategies).is_empty());
398        assert_eq!(result.context.get(ContextKey::Diagnostic).len(), 1);
399    }
400
401    #[test]
402    fn unreachable_customer_is_not_visited() {
403        let plan =
404            solve_nearest_neighbor_time_windows(&req(vec![customer(1, 1000.0, 0, 1)], 10_000));
405        assert_eq!(plan.customers_visited, 0);
406        assert_eq!(plan.status, "infeasible");
407    }
408
409    proptest! {
410        #[test]
411        fn route_has_unique_feasible_stops(customer_count in 0usize..30) {
412            let customers: Vec<_> = (0..customer_count)
413                .map(|i| customer(i, (i + 1) as f64, 0, 10_000))
414                .collect();
415            let request = req(customers.clone(), 100_000);
416            let plan = solve_nearest_neighbor_time_windows(&request);
417
418            let mut seen = BTreeSet::new();
419            for stop in &plan.route {
420                prop_assert!(seen.insert(stop.customer_id));
421                let customer = customers
422                    .iter()
423                    .find(|customer| customer.id == stop.customer_id)
424                    .expect("route stop must refer to a known customer");
425                prop_assert!(stop.arrival >= customer.window_open);
426                prop_assert!(stop.arrival <= customer.window_close);
427                prop_assert_eq!(stop.departure, stop.arrival + customer.service_time);
428            }
429
430            prop_assert!(plan.customers_visited <= plan.customers_total);
431            prop_assert!(plan.return_time <= request.depot.due_time);
432        }
433    }
434}