Skip to main content

converge_optimization/packs/inventory_replenishment/
solver.rs

1//! Solver for Inventory Replenishment pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8/// EOQ-based solver for inventory replenishment
9///
10/// Algorithm:
11/// 1. Calculate EOQ and safety stock for each product
12/// 2. Determine reorder points based on service level
13/// 3. Prioritize orders by urgency (days until stockout)
14/// 4. Allocate budget starting with most urgent
15/// 5. Generate orders with timing and projected inventory
16pub struct EoqSolver;
17
18impl EoqSolver {
19    /// Solve the inventory replenishment problem
20    pub fn solve_replenishment(
21        &self,
22        input: &InventoryReplenishmentInput,
23        spec: &ProblemSpec,
24    ) -> Result<(InventoryReplenishmentOutput, SolverReport)> {
25        let seed = spec.seed();
26        let constraints = &input.constraints;
27
28        // Calculate replenishment parameters for each product
29        let mut candidates: Vec<ReplenishmentCandidate> = input
30            .products
31            .iter()
32            .map(|p| self.calculate_candidate(p, constraints.target_service_level))
33            .collect();
34
35        // Sort by urgency (days until stockout, ascending)
36        candidates.sort_by(|a, b| {
37            a.days_until_stockout
38                .total_cmp(&b.days_until_stockout)
39                .then_with(|| a.product.id.cmp(&b.product.id))
40        });
41
42        // Apply tie-breaking for equal urgency
43        let tie_break = &spec.determinism.tie_break;
44        let mut sorted_candidates = Vec::new();
45        let mut current_urgency = f64::NEG_INFINITY;
46        let mut urgency_group: Vec<ReplenishmentCandidate> = vec![];
47
48        for candidate in candidates {
49            if (candidate.days_until_stockout - current_urgency).abs() < 0.01 {
50                urgency_group.push(candidate);
51            } else {
52                if !urgency_group.is_empty() {
53                    urgency_group.sort_by(|a, b| a.product.id.cmp(&b.product.id));
54                    if let Some(selected) = tie_break
55                        .select_by(&urgency_group, seed, |a, b| a.product.id.cmp(&b.product.id))
56                    {
57                        sorted_candidates.push(selected.clone());
58                    } else {
59                        sorted_candidates.extend(urgency_group.drain(..));
60                    }
61                }
62                current_urgency = candidate.days_until_stockout;
63                urgency_group = vec![candidate];
64            }
65        }
66        // Don't forget the last group
67        if !urgency_group.is_empty() {
68            urgency_group.sort_by(|a, b| a.product.id.cmp(&b.product.id));
69            if let Some(selected) =
70                tie_break.select_by(&urgency_group, seed, |a, b| a.product.id.cmp(&b.product.id))
71            {
72                sorted_candidates.push(selected.clone());
73            } else {
74                sorted_candidates.extend(urgency_group.drain(..));
75            }
76        }
77
78        // Allocate budget
79        let mut remaining_budget = constraints.budget;
80        let mut orders = Vec::new();
81        let mut not_ordered = Vec::new();
82        let mut total_cost = 0.0;
83        let mut total_units: i64 = 0;
84
85        for candidate in &sorted_candidates {
86            // Check if we've hit max orders limit
87            if let Some(max) = constraints.max_orders {
88                if orders.len() >= max {
89                    not_ordered.push(NotOrderedProduct {
90                        product_id: candidate.product.id.clone(),
91                        product_name: candidate.product.name.clone(),
92                        reason: "Maximum order limit reached".to_string(),
93                        current_inventory: candidate.product.current_inventory,
94                        days_remaining: candidate.days_until_stockout,
95                    });
96                    continue;
97                }
98            }
99
100            // Determine order quantity
101            let mut order_qty = candidate.recommended_quantity;
102
103            // Apply minimum order quantity if specified
104            if let Some(min_qty) = constraints.min_order_quantity {
105                if order_qty < min_qty && order_qty > 0 {
106                    order_qty = min_qty;
107                }
108            }
109
110            // Check if product needs ordering
111            if !candidate.needs_order {
112                not_ordered.push(NotOrderedProduct {
113                    product_id: candidate.product.id.clone(),
114                    product_name: candidate.product.name.clone(),
115                    reason: format!(
116                        "Sufficient inventory ({} days remaining)",
117                        candidate.days_until_stockout as i64
118                    ),
119                    current_inventory: candidate.product.current_inventory,
120                    days_remaining: candidate.days_until_stockout,
121                });
122                continue;
123            }
124
125            // Calculate cost
126            let order_cost = candidate.product.total_order_cost(order_qty);
127
128            // Check budget
129            if order_cost > remaining_budget {
130                // Try to order what we can afford
131                let max_affordable_units = ((remaining_budget - candidate.product.ordering_cost)
132                    / candidate.product.unit_cost)
133                    .floor() as i64;
134
135                if max_affordable_units > 0 {
136                    let affordable_cost = candidate.product.total_order_cost(max_affordable_units);
137                    if affordable_cost <= remaining_budget {
138                        order_qty = max_affordable_units;
139                    } else {
140                        not_ordered.push(NotOrderedProduct {
141                            product_id: candidate.product.id.clone(),
142                            product_name: candidate.product.name.clone(),
143                            reason: format!(
144                                "Insufficient budget (need ${:.2}, have ${:.2})",
145                                order_cost, remaining_budget
146                            ),
147                            current_inventory: candidate.product.current_inventory,
148                            days_remaining: candidate.days_until_stockout,
149                        });
150                        continue;
151                    }
152                } else {
153                    not_ordered.push(NotOrderedProduct {
154                        product_id: candidate.product.id.clone(),
155                        product_name: candidate.product.name.clone(),
156                        reason: format!(
157                            "Insufficient budget (need ${:.2}, have ${:.2})",
158                            order_cost, remaining_budget
159                        ),
160                        current_inventory: candidate.product.current_inventory,
161                        days_remaining: candidate.days_until_stockout,
162                    });
163                    continue;
164                }
165            }
166
167            let final_cost = candidate.product.total_order_cost(order_qty);
168            remaining_budget -= final_cost;
169            total_cost += final_cost;
170            total_units += order_qty;
171
172            // Determine order timing
173            let order_day = self.calculate_order_day(&candidate);
174            let arrival_day = order_day + candidate.product.lead_time_days;
175
176            orders.push(ReplenishmentOrder {
177                product_id: candidate.product.id.clone(),
178                product_name: candidate.product.name.clone(),
179                quantity: order_qty,
180                order_day,
181                arrival_day,
182                order_cost: final_cost,
183                unit_cost: candidate.product.unit_cost,
184                eoq: candidate.eoq,
185                safety_stock: candidate.safety_stock,
186                reorder_point: candidate.reorder_point,
187                order_reason: self.generate_order_reason(&candidate),
188            });
189        }
190
191        // Generate inventory projections
192        let projections = self.generate_projections(&orders, &input.products, constraints);
193
194        // Calculate projected service level
195        let projected_service_level = self.calculate_projected_service_level(
196            &orders,
197            &input.products,
198            constraints.target_service_level,
199        );
200
201        let budget_utilization = if constraints.budget > 0.0 {
202            total_cost / constraints.budget
203        } else {
204            0.0
205        };
206
207        let output = InventoryReplenishmentOutput {
208            orders,
209            not_ordered,
210            projections,
211            stats: ReplenishmentStats {
212                total_order_cost: total_cost,
213                total_units_ordered: total_units,
214                products_ordered: sorted_candidates
215                    .iter()
216                    .filter(|c| c.needs_order)
217                    .count()
218                    .min(input.products.len()),
219                products_skipped: input.products.len()
220                    - sorted_candidates
221                        .iter()
222                        .filter(|c| c.needs_order)
223                        .count()
224                        .min(input.products.len()),
225                budget_utilization,
226                projected_service_level,
227                reason: if total_units > 0 {
228                    format!(
229                        "EOQ-based replenishment plan for {} products",
230                        input.products.len()
231                    )
232                } else {
233                    "No replenishment needed".to_string()
234                },
235            },
236        };
237
238        // Update stats based on actual orders
239        let mut final_output = output;
240        final_output.stats.products_ordered = final_output.orders.len();
241        final_output.stats.products_skipped = final_output.not_ordered.len();
242
243        let replay = ReplayEnvelope::minimal(seed);
244        let report = if !final_output.orders.is_empty() {
245            // Objective: minimize total cost while meeting service level
246            SolverReport::optimal("eoq-v1", -total_cost, replay)
247        } else if input.products.iter().all(|p| !p.needs_reorder()) {
248            // No orders needed - this is feasible, not infeasible
249            SolverReport::feasible("eoq-v1", 0.0, StopReason::Feasible, replay)
250        } else {
251            SolverReport::infeasible("eoq-v1", vec![], StopReason::NoFeasible, replay)
252        };
253
254        Ok((final_output, report))
255    }
256
257    fn calculate_candidate(&self, product: &Product, service_level: f64) -> ReplenishmentCandidate {
258        let eoq = product.calculate_eoq();
259        let safety_stock = product.calculate_safety_stock(service_level);
260        let reorder_point = product.calculate_reorder_point(service_level);
261        let days_until_stockout = product.days_of_inventory();
262
263        // Determine if ordering is needed
264        let needs_order = (product.current_inventory as f64) < reorder_point;
265
266        // Recommended quantity is EOQ, but ensure it brings us above reorder point + safety stock
267        let target_level = reorder_point + eoq;
268        let quantity_needed = (target_level - product.current_inventory as f64).max(0.0);
269        let recommended_quantity = if needs_order {
270            eoq.max(quantity_needed).ceil() as i64
271        } else {
272            0
273        };
274
275        ReplenishmentCandidate {
276            product: product.clone(),
277            eoq,
278            safety_stock,
279            reorder_point,
280            days_until_stockout,
281            needs_order,
282            recommended_quantity,
283        }
284    }
285
286    fn calculate_order_day(&self, candidate: &ReplenishmentCandidate) -> i64 {
287        // Order immediately if below reorder point
288        if candidate.product.current_inventory as f64 <= candidate.reorder_point {
289            return 0;
290        }
291
292        // Calculate when inventory will hit reorder point
293        let inventory_above_rop =
294            candidate.product.current_inventory as f64 - candidate.reorder_point;
295        let days_until_rop = if candidate.product.demand_forecast.average_daily > 0.0 {
296            (inventory_above_rop / candidate.product.demand_forecast.average_daily).floor() as i64
297        } else {
298            0
299        };
300
301        days_until_rop.max(0)
302    }
303
304    fn generate_order_reason(&self, candidate: &ReplenishmentCandidate) -> String {
305        if candidate.days_until_stockout < candidate.product.lead_time_days as f64 {
306            format!(
307                "Urgent: stockout risk in {:.1} days, lead time is {} days",
308                candidate.days_until_stockout, candidate.product.lead_time_days
309            )
310        } else if candidate.product.current_inventory as f64 <= candidate.reorder_point {
311            format!(
312                "Below reorder point ({:.0} units), current inventory: {}",
313                candidate.reorder_point, candidate.product.current_inventory
314            )
315        } else {
316            format!(
317                "Proactive replenishment, {:.1} days of inventory remaining",
318                candidate.days_until_stockout
319            )
320        }
321    }
322
323    fn generate_projections(
324        &self,
325        orders: &[ReplenishmentOrder],
326        products: &[Product],
327        constraints: &ReplenishmentConstraints,
328    ) -> Vec<InventoryProjection> {
329        let mut projections = Vec::new();
330
331        for product in products {
332            let order = orders.iter().find(|o| o.product_id == product.id);
333            let current_inventory = product.current_inventory as f64;
334
335            // Generate projections for key days
336            let key_days = vec![0, 7, 14, 21, constraints.planning_horizon_days];
337
338            for &day in &key_days {
339                if day > constraints.planning_horizon_days {
340                    break;
341                }
342
343                // Consume demand
344                let demand = product.demand_forecast.average_daily * day as f64;
345                let mut projected = (current_inventory - demand).max(0.0);
346
347                // Add order if it arrives by this day
348                let order_arriving = if let Some(o) = order {
349                    if o.arrival_day <= day {
350                        projected += o.quantity as f64;
351                        o.arrival_day == day
352                    } else {
353                        false
354                    }
355                } else {
356                    false
357                };
358
359                // Calculate stockout probability
360                let safety_stock = product.calculate_safety_stock(constraints.target_service_level);
361                let stockout_prob = if projected <= 0.0 {
362                    1.0
363                } else if projected < safety_stock {
364                    (safety_stock - projected) / safety_stock
365                } else {
366                    0.0
367                };
368
369                projections.push(InventoryProjection {
370                    product_id: product.id.clone(),
371                    day,
372                    projected_inventory: projected.max(0.0) as i64,
373                    stockout_probability: stockout_prob.min(1.0),
374                    order_arriving,
375                });
376            }
377        }
378
379        projections
380    }
381
382    fn calculate_projected_service_level(
383        &self,
384        orders: &[ReplenishmentOrder],
385        products: &[Product],
386        target_service_level: f64,
387    ) -> f64 {
388        if products.is_empty() {
389            return 0.0;
390        }
391
392        let mut total_fill_rate = 0.0;
393
394        for product in products {
395            let order = orders.iter().find(|o| o.product_id == product.id);
396            let total_demand = product.total_forecast_demand();
397
398            if total_demand <= 0.0 {
399                total_fill_rate += 1.0;
400                continue;
401            }
402
403            // Estimate fill rate based on inventory + orders vs demand
404            let available =
405                product.current_inventory as f64 + order.map_or(0, |o| o.quantity) as f64;
406            let fill_rate = (available / total_demand).min(1.0);
407            total_fill_rate += fill_rate;
408        }
409
410        let avg_fill_rate = total_fill_rate / products.len() as f64;
411
412        // Weight the result towards target if we're close
413        if avg_fill_rate >= target_service_level {
414            avg_fill_rate
415        } else {
416            avg_fill_rate
417        }
418    }
419}
420
421/// Internal candidate structure for replenishment calculation
422#[derive(Debug, Clone)]
423struct ReplenishmentCandidate {
424    product: Product,
425    eoq: f64,
426    safety_stock: f64,
427    reorder_point: f64,
428    days_until_stockout: f64,
429    needs_order: bool,
430    recommended_quantity: i64,
431}
432
433impl PackSolver for EoqSolver {
434    fn id(&self) -> &'static str {
435        "eoq-v1"
436    }
437
438    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
439        let input: InventoryReplenishmentInput = spec.inputs_as()?;
440        let (output, report) = self.solve_replenishment(&input, spec)?;
441        let json = serde_json::to_value(&output)
442            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
443        Ok((json, report))
444    }
445
446    fn is_exact(&self) -> bool {
447        false // EOQ is an approximation
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use converge_pack::gate::ObjectiveSpec;
455
456    fn create_test_product(id: &str, inventory: i64, demand: f64) -> Product {
457        Product {
458            id: id.to_string(),
459            name: format!("Product {}", id),
460            current_inventory: inventory,
461            demand_forecast: DemandForecast {
462                average_daily: demand,
463                std_deviation: demand * 0.2,
464                forecast_days: 30,
465            },
466            lead_time_days: 7,
467            unit_cost: 10.0,
468            ordering_cost: 50.0,
469            holding_cost_per_day: 0.02,
470            stockout_cost: 25.0,
471        }
472    }
473
474    fn create_test_input() -> InventoryReplenishmentInput {
475        InventoryReplenishmentInput {
476            products: vec![
477                create_test_product("p1", 50, 10.0), // Low inventory, high demand
478                create_test_product("p2", 200, 5.0), // Good inventory
479                create_test_product("p3", 20, 15.0), // Critical - very low
480            ],
481            constraints: ReplenishmentConstraints {
482                budget: 10000.0,
483                target_service_level: 0.95,
484                planning_horizon_days: 30,
485                max_orders: None,
486                min_order_quantity: None,
487            },
488        }
489    }
490
491    fn create_spec(input: &InventoryReplenishmentInput, seed: u64) -> ProblemSpec {
492        ProblemSpec::builder("test", "tenant")
493            .objective(ObjectiveSpec::minimize("cost"))
494            .inputs(input)
495            .unwrap()
496            .seed(seed)
497            .build()
498            .unwrap()
499    }
500
501    #[test]
502    fn test_basic_replenishment() {
503        let solver = EoqSolver;
504        let input = create_test_input();
505        let spec = create_spec(&input, 42);
506
507        let (output, report) = solver.solve_replenishment(&input, &spec).unwrap();
508
509        assert!(!output.orders.is_empty());
510        assert!(report.feasible);
511    }
512
513    #[test]
514    fn test_prioritizes_urgent() {
515        let solver = EoqSolver;
516        let input = create_test_input();
517        let spec = create_spec(&input, 42);
518
519        let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
520
521        // p3 has only 20 units with 15/day demand = 1.3 days
522        // Should be ordered first
523        if !output.orders.is_empty() {
524            let first_order = &output.orders[0];
525            assert_eq!(first_order.product_id, "p3");
526        }
527    }
528
529    #[test]
530    fn test_respects_budget() {
531        let solver = EoqSolver;
532        let mut input = create_test_input();
533        input.constraints.budget = 500.0; // Very limited budget
534
535        let spec = create_spec(&input, 42);
536        let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
537
538        assert!(output.stats.total_order_cost <= 500.0);
539    }
540
541    #[test]
542    fn test_no_order_when_sufficient() {
543        let solver = EoqSolver;
544        let input = InventoryReplenishmentInput {
545            products: vec![create_test_product("p1", 1000, 5.0)], // Very high inventory
546            constraints: ReplenishmentConstraints::default(),
547        };
548
549        let spec = create_spec(&input, 42);
550        let (output, report) = solver.solve_replenishment(&input, &spec).unwrap();
551
552        // With 1000 units and 5/day demand, we have 200 days of inventory
553        // Should not need to order
554        assert!(output.orders.is_empty() || output.not_ordered.len() > 0);
555        assert!(report.feasible);
556    }
557
558    #[test]
559    fn test_max_orders_limit() {
560        let solver = EoqSolver;
561        let mut input = create_test_input();
562        input.constraints.max_orders = Some(1);
563
564        let spec = create_spec(&input, 42);
565        let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
566
567        assert!(output.orders.len() <= 1);
568    }
569
570    #[test]
571    fn test_determinism() {
572        let solver = EoqSolver;
573        let input = create_test_input();
574
575        let spec1 = create_spec(&input, 12345);
576        let spec2 = create_spec(&input, 12345);
577
578        let (output1, _) = solver.solve_replenishment(&input, &spec1).unwrap();
579        let (output2, _) = solver.solve_replenishment(&input, &spec2).unwrap();
580
581        assert_eq!(output1.orders.len(), output2.orders.len());
582        for (a, b) in output1.orders.iter().zip(output2.orders.iter()) {
583            assert_eq!(a.product_id, b.product_id);
584            assert_eq!(a.quantity, b.quantity);
585        }
586    }
587
588    #[test]
589    fn test_projections_generated() {
590        let solver = EoqSolver;
591        let input = create_test_input();
592        let spec = create_spec(&input, 42);
593
594        let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
595
596        assert!(!output.projections.is_empty());
597        // Should have projections for each product at key days
598        let p1_projections: Vec<_> = output
599            .projections
600            .iter()
601            .filter(|p| p.product_id == "p1")
602            .collect();
603        assert!(!p1_projections.is_empty());
604    }
605
606    #[test]
607    fn test_service_level_calculation() {
608        let solver = EoqSolver;
609        let input = create_test_input();
610        let spec = create_spec(&input, 42);
611
612        let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
613
614        assert!(output.stats.projected_service_level >= 0.0);
615        assert!(output.stats.projected_service_level <= 1.0);
616    }
617}