Skip to main content

converge_optimization/packs/inventory_rebalancing/
solver.rs

1//! Solver for Inventory Rebalancing pack
2
3use 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
11/// Greedy solver for inventory rebalancing
12///
13/// Algorithm:
14/// 1. Calculate deficit/surplus for each (location, product)
15/// 2. Sort deficits by urgency (most negative first)
16/// 3. For each deficit, find cheapest transfer from surplus locations
17/// 4. Stop when budget or transfer limits reached
18pub struct GreedyRebalancingSolver;
19
20impl GreedyRebalancingSolver {
21    /// Solve the inventory rebalancing problem
22    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        // Build working state
31        let mut state = WorkingState::from_input(input);
32
33        // Find all deficits sorted by urgency (most negative first)
34        let mut deficits: Vec<_> = state
35            .levels
36            .iter()
37            .filter(|(_, level)| level.has_deficit())
38            .map(|(key, level)| (key.clone(), level.deficit())) // deficit is negative
39            .collect();
40
41        deficits.sort_by_key(|d| d.1); // Sort ascending (most negative first)
42
43        // Greedy allocation
44        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            // Find cheapest source with surplus
57            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                // Check budget constraint
69                if total_cost + transfer_cost > input.constraints.max_total_cost {
70                    // Try smaller quantity
71                    let affordable =
72                        ((input.constraints.max_total_cost - total_cost) / cost_per_unit) as i64;
73                    if affordable <= 0 {
74                        continue;
75                    }
76                    // Use affordable amount instead
77                    let quantity = affordable.min(quantity);
78                    let transfer_cost = quantity as f64 * cost_per_unit;
79
80                    // Apply transfer
81                    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                    // Apply full transfer
95                    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        // Calculate service level improvement
114        let service_improvement = self.calculate_service_improvement(&state, input);
115
116        // Build location impacts
117        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        // Build report
130        let replay = ReplayEnvelope::minimal(seed);
131        let stop_reason = if output.transfers.is_empty() {
132            StopReason::Optimal // No transfers needed
133        } else if total_cost >= input.constraints.max_total_cost * 0.99 {
134            StopReason::Feasible // Hit budget limit
135        } 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    /// Find the cheapest source location with available surplus
165    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            // Must be same product
189            if source_product != product_id {
190                continue;
191            }
192
193            // Can't transfer to self
194            if source_loc == dest_loc {
195                continue;
196            }
197
198            // Must have surplus
199            let surplus = level.available_surplus();
200            if surplus <= 0 {
201                continue;
202            }
203
204            // Get transfer cost
205            let cost_info = match input.get_transfer_cost(source_loc, dest_loc) {
206                Some(c) => c,
207                None => continue, // No transfer route
208            };
209
210            // Calculate quantity to transfer
211            let qty = needed.min(surplus).min(space_available).min(max_qty);
212            if qty <= 0 {
213                continue;
214            }
215
216            // Compare with best
217            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    /// Calculate service level improvement
243    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; // Already perfect
266        }
267
268        let deficit_reduction = initial_deficit_sum - final_deficit_sum;
269        deficit_reduction as f64 / initial_deficit_sum as f64
270    }
271
272    /// Build location impact records
273    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 // Greedy is heuristic for this problem
315    }
316}
317
318/// Working state for solver
319struct 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        // Decrease source
340        if let Some(level) = self
341            .levels
342            .get_mut(&(from.to_string(), product.to_string()))
343        {
344            level.quantity -= qty;
345        }
346        // Increase destination
347        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        // First transfer should go to store-a (cheaper route at $0.5/unit vs $0.8/unit)
468        // unless store-b has larger deficit
469        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        // Both stores should receive some inventory
483        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; // Very low budget
491
492        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}