Skip to main content

converge_optimization/packs/inventory_replenishment/
mod.rs

1//! Inventory Replenishment Pack
2//!
3//! JTBD: "Determine optimal reorder quantities and timing to maintain service levels."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Products with current inventory levels
9//! - Demand forecasts (average and variability)
10//! - Lead times and order costs
11//! - Service level targets and budget constraints
12//!
13//! Find:
14//! - Optimal reorder quantities using EOQ methodology
15//! - Order timing to prevent stockouts
16//! - Safety stock levels for target service level
17//! - Projected inventory levels over planning horizon
18//!
19//! ## Solver
20//!
21//! Uses EOQ-based optimization with safety stock:
22//! 1. Calculate Economic Order Quantity (EOQ) for each product
23//! 2. Determine safety stock based on service level and demand variability
24//! 3. Calculate reorder points incorporating lead time
25//! 4. Prioritize orders by urgency (days until stockout)
26//! 5. Allocate budget starting with most urgent products
27//! 6. Generate inventory projections for planning horizon
28
29mod invariants;
30mod solver;
31mod types;
32
33pub use invariants::*;
34pub use solver::*;
35pub use types::*;
36
37use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
38use converge_pack::gate::GateResult as Result;
39use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
40use converge_pack::{CONFIDENCE_STEP_MEDIUM, CONFIDENCE_STEP_MINOR, CONFIDENCE_STEP_PRIMARY};
41
42/// Inventory Replenishment Pack
43pub struct InventoryReplenishmentPack;
44
45impl Pack for InventoryReplenishmentPack {
46    fn name(&self) -> &'static str {
47        "inventory-replenishment"
48    }
49
50    fn version(&self) -> &'static str {
51        "1.0.0"
52    }
53
54    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
55        let input: InventoryReplenishmentInput =
56            serde_json::from_value(inputs.clone()).map_err(|e| {
57                converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
58            })?;
59        input.validate()
60    }
61
62    fn invariants(&self) -> &[InvariantDef] {
63        INVARIANTS
64    }
65
66    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
67        let input: InventoryReplenishmentInput = spec.inputs_as()?;
68        input.validate()?;
69
70        let solver = EoqSolver;
71        let (output, report) = solver.solve_replenishment(&input, spec)?;
72
73        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
74        let confidence = calculate_confidence(&output, &input);
75
76        let plan = ProposedPlan::from_payload(
77            format!("plan-{}", spec.problem_id),
78            self.name(),
79            output.summary(),
80            &output,
81            confidence,
82            trace,
83        )?;
84
85        Ok(PackSolveResult::new(plan, report))
86    }
87
88    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
89        let output: InventoryReplenishmentOutput = plan.plan_as()?;
90        // We need the input for some invariant checks, but we'll use a simplified version
91        // that can work with just the output where possible
92        let input = InventoryReplenishmentInput::default();
93        Ok(check_all_invariants(&output, &input))
94    }
95
96    fn evaluate_gate(
97        &self,
98        _plan: &ProposedPlan,
99        invariant_results: &[InvariantResult],
100    ) -> PromotionGate {
101        default_gate_evaluation(invariant_results, self.invariants())
102    }
103}
104
105fn calculate_confidence(
106    output: &InventoryReplenishmentOutput,
107    input: &InventoryReplenishmentInput,
108) -> f64 {
109    // Start with base confidence
110    let mut confidence: f64 = 0.5;
111
112    // If no orders needed and all products have sufficient inventory
113    if output.orders.is_empty() {
114        if input.products.iter().all(|p| !p.needs_reorder()) {
115            return 0.9; // High confidence - no action needed
116        }
117        return 0.3; // Low confidence - might have missed something
118    }
119
120    // Higher confidence if we're meeting service level target
121    if output.stats.projected_service_level >= input.constraints.target_service_level {
122        confidence += CONFIDENCE_STEP_PRIMARY;
123    } else if output.stats.projected_service_level >= input.constraints.target_service_level * 0.9 {
124        confidence += CONFIDENCE_STEP_MEDIUM;
125    }
126
127    // Higher confidence if budget utilization is reasonable (not too high, not too low)
128    if output.stats.budget_utilization > 0.1 && output.stats.budget_utilization < 0.9 {
129        confidence += CONFIDENCE_STEP_MINOR;
130    }
131
132    // Higher confidence if we have projections showing no stockouts
133    let has_stockout_risk = output
134        .projections
135        .iter()
136        .any(|p| p.stockout_probability > 0.3);
137    if !has_stockout_risk {
138        confidence += CONFIDENCE_STEP_MEDIUM;
139    }
140
141    confidence.min(1.0)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use converge_pack::gate::ObjectiveSpec;
148
149    fn create_test_product(id: &str, inventory: i64, demand: f64) -> Product {
150        Product {
151            id: id.to_string(),
152            name: format!("Product {}", id),
153            current_inventory: inventory,
154            demand_forecast: DemandForecast {
155                average_daily: demand,
156                std_deviation: demand * 0.2,
157                forecast_days: 30,
158            },
159            lead_time_days: 7,
160            unit_cost: 10.0,
161            ordering_cost: 50.0,
162            holding_cost_per_day: 0.02,
163            stockout_cost: 25.0,
164        }
165    }
166
167    fn create_test_input() -> InventoryReplenishmentInput {
168        InventoryReplenishmentInput {
169            products: vec![
170                create_test_product("p1", 50, 10.0),
171                create_test_product("p2", 200, 5.0),
172            ],
173            constraints: ReplenishmentConstraints {
174                budget: 10000.0,
175                target_service_level: 0.95,
176                planning_horizon_days: 30,
177                max_orders: None,
178                min_order_quantity: None,
179            },
180        }
181    }
182
183    #[test]
184    fn test_pack_name() {
185        let pack = InventoryReplenishmentPack;
186        assert_eq!(pack.name(), "inventory-replenishment");
187        assert_eq!(pack.version(), "1.0.0");
188    }
189
190    #[test]
191    fn test_validate_inputs() {
192        let pack = InventoryReplenishmentPack;
193        let input = create_test_input();
194        let json = serde_json::to_value(&input).unwrap();
195        assert!(pack.validate_inputs(&json).is_ok());
196    }
197
198    #[test]
199    fn test_validate_inputs_invalid_budget() {
200        let pack = InventoryReplenishmentPack;
201        let mut input = create_test_input();
202        input.constraints.budget = -100.0;
203        let json = serde_json::to_value(&input).unwrap();
204        assert!(pack.validate_inputs(&json).is_err());
205    }
206
207    #[test]
208    fn test_validate_inputs_invalid_service_level() {
209        let pack = InventoryReplenishmentPack;
210        let mut input = create_test_input();
211        input.constraints.target_service_level = 1.5;
212        let json = serde_json::to_value(&input).unwrap();
213        assert!(pack.validate_inputs(&json).is_err());
214    }
215
216    #[test]
217    fn test_solve_basic() {
218        let pack = InventoryReplenishmentPack;
219        let input = create_test_input();
220
221        let spec = ProblemSpec::builder("test-001", "test-tenant")
222            .objective(ObjectiveSpec::minimize("cost"))
223            .inputs(&input)
224            .unwrap()
225            .seed(42)
226            .build()
227            .unwrap();
228
229        let result = pack.solve(&spec).unwrap();
230        assert!(result.is_feasible());
231
232        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
233        assert!(!output.orders.is_empty() || !output.not_ordered.is_empty());
234    }
235
236    #[test]
237    fn test_solve_with_sufficient_inventory() {
238        let pack = InventoryReplenishmentPack;
239        let input = InventoryReplenishmentInput {
240            products: vec![create_test_product("p1", 1000, 5.0)], // Lots of inventory
241            constraints: ReplenishmentConstraints::default(),
242        };
243
244        let spec = ProblemSpec::builder("test-002", "test-tenant")
245            .objective(ObjectiveSpec::minimize("cost"))
246            .inputs(&input)
247            .unwrap()
248            .seed(42)
249            .build()
250            .unwrap();
251
252        let result = pack.solve(&spec).unwrap();
253        assert!(result.is_feasible());
254    }
255
256    #[test]
257    fn test_check_invariants() {
258        let pack = InventoryReplenishmentPack;
259        let input = create_test_input();
260
261        let spec = ProblemSpec::builder("test-003", "test-tenant")
262            .objective(ObjectiveSpec::minimize("cost"))
263            .inputs(&input)
264            .unwrap()
265            .seed(42)
266            .build()
267            .unwrap();
268
269        let result = pack.solve(&spec).unwrap();
270        let invariants = pack.check_invariants(&result.plan).unwrap();
271
272        // Should have multiple invariant checks
273        assert!(!invariants.is_empty());
274    }
275
276    #[test]
277    fn test_gate_evaluation() {
278        let pack = InventoryReplenishmentPack;
279        let input = create_test_input();
280
281        let spec = ProblemSpec::builder("test-004", "test-tenant")
282            .objective(ObjectiveSpec::minimize("cost"))
283            .inputs(&input)
284            .unwrap()
285            .seed(42)
286            .build()
287            .unwrap();
288
289        let result = pack.solve(&spec).unwrap();
290        let invariants = pack.check_invariants(&result.plan).unwrap();
291        let gate = pack.evaluate_gate(&result.plan, &invariants);
292
293        // Gate evaluation should work
294        assert!(gate.is_promoted() || !gate.is_promoted());
295    }
296
297    #[test]
298    fn test_determinism() {
299        let pack = InventoryReplenishmentPack;
300        let input = create_test_input();
301
302        let spec1 = ProblemSpec::builder("test-a", "tenant")
303            .objective(ObjectiveSpec::minimize("cost"))
304            .inputs(&input)
305            .unwrap()
306            .seed(99999)
307            .build()
308            .unwrap();
309
310        let spec2 = ProblemSpec::builder("test-b", "tenant")
311            .objective(ObjectiveSpec::minimize("cost"))
312            .inputs(&input)
313            .unwrap()
314            .seed(99999)
315            .build()
316            .unwrap();
317
318        let result1 = pack.solve(&spec1).unwrap();
319        let result2 = pack.solve(&spec2).unwrap();
320
321        let output1: InventoryReplenishmentOutput = result1.plan.plan_as().unwrap();
322        let output2: InventoryReplenishmentOutput = result2.plan.plan_as().unwrap();
323
324        assert_eq!(output1.orders.len(), output2.orders.len());
325        assert_eq!(
326            output1.stats.total_order_cost,
327            output2.stats.total_order_cost
328        );
329
330        for (a, b) in output1.orders.iter().zip(output2.orders.iter()) {
331            assert_eq!(a.product_id, b.product_id);
332            assert_eq!(a.quantity, b.quantity);
333        }
334    }
335
336    #[test]
337    fn test_budget_constraint() {
338        let pack = InventoryReplenishmentPack;
339        let mut input = create_test_input();
340        input.constraints.budget = 500.0; // Very limited budget
341
342        let spec = ProblemSpec::builder("test-005", "test-tenant")
343            .objective(ObjectiveSpec::minimize("cost"))
344            .inputs(&input)
345            .unwrap()
346            .seed(42)
347            .build()
348            .unwrap();
349
350        let result = pack.solve(&spec).unwrap();
351        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
352
353        // Total cost should not exceed budget
354        assert!(output.stats.total_order_cost <= 500.0);
355    }
356
357    #[test]
358    fn test_max_orders_constraint() {
359        let pack = InventoryReplenishmentPack;
360        let mut input = create_test_input();
361        input.constraints.max_orders = Some(1);
362
363        let spec = ProblemSpec::builder("test-006", "test-tenant")
364            .objective(ObjectiveSpec::minimize("cost"))
365            .inputs(&input)
366            .unwrap()
367            .seed(42)
368            .build()
369            .unwrap();
370
371        let result = pack.solve(&spec).unwrap();
372        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
373
374        // Should have at most 1 order
375        assert!(output.orders.len() <= 1);
376    }
377
378    #[test]
379    fn test_output_summary() {
380        let pack = InventoryReplenishmentPack;
381        let input = create_test_input();
382
383        let spec = ProblemSpec::builder("test-007", "test-tenant")
384            .objective(ObjectiveSpec::minimize("cost"))
385            .inputs(&input)
386            .unwrap()
387            .seed(42)
388            .build()
389            .unwrap();
390
391        let result = pack.solve(&spec).unwrap();
392        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
393
394        let summary = output.summary();
395        assert!(!summary.is_empty());
396        assert!(summary.contains("units") || summary.contains("products"));
397    }
398}