converge_optimization/packs/inventory_rebalancing/
mod.rs1mod invariants;
25mod solver;
26mod types;
27
28pub use invariants::*;
29pub use solver::*;
30pub use types::*;
31
32use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
33use converge_pack::CONFIDENCE_STEP_MINOR;
34use converge_pack::gate::GateResult as Result;
35use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
36
37pub struct InventoryRebalancingPack;
39
40impl Pack for InventoryRebalancingPack {
41 fn name(&self) -> &'static str {
42 "inventory-rebalancing"
43 }
44
45 fn version(&self) -> &'static str {
46 "1.0.0"
47 }
48
49 fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
50 let input: InventoryRebalancingInput =
51 serde_json::from_value(inputs.clone()).map_err(|e| {
52 converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
53 })?;
54 input.validate()
55 }
56
57 fn invariants(&self) -> &[InvariantDef] {
58 INVARIANTS
59 }
60
61 fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
62 let input: InventoryRebalancingInput = spec.inputs_as()?;
63 input.validate()?;
64
65 let solver = GreedyRebalancingSolver;
66 let (output, report) = solver.solve_rebalancing(&input, spec)?;
67
68 let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
69 let confidence = calculate_confidence(&output, &input);
70
71 let plan = ProposedPlan::from_payload(
72 format!("plan-{}", spec.problem_id),
73 self.name(),
74 output.summary(),
75 &output,
76 confidence,
77 trace,
78 )?;
79
80 Ok(PackSolveResult::new(plan, report))
81 }
82
83 fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
84 let output: InventoryRebalancingOutput = plan.plan_as()?;
85 Ok(check_all_invariants(&output))
86 }
87
88 fn evaluate_gate(
89 &self,
90 plan: &ProposedPlan,
91 invariant_results: &[InvariantResult],
92 ) -> PromotionGate {
93 if let Ok(output) = plan.plan_as::<InventoryRebalancingOutput>() {
95 if output.total_cost > 0.0 && output.service_level_improvement <= 0.0 {
97 return PromotionGate::reject("Cost incurred with no service improvement");
98 }
99 }
100
101 default_gate_evaluation(invariant_results, self.invariants())
102 }
103}
104
105fn calculate_confidence(
107 output: &InventoryRebalancingOutput,
108 input: &InventoryRebalancingInput,
109) -> f64 {
110 if output.transfers.is_empty() {
111 return 0.6;
113 }
114
115 let mut confidence = 0.5;
116
117 if output.service_level_improvement > 0.0 {
119 confidence += 0.2_f64.min(output.service_level_improvement * 0.1);
120 }
121
122 if input.constraints.max_total_cost > 0.0 {
124 let budget_usage = output.total_cost / input.constraints.max_total_cost;
125 if budget_usage < 0.8 {
126 confidence += CONFIDENCE_STEP_MINOR;
127 }
128 }
129
130 if input.constraints.max_total_transfers > 0 {
132 let transfer_usage =
133 output.transfers.len() as f64 / input.constraints.max_total_transfers as f64;
134 if transfer_usage < 0.8 {
135 confidence += CONFIDENCE_STEP_MINOR;
136 }
137 }
138
139 confidence.min(1.0_f64)
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use converge_pack::gate::{ObjectiveSpec, SolveBudgets};
146
147 fn create_test_input() -> InventoryRebalancingInput {
148 InventoryRebalancingInput {
149 locations: vec![
150 Location {
151 id: "warehouse-1".to_string(),
152 name: "Main Warehouse".to_string(),
153 capacity: 1000,
154 location_type: LocationType::Warehouse,
155 },
156 Location {
157 id: "store-1".to_string(),
158 name: "Store A".to_string(),
159 capacity: 100,
160 location_type: LocationType::Store,
161 },
162 ],
163 products: vec![Product {
164 id: "sku-001".to_string(),
165 name: "Widget".to_string(),
166 unit_weight: 1.0,
167 unit_value: 10.0,
168 }],
169 inventory: vec![
170 InventoryLevel {
171 location_id: "warehouse-1".to_string(),
172 product_id: "sku-001".to_string(),
173 quantity: 500,
174 target_quantity: 200,
175 min_quantity: 50,
176 max_quantity: 800,
177 },
178 InventoryLevel {
179 location_id: "store-1".to_string(),
180 product_id: "sku-001".to_string(),
181 quantity: 10,
182 target_quantity: 50,
183 min_quantity: 20,
184 max_quantity: 80,
185 },
186 ],
187 transfer_costs: vec![TransferCost {
188 from_location: "warehouse-1".to_string(),
189 to_location: "store-1".to_string(),
190 cost_per_unit: 0.5,
191 lead_time_hours: 24,
192 }],
193 constraints: RebalancingConstraints {
194 max_total_transfers: 10,
195 max_transfer_quantity: 100,
196 max_total_cost: 100.0,
197 },
198 }
199 }
200
201 #[test]
202 fn test_pack_name() {
203 let pack = InventoryRebalancingPack;
204 assert_eq!(pack.name(), "inventory-rebalancing");
205 }
206
207 #[test]
208 fn test_validate_inputs() {
209 let pack = InventoryRebalancingPack;
210 let input = create_test_input();
211 let json = serde_json::to_value(&input).unwrap();
212 assert!(pack.validate_inputs(&json).is_ok());
213 }
214
215 #[test]
216 fn test_solve_basic() {
217 let pack = InventoryRebalancingPack;
218 let input = create_test_input();
219
220 let spec = ProblemSpec::builder("test-001", "test-tenant")
221 .objective(ObjectiveSpec::minimize("cost"))
222 .inputs(&input)
223 .unwrap()
224 .budgets(SolveBudgets::with_time_limit(10))
225 .seed(42)
226 .build()
227 .unwrap();
228
229 let result = pack.solve(&spec).unwrap();
230 assert!(result.is_feasible());
231
232 let output: InventoryRebalancingOutput = result.plan.plan_as().unwrap();
233 assert!(!output.transfers.is_empty());
235
236 let transfer = &output.transfers[0];
238 assert_eq!(transfer.from_location, "warehouse-1");
239 assert_eq!(transfer.to_location, "store-1");
240 }
241
242 #[test]
243 fn test_check_invariants() {
244 let pack = InventoryRebalancingPack;
245 let input = create_test_input();
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 let invariants = pack.check_invariants(&result.plan).unwrap();
257
258 let critical_pass = invariants
260 .iter()
261 .filter(|r| {
262 r.invariant == "no_negative_inventory"
263 || r.invariant == "within_capacity_limits"
264 || r.invariant == "within_budget"
265 })
266 .all(|r| r.passed);
267 assert!(critical_pass);
268 }
269
270 #[test]
271 fn test_evaluate_gate() {
272 let pack = InventoryRebalancingPack;
273 let input = create_test_input();
274
275 let spec = ProblemSpec::builder("test-003", "test-tenant")
276 .objective(ObjectiveSpec::minimize("cost"))
277 .inputs(&input)
278 .unwrap()
279 .seed(42)
280 .build()
281 .unwrap();
282
283 let result = pack.solve(&spec).unwrap();
284 let invariants = pack.check_invariants(&result.plan).unwrap();
285 let gate = pack.evaluate_gate(&result.plan, &invariants);
286
287 assert!(gate.is_promoted());
288 }
289
290 #[test]
291 fn test_no_transfers_needed() {
292 let pack = InventoryRebalancingPack;
293
294 let input = InventoryRebalancingInput {
296 locations: vec![Location {
297 id: "warehouse-1".to_string(),
298 name: "Warehouse".to_string(),
299 capacity: 1000,
300 location_type: LocationType::Warehouse,
301 }],
302 products: vec![Product {
303 id: "sku-001".to_string(),
304 name: "Widget".to_string(),
305 unit_weight: 1.0,
306 unit_value: 10.0,
307 }],
308 inventory: vec![InventoryLevel {
309 location_id: "warehouse-1".to_string(),
310 product_id: "sku-001".to_string(),
311 quantity: 100,
312 target_quantity: 100, min_quantity: 50,
314 max_quantity: 200,
315 }],
316 transfer_costs: vec![],
317 constraints: RebalancingConstraints {
318 max_total_transfers: 10,
319 max_transfer_quantity: 100,
320 max_total_cost: 100.0,
321 },
322 };
323
324 let spec = ProblemSpec::builder("test-004", "test-tenant")
325 .objective(ObjectiveSpec::minimize("cost"))
326 .inputs(&input)
327 .unwrap()
328 .seed(42)
329 .build()
330 .unwrap();
331
332 let result = pack.solve(&spec).unwrap();
333 let output: InventoryRebalancingOutput = result.plan.plan_as().unwrap();
334
335 assert!(output.transfers.is_empty());
337 }
338}