1use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{
7 Diagnostic, DiagnosticKind, ProblemSpec, ReplayEnvelope, SolverReport, StopReason,
8};
9use std::collections::HashMap;
10
11pub struct GreedyRebalancingSolver;
19
20impl GreedyRebalancingSolver {
21 pub fn solve_rebalancing(
23 &self,
24 input: &InventoryRebalancingInput,
25 spec: &ProblemSpec,
26 ) -> Result<(InventoryRebalancingOutput, SolverReport)> {
27 let start = std::time::Instant::now();
28 let seed = spec.seed();
29
30 let mut state = WorkingState::from_input(input);
32
33 let mut deficits: Vec<_> = state
35 .levels
36 .iter()
37 .filter(|(_, level)| level.has_deficit())
38 .map(|(key, level)| (key.clone(), level.deficit())) .collect();
40
41 deficits.sort_by_key(|d| d.1); let mut transfers = Vec::new();
45 let mut total_cost = 0.0;
46 let mut total_units = 0i64;
47 let mut iterations = 0;
48
49 for (deficit_key, _) in deficits {
50 if transfers.len() >= input.constraints.max_total_transfers {
51 break;
52 }
53
54 let (dest_loc, product_id) = deficit_key;
55
56 let best_source = self.find_cheapest_source(
58 &state,
59 input,
60 &dest_loc,
61 &product_id,
62 input.constraints.max_transfer_quantity,
63 );
64
65 if let Some((source_loc, quantity, cost_per_unit, lead_time)) = best_source {
66 let transfer_cost = quantity as f64 * cost_per_unit;
67
68 if total_cost + transfer_cost > input.constraints.max_total_cost {
70 let affordable =
72 ((input.constraints.max_total_cost - total_cost) / cost_per_unit) as i64;
73 if affordable <= 0 {
74 continue;
75 }
76 let quantity = affordable.min(quantity);
78 let transfer_cost = quantity as f64 * cost_per_unit;
79
80 state.apply_transfer(&source_loc, &dest_loc, &product_id, quantity);
82 total_cost += transfer_cost;
83 total_units += quantity;
84
85 transfers.push(Transfer::new(
86 &source_loc,
87 &dest_loc,
88 &product_id,
89 quantity,
90 transfer_cost,
91 lead_time,
92 ));
93 } else {
94 state.apply_transfer(&source_loc, &dest_loc, &product_id, quantity);
96 total_cost += transfer_cost;
97 total_units += quantity;
98
99 transfers.push(Transfer::new(
100 &source_loc,
101 &dest_loc,
102 &product_id,
103 quantity,
104 transfer_cost,
105 lead_time,
106 ));
107 }
108 }
109
110 iterations += 1;
111 }
112
113 let service_improvement = self.calculate_service_improvement(&state, input);
115
116 let location_impacts = self.build_location_impacts(&state, input);
118
119 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
120
121 let output = InventoryRebalancingOutput {
122 transfers,
123 total_cost,
124 total_units_moved: total_units,
125 service_level_improvement: service_improvement,
126 location_impacts,
127 };
128
129 let replay = ReplayEnvelope::minimal(seed);
131 let stop_reason = if output.transfers.is_empty() {
132 StopReason::Optimal } else if total_cost >= input.constraints.max_total_cost * 0.99 {
134 StopReason::Feasible } else {
136 StopReason::Optimal
137 };
138
139 let report = SolverReport::feasible("greedy-v1", -total_cost, stop_reason, replay)
140 .with_diagnostic(Diagnostic::performance(
141 "rebalancing",
142 elapsed_ms,
143 iterations,
144 ))
145 .with_diagnostic(Diagnostic::with_data(
146 DiagnosticKind::ScoringBreakdown,
147 format!(
148 "{} transfers, {} units, ${:.2} cost",
149 output.transfers.len(),
150 total_units,
151 total_cost
152 ),
153 serde_json::json!({
154 "total_transfers": output.transfers.len(),
155 "total_units": total_units,
156 "total_cost": total_cost,
157 "service_improvement": service_improvement,
158 }),
159 ));
160
161 Ok((output, report))
162 }
163
164 fn find_cheapest_source(
166 &self,
167 state: &WorkingState,
168 input: &InventoryRebalancingInput,
169 dest_loc: &str,
170 product_id: &str,
171 max_qty: i64,
172 ) -> Option<(String, i64, f64, i64)> {
173 let dest_level = state
174 .levels
175 .get(&(dest_loc.to_string(), product_id.to_string()))?;
176 let needed = (dest_level.target_quantity - dest_level.quantity).max(0);
177 let space_available = dest_level.available_space();
178
179 if needed == 0 || space_available == 0 {
180 return None;
181 }
182
183 let mut best: Option<(String, i64, f64, i64)> = None;
184
185 for (key, level) in &state.levels {
186 let (source_loc, source_product) = key;
187
188 if source_product != product_id {
190 continue;
191 }
192
193 if source_loc == dest_loc {
195 continue;
196 }
197
198 let surplus = level.available_surplus();
200 if surplus <= 0 {
201 continue;
202 }
203
204 let cost_info = match input.get_transfer_cost(source_loc, dest_loc) {
206 Some(c) => c,
207 None => continue, };
209
210 let qty = needed.min(surplus).min(space_available).min(max_qty);
212 if qty <= 0 {
213 continue;
214 }
215
216 match &best {
218 None => {
219 best = Some((
220 source_loc.clone(),
221 qty,
222 cost_info.cost_per_unit,
223 cost_info.lead_time_hours,
224 ));
225 }
226 Some((_, _, best_cost, _)) => {
227 if cost_info.cost_per_unit < *best_cost {
228 best = Some((
229 source_loc.clone(),
230 qty,
231 cost_info.cost_per_unit,
232 cost_info.lead_time_hours,
233 ));
234 }
235 }
236 }
237 }
238
239 best
240 }
241
242 fn calculate_service_improvement(
244 &self,
245 state: &WorkingState,
246 input: &InventoryRebalancingInput,
247 ) -> f64 {
248 let mut initial_deficit_sum = 0i64;
249 let mut final_deficit_sum = 0i64;
250
251 for inv in &input.inventory {
252 let initial_deficit = (inv.target_quantity - inv.quantity).max(0);
253 initial_deficit_sum += initial_deficit;
254
255 if let Some(level) = state
256 .levels
257 .get(&(inv.location_id.clone(), inv.product_id.clone()))
258 {
259 let final_deficit = (level.target_quantity - level.quantity).max(0);
260 final_deficit_sum += final_deficit;
261 }
262 }
263
264 if initial_deficit_sum == 0 {
265 return 0.0; }
267
268 let deficit_reduction = initial_deficit_sum - final_deficit_sum;
269 deficit_reduction as f64 / initial_deficit_sum as f64
270 }
271
272 fn build_location_impacts(
274 &self,
275 state: &WorkingState,
276 input: &InventoryRebalancingInput,
277 ) -> Vec<LocationImpact> {
278 let mut impacts = Vec::new();
279
280 for inv in &input.inventory {
281 let key = (inv.location_id.clone(), inv.product_id.clone());
282 if let Some(level) = state.levels.get(&key) {
283 let change = level.quantity - inv.quantity;
284 if change != 0 {
285 impacts.push(LocationImpact {
286 location_id: inv.location_id.clone(),
287 product_id: inv.product_id.clone(),
288 inventory_change: change,
289 final_quantity: level.quantity,
290 meets_target: level.quantity >= level.target_quantity,
291 });
292 }
293 }
294 }
295
296 impacts
297 }
298}
299
300impl PackSolver for GreedyRebalancingSolver {
301 fn id(&self) -> &'static str {
302 "greedy-v1"
303 }
304
305 fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
306 let input: InventoryRebalancingInput = spec.inputs_as()?;
307 let (output, report) = self.solve_rebalancing(&input, spec)?;
308 let json = serde_json::to_value(&output)
309 .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
310 Ok((json, report))
311 }
312
313 fn is_exact(&self) -> bool {
314 false }
316}
317
318struct WorkingState {
320 levels: HashMap<(String, String), InventoryLevel>,
321}
322
323impl WorkingState {
324 fn from_input(input: &InventoryRebalancingInput) -> Self {
325 let levels = input
326 .inventory
327 .iter()
328 .map(|inv| {
329 (
330 (inv.location_id.clone(), inv.product_id.clone()),
331 inv.clone(),
332 )
333 })
334 .collect();
335 Self { levels }
336 }
337
338 fn apply_transfer(&mut self, from: &str, to: &str, product: &str, qty: i64) {
339 if let Some(level) = self
341 .levels
342 .get_mut(&(from.to_string(), product.to_string()))
343 {
344 level.quantity -= qty;
345 }
346 if let Some(level) = self.levels.get_mut(&(to.to_string(), product.to_string())) {
348 level.quantity += qty;
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use converge_pack::gate::{ObjectiveSpec, SolveBudgets};
357
358 fn create_test_input() -> InventoryRebalancingInput {
359 InventoryRebalancingInput {
360 locations: vec![
361 Location {
362 id: "warehouse".to_string(),
363 name: "Main Warehouse".to_string(),
364 capacity: 1000,
365 location_type: LocationType::Warehouse,
366 },
367 Location {
368 id: "store-a".to_string(),
369 name: "Store A".to_string(),
370 capacity: 100,
371 location_type: LocationType::Store,
372 },
373 Location {
374 id: "store-b".to_string(),
375 name: "Store B".to_string(),
376 capacity: 100,
377 location_type: LocationType::Store,
378 },
379 ],
380 products: vec![Product {
381 id: "widget".to_string(),
382 name: "Widget".to_string(),
383 unit_weight: 1.0,
384 unit_value: 10.0,
385 }],
386 inventory: vec![
387 InventoryLevel {
388 location_id: "warehouse".to_string(),
389 product_id: "widget".to_string(),
390 quantity: 500,
391 target_quantity: 300,
392 min_quantity: 100,
393 max_quantity: 800,
394 },
395 InventoryLevel {
396 location_id: "store-a".to_string(),
397 product_id: "widget".to_string(),
398 quantity: 10,
399 target_quantity: 40,
400 min_quantity: 20,
401 max_quantity: 80,
402 },
403 InventoryLevel {
404 location_id: "store-b".to_string(),
405 product_id: "widget".to_string(),
406 quantity: 5,
407 target_quantity: 50,
408 min_quantity: 25,
409 max_quantity: 80,
410 },
411 ],
412 transfer_costs: vec![
413 TransferCost {
414 from_location: "warehouse".to_string(),
415 to_location: "store-a".to_string(),
416 cost_per_unit: 0.5,
417 lead_time_hours: 24,
418 },
419 TransferCost {
420 from_location: "warehouse".to_string(),
421 to_location: "store-b".to_string(),
422 cost_per_unit: 0.8,
423 lead_time_hours: 48,
424 },
425 ],
426 constraints: RebalancingConstraints {
427 max_total_transfers: 10,
428 max_transfer_quantity: 50,
429 max_total_cost: 100.0,
430 },
431 }
432 }
433
434 fn create_spec(input: &InventoryRebalancingInput, seed: u64) -> ProblemSpec {
435 ProblemSpec::builder("test", "tenant")
436 .objective(ObjectiveSpec::minimize("cost"))
437 .inputs(input)
438 .unwrap()
439 .budgets(SolveBudgets::with_time_limit(10))
440 .seed(seed)
441 .build()
442 .unwrap()
443 }
444
445 #[test]
446 fn test_greedy_solver() {
447 let solver = GreedyRebalancingSolver;
448 let input = create_test_input();
449 let spec = create_spec(&input, 42);
450
451 let (output, report) = solver.solve_rebalancing(&input, &spec).unwrap();
452
453 assert!(!output.transfers.is_empty());
454 assert!(report.feasible);
455 assert!(output.total_cost > 0.0);
456 assert!(output.service_level_improvement > 0.0);
457 }
458
459 #[test]
460 fn test_prefers_cheaper_route() {
461 let solver = GreedyRebalancingSolver;
462 let input = create_test_input();
463 let spec = create_spec(&input, 42);
464
465 let (output, _) = solver.solve_rebalancing(&input, &spec).unwrap();
466
467 let to_store_a: i64 = output
470 .transfers
471 .iter()
472 .filter(|t| t.to_location == "store-a")
473 .map(|t| t.quantity)
474 .sum();
475 let to_store_b: i64 = output
476 .transfers
477 .iter()
478 .filter(|t| t.to_location == "store-b")
479 .map(|t| t.quantity)
480 .sum();
481
482 assert!(to_store_a > 0 || to_store_b > 0);
484 }
485
486 #[test]
487 fn test_respects_budget() {
488 let solver = GreedyRebalancingSolver;
489 let mut input = create_test_input();
490 input.constraints.max_total_cost = 10.0; let spec = create_spec(&input, 42);
493 let (output, _) = solver.solve_rebalancing(&input, &spec).unwrap();
494
495 assert!(output.total_cost <= input.constraints.max_total_cost + 0.01);
496 }
497
498 #[test]
499 fn test_respects_transfer_limit() {
500 let solver = GreedyRebalancingSolver;
501 let mut input = create_test_input();
502 input.constraints.max_total_transfers = 1;
503
504 let spec = create_spec(&input, 42);
505 let (output, _) = solver.solve_rebalancing(&input, &spec).unwrap();
506
507 assert!(output.transfers.len() <= 1);
508 }
509
510 #[test]
511 fn test_no_transfers_when_balanced() {
512 let solver = GreedyRebalancingSolver;
513 let input = InventoryRebalancingInput {
514 locations: vec![Location {
515 id: "warehouse".to_string(),
516 name: "Warehouse".to_string(),
517 capacity: 1000,
518 location_type: LocationType::Warehouse,
519 }],
520 products: vec![Product {
521 id: "widget".to_string(),
522 name: "Widget".to_string(),
523 unit_weight: 1.0,
524 unit_value: 10.0,
525 }],
526 inventory: vec![InventoryLevel {
527 location_id: "warehouse".to_string(),
528 product_id: "widget".to_string(),
529 quantity: 100,
530 target_quantity: 100,
531 min_quantity: 50,
532 max_quantity: 200,
533 }],
534 transfer_costs: vec![],
535 constraints: RebalancingConstraints {
536 max_total_transfers: 10,
537 max_transfer_quantity: 50,
538 max_total_cost: 100.0,
539 },
540 };
541
542 let spec = create_spec(&input, 42);
543 let (output, _) = solver.solve_rebalancing(&input, &spec).unwrap();
544
545 assert!(output.transfers.is_empty());
546 assert_eq!(output.total_cost, 0.0);
547 }
548}