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