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