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 .partial_cmp(&b.cost)
61 .unwrap_or(std::cmp::Ordering::Equal)
62 });
63
64 let tie_break = &spec.determinism.tie_break;
66
67 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 let selected = if best_group.len() == 1 {
82 *best_group[0]
83 } else {
84 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 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); 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 }
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; let spec = create_spec(&input, 42);
228 let (output, _) = solver.solve_shipping(&input, &spec).unwrap();
229
230 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 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, }],
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}