Skip to main content

converge_optimization/packs/inventory_rebalancing/
mod.rs

1//! Inventory Rebalancing Pack
2//!
3//! Plans inventory transfers to optimize service levels while minimizing costs.
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Locations with current inventory levels
9//! - Target levels and safety stock requirements
10//! - Transfer costs between locations
11//! - Budget and transfer constraints
12//!
13//! Find:
14//! - Set of transfers that improve service levels within budget
15//!
16//! ## Solver
17//!
18//! Uses greedy cost/service optimization:
19//! 1. Calculate deficit/surplus for each (location, product)
20//! 2. Sort deficits by urgency (most negative first)
21//! 3. For each deficit, find cheapest transfer from surplus locations
22//! 4. Stop when budget or transfer limits reached
23
24mod 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
37/// Inventory Rebalancing Pack
38pub 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        // Check for critical financial threshold
94        if let Ok(output) = plan.plan_as::<InventoryRebalancingOutput>() {
95            // If cost is very high relative to improvement, require review
96            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
105/// Calculate confidence score based on output quality
106fn calculate_confidence(
107    output: &InventoryRebalancingOutput,
108    input: &InventoryRebalancingInput,
109) -> f64 {
110    if output.transfers.is_empty() {
111        // No transfers might be correct (already balanced)
112        return 0.6;
113    }
114
115    let mut confidence = 0.5;
116
117    // Bonus for positive service improvement
118    if output.service_level_improvement > 0.0 {
119        confidence += 0.2_f64.min(output.service_level_improvement * 0.1);
120    }
121
122    // Bonus for staying well under budget
123    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    // Bonus for using fewer transfers than limit
131    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        // Should transfer from warehouse to store
234        assert!(!output.transfers.is_empty());
235
236        // Check transfer direction
237        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        // All critical invariants should pass
259        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        // Create input where everything is already at target
295        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, // Already at target
313                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        // No transfers needed
336        assert!(output.transfers.is_empty());
337    }
338}