Skip to main content

converge_optimization/packs/shipping_choice/
mod.rs

1//! Shipping Choice Pack
2//!
3//! JTBD: "Choose shipping method minimizing total cost subject to SLA."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Order details (weight, dimensions, destination)
9//! - Available carriers and rates
10//! - SLA requirements
11//!
12//! Find:
13//! - Optimal carrier/method selection minimizing cost while meeting SLA
14//!
15//! ## Solver
16//!
17//! Uses cost minimization:
18//! 1. Filter carriers that can handle the order (hazmat check)
19//! 2. Filter carriers that meet SLA requirements
20//! 3. Sort by cost (ascending)
21//! 4. Select cheapest option with tie-breaking
22
23mod invariants;
24mod solver;
25mod types;
26
27pub use invariants::*;
28pub use solver::*;
29pub use types::*;
30
31use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
32use converge_pack::CONFIDENCE_STEP_MINOR;
33use converge_pack::gate::GateResult as Result;
34use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
35
36/// Shipping Choice Pack
37pub struct ShippingChoicePack;
38
39impl Pack for ShippingChoicePack {
40    fn name(&self) -> &'static str {
41        "shipping-choice"
42    }
43
44    fn version(&self) -> &'static str {
45        "1.0.0"
46    }
47
48    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
49        let input: ShippingChoiceInput = serde_json::from_value(inputs.clone()).map_err(|e| {
50            converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
51        })?;
52        input.validate()
53    }
54
55    fn invariants(&self) -> &[InvariantDef] {
56        INVARIANTS
57    }
58
59    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
60        let input: ShippingChoiceInput = spec.inputs_as()?;
61        input.validate()?;
62
63        let solver = CostMinimizingSolver;
64        let (output, report) = solver.solve_shipping(&input, spec)?;
65
66        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
67        let confidence = calculate_confidence(&output);
68
69        let plan = ProposedPlan::from_payload(
70            format!("plan-{}", spec.problem_id),
71            self.name(),
72            output.summary(),
73            &output,
74            confidence,
75            trace,
76        )?;
77
78        Ok(PackSolveResult::new(plan, report))
79    }
80
81    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
82        let output: ShippingChoiceOutput = plan.plan_as()?;
83        Ok(check_all_invariants(&output))
84    }
85
86    fn evaluate_gate(
87        &self,
88        _plan: &ProposedPlan,
89        invariant_results: &[InvariantResult],
90    ) -> PromotionGate {
91        default_gate_evaluation(invariant_results, self.invariants())
92    }
93}
94
95fn calculate_confidence(output: &ShippingChoiceOutput) -> f64 {
96    if output.selected_carrier.is_none() {
97        return 0.0;
98    }
99
100    let mut confidence: f64 = 0.6;
101
102    if output.meets_sla {
103        confidence += 0.3;
104    }
105
106    // Bonus if we have alternatives to compare against
107    if !output.alternatives.is_empty() {
108        confidence += CONFIDENCE_STEP_MINOR;
109    }
110
111    confidence.min(1.0)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use converge_pack::gate::ObjectiveSpec;
118
119    fn create_test_input() -> ShippingChoiceInput {
120        ShippingChoiceInput {
121            order: OrderDetails {
122                order_id: "ORD-001".to_string(),
123                weight_kg: 2.5,
124                dimensions_cm: [20.0, 15.0, 10.0],
125                destination_zip: "10001".to_string(),
126                is_hazmat: false,
127            },
128            carriers: vec![
129                CarrierOption {
130                    carrier_id: "ups".to_string(),
131                    service_level: "ground".to_string(),
132                    cost: 8.99,
133                    estimated_days: 5,
134                    supports_hazmat: false,
135                },
136                CarrierOption {
137                    carrier_id: "fedex".to_string(),
138                    service_level: "express".to_string(),
139                    cost: 15.99,
140                    estimated_days: 2,
141                    supports_hazmat: true,
142                },
143            ],
144            sla_days: 5,
145        }
146    }
147
148    #[test]
149    fn test_pack_name() {
150        let pack = ShippingChoicePack;
151        assert_eq!(pack.name(), "shipping-choice");
152        assert_eq!(pack.version(), "1.0.0");
153    }
154
155    #[test]
156    fn test_validate_inputs() {
157        let pack = ShippingChoicePack;
158        let input = create_test_input();
159        let json = serde_json::to_value(&input).unwrap();
160        assert!(pack.validate_inputs(&json).is_ok());
161    }
162
163    #[test]
164    fn test_solve_basic() {
165        let pack = ShippingChoicePack;
166        let input = create_test_input();
167
168        let spec = ProblemSpec::builder("test-001", "test-tenant")
169            .objective(ObjectiveSpec::minimize("cost"))
170            .inputs(&input)
171            .unwrap()
172            .seed(42)
173            .build()
174            .unwrap();
175
176        let result = pack.solve(&spec).unwrap();
177        assert!(result.is_feasible());
178
179        let output: ShippingChoiceOutput = result.plan.plan_as().unwrap();
180        assert!(output.selected_carrier.is_some());
181        assert_eq!(output.selected_carrier.as_deref(), Some("ups"));
182    }
183
184    #[test]
185    fn test_check_invariants() {
186        let pack = ShippingChoicePack;
187        let input = create_test_input();
188
189        let spec = ProblemSpec::builder("test-002", "test-tenant")
190            .objective(ObjectiveSpec::minimize("cost"))
191            .inputs(&input)
192            .unwrap()
193            .seed(42)
194            .build()
195            .unwrap();
196
197        let result = pack.solve(&spec).unwrap();
198        let invariants = pack.check_invariants(&result.plan).unwrap();
199
200        let all_pass = invariants.iter().all(|r| r.passed);
201        assert!(all_pass);
202    }
203
204    #[test]
205    fn test_gate_promotes() {
206        let pack = ShippingChoicePack;
207        let input = create_test_input();
208
209        let spec = ProblemSpec::builder("test-003", "test-tenant")
210            .objective(ObjectiveSpec::minimize("cost"))
211            .inputs(&input)
212            .unwrap()
213            .seed(42)
214            .build()
215            .unwrap();
216
217        let result = pack.solve(&spec).unwrap();
218        let invariants = pack.check_invariants(&result.plan).unwrap();
219        let gate = pack.evaluate_gate(&result.plan, &invariants);
220
221        assert!(gate.is_promoted());
222    }
223
224    #[test]
225    fn test_determinism() {
226        let pack = ShippingChoicePack;
227        let input = create_test_input();
228
229        let spec1 = ProblemSpec::builder("test-a", "tenant")
230            .objective(ObjectiveSpec::minimize("cost"))
231            .inputs(&input)
232            .unwrap()
233            .seed(99999)
234            .build()
235            .unwrap();
236
237        let spec2 = ProblemSpec::builder("test-b", "tenant")
238            .objective(ObjectiveSpec::minimize("cost"))
239            .inputs(&input)
240            .unwrap()
241            .seed(99999)
242            .build()
243            .unwrap();
244
245        let result1 = pack.solve(&spec1).unwrap();
246        let result2 = pack.solve(&spec2).unwrap();
247
248        let output1: ShippingChoiceOutput = result1.plan.plan_as().unwrap();
249        let output2: ShippingChoiceOutput = result2.plan.plan_as().unwrap();
250
251        assert_eq!(output1.selected_carrier, output2.selected_carrier);
252        assert_eq!(output1.cost, output2.cost);
253    }
254}