Skip to main content

converge_optimization/packs/shipping_choice/
solver.rs

1//! Solver for Shipping Choice pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8/// Cost-minimizing solver for shipping choice
9///
10/// Algorithm:
11/// 1. Filter carriers that can handle the order (hazmat check)
12/// 2. Filter carriers that meet SLA requirements
13/// 3. Sort by cost (ascending)
14/// 4. Select cheapest option
15pub struct CostMinimizingSolver;
16
17impl CostMinimizingSolver {
18    /// Solve the shipping choice problem
19    pub fn solve_shipping(
20        &self,
21        input: &ShippingChoiceInput,
22        spec: &ProblemSpec,
23    ) -> Result<(ShippingChoiceOutput, SolverReport)> {
24        let seed = spec.seed();
25
26        // Filter to valid carriers
27        let valid_carriers: Vec<&CarrierOption> = input
28            .carriers
29            .iter()
30            .filter(|c| c.can_handle(&input.order))
31            .collect();
32
33        if valid_carriers.is_empty() {
34            let output = ShippingChoiceOutput::no_carrier(if input.is_hazmat() {
35                "No carriers support hazmat shipping"
36            } else {
37                "No carriers available"
38            });
39            let replay = ReplayEnvelope::minimal(seed);
40            let report =
41                SolverReport::infeasible("cost-min-v1", vec![], StopReason::NoFeasible, replay);
42            return Ok((output, report));
43        }
44
45        // Separate carriers meeting SLA vs not
46        let (meeting_sla, _not_meeting_sla): (Vec<_>, Vec<_>) = valid_carriers
47            .iter()
48            .partition(|c| c.estimated_days <= input.sla_days);
49
50        // Sort by cost
51        let mut candidates: Vec<_> = if !meeting_sla.is_empty() {
52            meeting_sla
53        } else {
54            // Fall back to all carriers if none meet SLA
55            valid_carriers.clone()
56        };
57
58        candidates.sort_by(|a, b| {
59            a.cost
60                .total_cmp(&b.cost)
61                .then_with(|| a.carrier_id.cmp(&b.carrier_id))
62                .then_with(|| a.service_level.cmp(&b.service_level))
63        });
64
65        // Apply tie-breaking for equal costs
66        let tie_break = &spec.determinism.tie_break;
67
68        // Group by cost and apply tie-breaking
69        let mut best_cost = f64::INFINITY;
70        let mut best_group: Vec<&&CarrierOption> = vec![];
71
72        for carrier in &candidates {
73            if (carrier.cost - best_cost).abs() < 0.01 {
74                best_group.push(carrier);
75            } else if carrier.cost < best_cost {
76                best_cost = carrier.cost;
77                best_group = vec![carrier];
78            }
79        }
80
81        // Select from best group using tie-breaking
82        let selected = if best_group.len() == 1 {
83            *best_group[0]
84        } else {
85            // Sort by carrier_id for deterministic tie-breaking
86            best_group.sort_by(|a, b| a.carrier_id.cmp(&b.carrier_id));
87            tie_break
88                .select_by(&best_group, seed, |a, b| a.carrier_id.cmp(&b.carrier_id))
89                .map(|c| **c)
90                .unwrap_or(best_group[0])
91        };
92
93        // Build alternatives list
94        let alternatives: Vec<AlternativeCarrier> = candidates
95            .iter()
96            .filter(|c| c.carrier_id != selected.carrier_id)
97            .take(3)
98            .map(|c| AlternativeCarrier {
99                carrier_id: c.carrier_id.clone(),
100                service_level: c.service_level.clone(),
101                cost: c.cost,
102                reason_not_selected: if c.cost > selected.cost {
103                    "Higher cost".to_string()
104                } else if c.estimated_days > selected.estimated_days {
105                    "Longer delivery time".to_string()
106                } else {
107                    "Tie-breaking".to_string()
108                },
109            })
110            .collect();
111
112        let meets_sla = selected.estimated_days <= input.sla_days;
113        let selection_reason = if meets_sla {
114            format!("Lowest cost carrier meeting {}-day SLA", input.sla_days)
115        } else {
116            format!(
117                "Best available carrier (SLA not achievable, {} days vs {} required)",
118                selected.estimated_days, input.sla_days
119            )
120        };
121
122        let output = ShippingChoiceOutput {
123            selected_carrier: Some(selected.carrier_id.clone()),
124            selected_service: Some(selected.service_level.clone()),
125            cost: selected.cost,
126            estimated_days: selected.estimated_days,
127            meets_sla,
128            selection_reason,
129            alternatives,
130        };
131
132        let replay = ReplayEnvelope::minimal(seed);
133        let report = SolverReport::optimal("cost-min-v1", -selected.cost, replay); // Negative because we minimize
134
135        Ok((output, report))
136    }
137}
138
139impl PackSolver for CostMinimizingSolver {
140    fn id(&self) -> &'static str {
141        "cost-min-v1"
142    }
143
144    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
145        let input: ShippingChoiceInput = spec.inputs_as()?;
146        let (output, report) = self.solve_shipping(&input, spec)?;
147        let json = serde_json::to_value(&output)
148            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
149        Ok((json, report))
150    }
151
152    fn is_exact(&self) -> bool {
153        true // We enumerate all options
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use converge_pack::gate::ObjectiveSpec;
161
162    fn create_test_input() -> ShippingChoiceInput {
163        ShippingChoiceInput {
164            order: OrderDetails {
165                order_id: "ORD-001".to_string(),
166                weight_kg: 2.5,
167                dimensions_cm: [20.0, 15.0, 10.0],
168                destination_zip: "10001".to_string(),
169                is_hazmat: false,
170            },
171            carriers: vec![
172                CarrierOption {
173                    carrier_id: "ups".to_string(),
174                    service_level: "ground".to_string(),
175                    cost: 8.99,
176                    estimated_days: 5,
177                    supports_hazmat: false,
178                },
179                CarrierOption {
180                    carrier_id: "fedex".to_string(),
181                    service_level: "express".to_string(),
182                    cost: 15.99,
183                    estimated_days: 2,
184                    supports_hazmat: true,
185                },
186                CarrierOption {
187                    carrier_id: "usps".to_string(),
188                    service_level: "priority".to_string(),
189                    cost: 9.99,
190                    estimated_days: 3,
191                    supports_hazmat: false,
192                },
193            ],
194            sla_days: 5,
195        }
196    }
197
198    fn create_spec(input: &ShippingChoiceInput, seed: u64) -> ProblemSpec {
199        ProblemSpec::builder("test", "tenant")
200            .objective(ObjectiveSpec::minimize("cost"))
201            .inputs(input)
202            .unwrap()
203            .seed(seed)
204            .build()
205            .unwrap()
206    }
207
208    #[test]
209    fn test_selects_cheapest() {
210        let solver = CostMinimizingSolver;
211        let input = create_test_input();
212        let spec = create_spec(&input, 42);
213
214        let (output, report) = solver.solve_shipping(&input, &spec).unwrap();
215
216        assert_eq!(output.selected_carrier.as_deref(), Some("ups"));
217        assert!((output.cost - 8.99).abs() < 0.01);
218        assert!(output.meets_sla);
219        assert!(report.feasible);
220    }
221
222    #[test]
223    fn test_respects_sla() {
224        let solver = CostMinimizingSolver;
225        let mut input = create_test_input();
226        input.sla_days = 2; // Tight SLA
227
228        let spec = create_spec(&input, 42);
229        let (output, _) = solver.solve_shipping(&input, &spec).unwrap();
230
231        // Only FedEx meets 2-day SLA
232        assert_eq!(output.selected_carrier.as_deref(), Some("fedex"));
233        assert!(output.meets_sla);
234    }
235
236    #[test]
237    fn test_hazmat_filtering() {
238        let solver = CostMinimizingSolver;
239        let mut input = create_test_input();
240        input.order.is_hazmat = true;
241
242        let spec = create_spec(&input, 42);
243        let (output, _) = solver.solve_shipping(&input, &spec).unwrap();
244
245        // Only FedEx supports hazmat
246        assert_eq!(output.selected_carrier.as_deref(), Some("fedex"));
247    }
248
249    #[test]
250    fn test_no_carrier_available() {
251        let solver = CostMinimizingSolver;
252        let input = ShippingChoiceInput {
253            order: OrderDetails {
254                is_hazmat: true,
255                ..Default::default()
256            },
257            carriers: vec![CarrierOption {
258                carrier_id: "basic".to_string(),
259                service_level: "ground".to_string(),
260                cost: 5.0,
261                estimated_days: 7,
262                supports_hazmat: false, // Can't handle hazmat
263            }],
264            sla_days: 5,
265        };
266
267        let spec = create_spec(&input, 42);
268        let (output, report) = solver.solve_shipping(&input, &spec).unwrap();
269
270        assert!(output.selected_carrier.is_none());
271        assert!(!report.feasible);
272    }
273
274    #[test]
275    fn test_determinism() {
276        let solver = CostMinimizingSolver;
277        let input = create_test_input();
278
279        let spec1 = create_spec(&input, 12345);
280        let spec2 = create_spec(&input, 12345);
281
282        let (output1, _) = solver.solve_shipping(&input, &spec1).unwrap();
283        let (output2, _) = solver.solve_shipping(&input, &spec2).unwrap();
284
285        assert_eq!(output1.selected_carrier, output2.selected_carrier);
286        assert_eq!(output1.cost, output2.cost);
287    }
288}