converge_optimization/packs/inventory_replenishment/
mod.rs1mod 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
42pub 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 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 let mut confidence: f64 = 0.5;
111
112 if output.orders.is_empty() {
114 if input.products.iter().all(|p| !p.needs_reorder()) {
115 return 0.9; }
117 return 0.3; }
119
120 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 if output.stats.budget_utilization > 0.1 && output.stats.budget_utilization < 0.9 {
129 confidence += CONFIDENCE_STEP_MINOR;
130 }
131
132 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)], 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 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 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; 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 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 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}