converge_optimization/packs/shipping_choice/
solver.rs1use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8pub struct CostMinimizingSolver;
16
17impl CostMinimizingSolver {
18 pub fn solve_shipping(
20 &self,
21 input: &ShippingChoiceInput,
22 spec: &ProblemSpec,
23 ) -> Result<(ShippingChoiceOutput, SolverReport)> {
24 let seed = spec.seed();
25
26 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 let (meeting_sla, _not_meeting_sla): (Vec<_>, Vec<_>) = valid_carriers
47 .iter()
48 .partition(|c| c.estimated_days <= input.sla_days);
49
50 let mut candidates: Vec<_> = if !meeting_sla.is_empty() {
52 meeting_sla
53 } else {
54 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 let tie_break = &spec.determinism.tie_break;
67
68 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 let selected = if best_group.len() == 1 {
83 *best_group[0]
84 } else {
85 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 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); 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 }
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; let spec = create_spec(&input, 42);
229 let (output, _) = solver.solve_shipping(&input, &spec).unwrap();
230
231 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 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, }],
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}