1use async_trait::async_trait;
2use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
3use std::time::Instant;
4use tracing::warn;
5
6use super::problem::{RouteStop, VrptwPlan, VrptwRequest};
7
8pub(super) const REQUEST_PREFIX: &str = "vrptw-request:";
9const PLAN_PREFIX: &str = "vrptw-plan-greedy:";
10
11pub struct NearestNeighborSuggestor;
20
21#[async_trait]
22impl Suggestor for NearestNeighborSuggestor {
23 fn name(&self) -> &str {
24 "NearestNeighborSuggestor"
25 }
26
27 fn dependencies(&self) -> &[ContextKey] {
28 &[ContextKey::Seeds]
29 }
30
31 fn complexity_hint(&self) -> Option<&'static str> {
32 Some("O(n²) — nearest-neighbour with TW feasibility; deterministic, sub-ms for n ≤ 10 000")
33 }
34
35 fn accepts(&self, ctx: &dyn Context) -> bool {
36 ctx.get(ContextKey::Seeds)
37 .iter()
38 .any(|f| f.id.starts_with(REQUEST_PREFIX) && !own_plan_exists(ctx, request_id(&f.id)))
39 }
40
41 async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
42 let mut proposals = Vec::new();
43
44 for fact in ctx
45 .get(ContextKey::Seeds)
46 .iter()
47 .filter(|f| f.id.starts_with(REQUEST_PREFIX))
48 {
49 let rid = request_id(&fact.id);
50 if own_plan_exists(ctx, rid) {
51 continue;
52 }
53
54 match serde_json::from_str::<VrptwRequest>(&fact.content) {
55 Ok(req) => {
56 let plan = solve_nn(&req);
57 let confidence = (plan.visit_ratio() * 0.60).min(0.60);
58 proposals.push(
59 ProposedFact::new(
60 ContextKey::Strategies,
61 format!("{PLAN_PREFIX}{rid}"),
62 serde_json::to_string(&plan).unwrap_or_default(),
63 self.name(),
64 )
65 .with_confidence(confidence),
66 );
67 }
68 Err(e) => {
69 warn!(id = %fact.id, error = %e, "malformed vrptw-request");
70 }
71 }
72 }
73
74 if proposals.is_empty() {
75 AgentEffect::empty()
76 } else {
77 AgentEffect::with_proposals(proposals)
78 }
79 }
80}
81
82fn request_id(fact_id: &str) -> &str {
83 fact_id.trim_start_matches(REQUEST_PREFIX)
84}
85
86fn own_plan_exists(ctx: &dyn Context, request_id: &str) -> bool {
87 let plan_id = format!("{PLAN_PREFIX}{request_id}");
88 ctx.get(ContextKey::Strategies)
89 .iter()
90 .any(|f| f.id == plan_id)
91}
92
93pub fn solve_nn(req: &VrptwRequest) -> VrptwPlan {
95 let t0 = Instant::now();
96 let n = req.customers.len();
97 let mut visited = vec![false; n];
98 let mut route: Vec<RouteStop> = Vec::new();
99
100 let mut cur_x = req.depot.x;
101 let mut cur_y = req.depot.y;
102 let mut cur_t = req.depot.ready_time;
103 let mut total_dist = 0.0_f64;
104
105 loop {
106 let best = (0..n)
108 .filter(|&i| !visited[i])
109 .filter_map(|i| {
110 let c = &req.customers[i];
111 let dx = cur_x - c.x;
112 let dy = cur_y - c.y;
113 let dist = (dx * dx + dy * dy).sqrt();
114 #[allow(clippy::cast_possible_truncation)]
115 let travel = dist.ceil() as i64;
116 let arrival = cur_t + travel;
117 let depart = arrival.max(c.window_open) + c.service_time;
119 let depot_dx = c.x - req.depot.x;
120 let depot_dy = c.y - req.depot.y;
121 #[allow(clippy::cast_possible_truncation)]
122 let return_dist = ((depot_dx * depot_dx + depot_dy * depot_dy).sqrt().ceil()) as i64;
123 if arrival <= c.window_close && depart + return_dist <= req.depot.due_time {
124 Some((i, dist, travel, arrival, depart))
125 } else {
126 None
127 }
128 })
129 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
130
131 match best {
132 Some((i, dist, travel, arrival, depart)) => {
133 let c = &req.customers[i];
134 visited[i] = true;
135 total_dist += dist;
136 cur_x = c.x;
137 cur_y = c.y;
138 cur_t = depart;
139 let _ = travel;
140 let _ = arrival;
141 route.push(RouteStop {
142 customer_id: c.id,
143 customer_name: c.name.clone(),
144 arrival: depart - c.service_time,
145 departure: depart,
146 });
147 }
148 None => break,
149 }
150 }
151
152 let depot_dx = cur_x - req.depot.x;
154 let depot_dy = cur_y - req.depot.y;
155 let return_dist = (depot_dx * depot_dx + depot_dy * depot_dy).sqrt();
156 total_dist += return_dist;
157 #[allow(clippy::cast_possible_truncation)]
158 let return_time = cur_t + return_dist.ceil() as i64;
159
160 VrptwPlan {
161 request_id: req.id.clone(),
162 customers_visited: route.len(),
163 customers_total: n,
164 route,
165 total_distance: total_dist,
166 return_time,
167 solver: "nearest-neighbour".to_string(),
168 status: "feasible".to_string(),
169 wall_time_seconds: t0.elapsed().as_secs_f64(),
170 }
171}