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