1use 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#[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#[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#[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#[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
113const REQUEST_PREFIX: &str = "vrptw-request:";
116const PLAN_PREFIX: &str = "vrptw-plan-greedy:";
117const ERROR_PREFIX: &str = "vrptw-request-error:";
118
119pub 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
209pub 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}