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 {
93            products: Vec::new(),
94            constraints: ReplenishmentConstraints::default(),
95        };
96        Ok(check_all_invariants(&output, &input))
97    }
98
99    fn evaluate_gate(
100        &self,
101        _plan: &ProposedPlan,
102        invariant_results: &[InvariantResult],
103    ) -> PromotionGate {
104        default_gate_evaluation(invariant_results, self.invariants())
105    }
106}
107
108fn calculate_confidence(
109    output: &InventoryReplenishmentOutput,
110    input: &InventoryReplenishmentInput,
111) -> f64 {
112    // Start with base confidence
113    let mut confidence: f64 = 0.5;
114
115    // If no orders needed and all products have sufficient inventory
116    if output.orders.is_empty() {
117        if input.products.iter().all(|p| !p.needs_reorder()) {
118            return 0.9; // High confidence - no action needed
119        }
120        return 0.3; // Low confidence - might have missed something
121    }
122
123    // Higher confidence if we're meeting service level target
124    if output.stats.projected_service_level >= input.constraints.target_service_level {
125        confidence += CONFIDENCE_STEP_PRIMARY;
126    } else if output.stats.projected_service_level >= input.constraints.target_service_level * 0.9 {
127        confidence += CONFIDENCE_STEP_MEDIUM;
128    }
129
130    // Higher confidence if budget utilization is reasonable (not too high, not too low)
131    if output.stats.budget_utilization > 0.1 && output.stats.budget_utilization < 0.9 {
132        confidence += CONFIDENCE_STEP_MINOR;
133    }
134
135    // Higher confidence if we have projections showing no stockouts
136    let has_stockout_risk = output
137        .projections
138        .iter()
139        .any(|p| p.stockout_probability > 0.3);
140    if !has_stockout_risk {
141        confidence += CONFIDENCE_STEP_MEDIUM;
142    }
143
144    confidence.min(1.0)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use converge_pack::gate::ObjectiveSpec;
151
152    fn create_test_product(id: &str, inventory: i64, demand: f64) -> Product {
153        Product {
154            id: id.to_string(),
155            name: format!("Product {}", id),
156            current_inventory: inventory,
157            demand_forecast: DemandForecast {
158                average_daily: demand,
159                std_deviation: demand * 0.2,
160                forecast_days: 30,
161            },
162            lead_time_days: 7,
163            unit_cost: 10.0,
164            ordering_cost: 50.0,
165            holding_cost_per_day: 0.02,
166            stockout_cost: 25.0,
167        }
168    }
169
170    fn create_test_input() -> InventoryReplenishmentInput {
171        InventoryReplenishmentInput {
172            products: vec![
173                create_test_product("p1", 50, 10.0),
174                create_test_product("p2", 200, 5.0),
175            ],
176            constraints: ReplenishmentConstraints {
177                budget: 10000.0,
178                target_service_level: 0.95,
179                planning_horizon_days: 30,
180                max_orders: None,
181                min_order_quantity: None,
182            },
183        }
184    }
185
186    #[test]
187    fn test_pack_name() {
188        let pack = InventoryReplenishmentPack;
189        assert_eq!(pack.name(), "inventory-replenishment");
190        assert_eq!(pack.version(), "1.0.0");
191    }
192
193    #[test]
194    fn test_validate_inputs() {
195        let pack = InventoryReplenishmentPack;
196        let input = create_test_input();
197        let json = serde_json::to_value(&input).unwrap();
198        assert!(pack.validate_inputs(&json).is_ok());
199    }
200
201    #[test]
202    fn test_validate_inputs_invalid_budget() {
203        let pack = InventoryReplenishmentPack;
204        let mut input = create_test_input();
205        input.constraints.budget = -100.0;
206        let json = serde_json::to_value(&input).unwrap();
207        assert!(pack.validate_inputs(&json).is_err());
208    }
209
210    #[test]
211    fn test_validate_inputs_invalid_service_level() {
212        let pack = InventoryReplenishmentPack;
213        let mut input = create_test_input();
214        input.constraints.target_service_level = 1.5;
215        let json = serde_json::to_value(&input).unwrap();
216        assert!(pack.validate_inputs(&json).is_err());
217    }
218
219    #[test]
220    fn test_solve_basic() {
221        let pack = InventoryReplenishmentPack;
222        let input = create_test_input();
223
224        let spec = ProblemSpec::builder("test-001", "test-tenant")
225            .objective(ObjectiveSpec::minimize("cost"))
226            .inputs(&input)
227            .unwrap()
228            .seed(42)
229            .build()
230            .unwrap();
231
232        let result = pack.solve(&spec).unwrap();
233        assert!(result.is_feasible());
234
235        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
236        assert!(!output.orders.is_empty() || !output.not_ordered.is_empty());
237    }
238
239    #[test]
240    fn test_solve_with_sufficient_inventory() {
241        let pack = InventoryReplenishmentPack;
242        let input = InventoryReplenishmentInput {
243            products: vec![create_test_product("p1", 1000, 5.0)], // Lots of inventory
244            constraints: ReplenishmentConstraints::default(),
245        };
246
247        let spec = ProblemSpec::builder("test-002", "test-tenant")
248            .objective(ObjectiveSpec::minimize("cost"))
249            .inputs(&input)
250            .unwrap()
251            .seed(42)
252            .build()
253            .unwrap();
254
255        let result = pack.solve(&spec).unwrap();
256        assert!(result.is_feasible());
257    }
258
259    #[test]
260    fn test_check_invariants() {
261        let pack = InventoryReplenishmentPack;
262        let input = create_test_input();
263
264        let spec = ProblemSpec::builder("test-003", "test-tenant")
265            .objective(ObjectiveSpec::minimize("cost"))
266            .inputs(&input)
267            .unwrap()
268            .seed(42)
269            .build()
270            .unwrap();
271
272        let result = pack.solve(&spec).unwrap();
273        let invariants = pack.check_invariants(&result.plan).unwrap();
274
275        // Should have multiple invariant checks
276        assert!(!invariants.is_empty());
277    }
278
279    #[test]
280    fn test_gate_evaluation() {
281        let pack = InventoryReplenishmentPack;
282        let input = create_test_input();
283
284        let spec = ProblemSpec::builder("test-004", "test-tenant")
285            .objective(ObjectiveSpec::minimize("cost"))
286            .inputs(&input)
287            .unwrap()
288            .seed(42)
289            .build()
290            .unwrap();
291
292        let result = pack.solve(&spec).unwrap();
293        let invariants = pack.check_invariants(&result.plan).unwrap();
294        let gate = pack.evaluate_gate(&result.plan, &invariants);
295
296        // Gate evaluation should work
297        assert!(gate.is_promoted() || !gate.is_promoted());
298    }
299
300    #[test]
301    fn test_determinism() {
302        let pack = InventoryReplenishmentPack;
303        let input = create_test_input();
304
305        let spec1 = ProblemSpec::builder("test-a", "tenant")
306            .objective(ObjectiveSpec::minimize("cost"))
307            .inputs(&input)
308            .unwrap()
309            .seed(99999)
310            .build()
311            .unwrap();
312
313        let spec2 = ProblemSpec::builder("test-b", "tenant")
314            .objective(ObjectiveSpec::minimize("cost"))
315            .inputs(&input)
316            .unwrap()
317            .seed(99999)
318            .build()
319            .unwrap();
320
321        let result1 = pack.solve(&spec1).unwrap();
322        let result2 = pack.solve(&spec2).unwrap();
323
324        let output1: InventoryReplenishmentOutput = result1.plan.plan_as().unwrap();
325        let output2: InventoryReplenishmentOutput = result2.plan.plan_as().unwrap();
326
327        assert_eq!(output1.orders.len(), output2.orders.len());
328        assert_eq!(
329            output1.stats.total_order_cost,
330            output2.stats.total_order_cost
331        );
332
333        for (a, b) in output1.orders.iter().zip(output2.orders.iter()) {
334            assert_eq!(a.product_id, b.product_id);
335            assert_eq!(a.quantity, b.quantity);
336        }
337    }
338
339    #[test]
340    fn test_budget_constraint() {
341        let pack = InventoryReplenishmentPack;
342        let mut input = create_test_input();
343        input.constraints.budget = 500.0; // Very limited budget
344
345        let spec = ProblemSpec::builder("test-005", "test-tenant")
346            .objective(ObjectiveSpec::minimize("cost"))
347            .inputs(&input)
348            .unwrap()
349            .seed(42)
350            .build()
351            .unwrap();
352
353        let result = pack.solve(&spec).unwrap();
354        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
355
356        // Total cost should not exceed budget
357        assert!(output.stats.total_order_cost <= 500.0);
358    }
359
360    #[test]
361    fn test_max_orders_constraint() {
362        let pack = InventoryReplenishmentPack;
363        let mut input = create_test_input();
364        input.constraints.max_orders = Some(1);
365
366        let spec = ProblemSpec::builder("test-006", "test-tenant")
367            .objective(ObjectiveSpec::minimize("cost"))
368            .inputs(&input)
369            .unwrap()
370            .seed(42)
371            .build()
372            .unwrap();
373
374        let result = pack.solve(&spec).unwrap();
375        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
376
377        // Should have at most 1 order
378        assert!(output.orders.len() <= 1);
379    }
380
381    #[test]
382    fn test_output_summary() {
383        let pack = InventoryReplenishmentPack;
384        let input = create_test_input();
385
386        let spec = ProblemSpec::builder("test-007", "test-tenant")
387            .objective(ObjectiveSpec::minimize("cost"))
388            .inputs(&input)
389            .unwrap()
390            .seed(42)
391            .build()
392            .unwrap();
393
394        let result = pack.solve(&spec).unwrap();
395        let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
396
397        let summary = output.summary();
398        assert!(!summary.is_empty());
399        assert!(summary.contains("units") || summary.contains("products"));
400    }
401}