Skip to main content

converge_optimization/packs/vendor_shortlist/
mod.rs

1//! Vendor Shortlist Pack
2//!
3//! JTBD: "Shortlist vendors under risk/compliance constraints with explainability."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Vendor profiles and scores
9//! - Risk and compliance requirements
10//! - Diversification rules
11//!
12//! Find:
13//! - Shortlist of qualifying vendors ranked by composite score
14//!
15//! ## Solver
16//!
17//! Uses score-based ranking:
18//! 1. Filter vendors by compliance and certifications
19//! 2. Filter by minimum score and maximum risk
20//! 3. Calculate composite score for each vendor
21//! 4. Rank by composite score, take top N
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::gate::GateResult as Result;
33use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
34use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR};
35
36/// Vendor Shortlist Pack
37pub struct VendorShortlistPack;
38
39impl Pack for VendorShortlistPack {
40    fn name(&self) -> &'static str {
41        "vendor-shortlist"
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: VendorShortlistInput = 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: VendorShortlistInput = spec.inputs_as()?;
61        input.validate()?;
62
63        let solver = ScoreRankingSolver;
64        let (output, report) = solver.solve_shortlist(&input, spec)?;
65
66        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
67        let confidence = calculate_confidence(&output, &input);
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: VendorShortlistOutput = 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: &VendorShortlistOutput, input: &VendorShortlistInput) -> f64 {
96    if output.shortlist.is_empty() {
97        return 0.0;
98    }
99
100    let mut confidence: f64 = 0.5;
101
102    // Higher confidence if we found multiple vendors
103    if output.shortlist.len() >= 2 {
104        confidence += CONFIDENCE_STEP_MAJOR;
105    }
106
107    // Higher confidence if shortlist is at capacity
108    if output.shortlist.len() == input.requirements.max_vendors {
109        confidence += CONFIDENCE_STEP_MINOR;
110    }
111
112    // Higher confidence if average score is good
113    if output.stats.average_score >= 80.0 {
114        confidence += CONFIDENCE_STEP_MAJOR;
115    } else if output.stats.average_score >= 70.0 {
116        confidence += CONFIDENCE_STEP_MINOR;
117    }
118
119    confidence.min(1.0)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use converge_pack::gate::ObjectiveSpec;
126
127    fn create_test_input() -> VendorShortlistInput {
128        VendorShortlistInput {
129            vendors: vec![
130                Vendor {
131                    id: "v1".to_string(),
132                    name: "Acme Corp".to_string(),
133                    score: 85.0,
134                    risk_score: 20.0,
135                    compliance_status: "compliant".to_string(),
136                    certifications: vec!["ISO9001".to_string()],
137                },
138                Vendor {
139                    id: "v2".to_string(),
140                    name: "BetaCo".to_string(),
141                    score: 75.0,
142                    risk_score: 15.0,
143                    compliance_status: "compliant".to_string(),
144                    certifications: vec!["ISO9001".to_string()],
145                },
146            ],
147            requirements: ShortlistRequirements {
148                max_vendors: 3,
149                min_score: 50.0,
150                max_risk_score: 50.0,
151                required_certifications: vec![],
152            },
153        }
154    }
155
156    #[test]
157    fn test_pack_name() {
158        let pack = VendorShortlistPack;
159        assert_eq!(pack.name(), "vendor-shortlist");
160        assert_eq!(pack.version(), "1.0.0");
161    }
162
163    #[test]
164    fn test_validate_inputs() {
165        let pack = VendorShortlistPack;
166        let input = create_test_input();
167        let json = serde_json::to_value(&input).unwrap();
168        assert!(pack.validate_inputs(&json).is_ok());
169    }
170
171    #[test]
172    fn test_solve_basic() {
173        let pack = VendorShortlistPack;
174        let input = create_test_input();
175
176        let spec = ProblemSpec::builder("test-001", "test-tenant")
177            .objective(ObjectiveSpec::maximize("score"))
178            .inputs(&input)
179            .unwrap()
180            .seed(42)
181            .build()
182            .unwrap();
183
184        let result = pack.solve(&spec).unwrap();
185        assert!(result.is_feasible());
186
187        let output: VendorShortlistOutput = result.plan.plan_as().unwrap();
188        assert_eq!(output.shortlist.len(), 2);
189    }
190
191    #[test]
192    fn test_check_invariants() {
193        let pack = VendorShortlistPack;
194        let input = create_test_input();
195
196        let spec = ProblemSpec::builder("test-002", "test-tenant")
197            .objective(ObjectiveSpec::maximize("score"))
198            .inputs(&input)
199            .unwrap()
200            .seed(42)
201            .build()
202            .unwrap();
203
204        let result = pack.solve(&spec).unwrap();
205        let invariants = pack.check_invariants(&result.plan).unwrap();
206
207        let all_pass = invariants.iter().all(|r| r.passed);
208        assert!(all_pass);
209    }
210
211    #[test]
212    fn test_gate_promotes() {
213        let pack = VendorShortlistPack;
214        let input = create_test_input();
215
216        let spec = ProblemSpec::builder("test-003", "test-tenant")
217            .objective(ObjectiveSpec::maximize("score"))
218            .inputs(&input)
219            .unwrap()
220            .seed(42)
221            .build()
222            .unwrap();
223
224        let result = pack.solve(&spec).unwrap();
225        let invariants = pack.check_invariants(&result.plan).unwrap();
226        let gate = pack.evaluate_gate(&result.plan, &invariants);
227
228        assert!(gate.is_promoted());
229    }
230
231    #[test]
232    fn test_determinism() {
233        let pack = VendorShortlistPack;
234        let input = create_test_input();
235
236        let spec1 = ProblemSpec::builder("test-a", "tenant")
237            .objective(ObjectiveSpec::maximize("score"))
238            .inputs(&input)
239            .unwrap()
240            .seed(99999)
241            .build()
242            .unwrap();
243
244        let spec2 = ProblemSpec::builder("test-b", "tenant")
245            .objective(ObjectiveSpec::maximize("score"))
246            .inputs(&input)
247            .unwrap()
248            .seed(99999)
249            .build()
250            .unwrap();
251
252        let result1 = pack.solve(&spec1).unwrap();
253        let result2 = pack.solve(&spec2).unwrap();
254
255        let output1: VendorShortlistOutput = result1.plan.plan_as().unwrap();
256        let output2: VendorShortlistOutput = result2.plan.plan_as().unwrap();
257
258        assert_eq!(output1.shortlist.len(), output2.shortlist.len());
259        for (a, b) in output1.shortlist.iter().zip(output2.shortlist.iter()) {
260            assert_eq!(a.vendor_id, b.vendor_id);
261        }
262    }
263}