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 {
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 let mut confidence: f64 = 0.5;
114
115 if output.orders.is_empty() {
117 if input.products.iter().all(|p| !p.needs_reorder()) {
118 return 0.9; }
120 return 0.3; }
122
123 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 if output.stats.budget_utilization > 0.1 && output.stats.budget_utilization < 0.9 {
132 confidence += CONFIDENCE_STEP_MINOR;
133 }
134
135 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)], 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 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 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; 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 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 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}