Skip to main content

converge_optimization/packs/vendor_shortlist/
solver.rs

1//! Solver for Vendor Shortlist 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/// Score-based ranking solver for vendor shortlisting
9///
10/// Algorithm:
11/// 1. Filter vendors by compliance and certifications
12/// 2. Filter by minimum score and maximum risk
13/// 3. Calculate composite score for each vendor
14/// 4. Rank by composite score, take top N
15pub struct ScoreRankingSolver;
16
17impl ScoreRankingSolver {
18    /// Solve the vendor shortlist problem
19    pub fn solve_shortlist(
20        &self,
21        input: &VendorShortlistInput,
22        spec: &ProblemSpec,
23    ) -> Result<(VendorShortlistOutput, SolverReport)> {
24        let seed = spec.seed();
25        let reqs = &input.requirements;
26
27        let mut shortlist = Vec::new();
28        let mut rejected = Vec::new();
29
30        // Evaluate each vendor
31        for vendor in &input.vendors {
32            // Check compliance
33            if !vendor.is_compliant() {
34                rejected.push(RejectedVendor {
35                    vendor_id: vendor.id.clone(),
36                    vendor_name: vendor.name.clone(),
37                    reason: format!("Non-compliant status: {}", vendor.compliance_status),
38                });
39                continue;
40            }
41
42            // Check certifications
43            if !vendor.has_certifications(&reqs.required_certifications) {
44                let missing: Vec<_> = reqs
45                    .required_certifications
46                    .iter()
47                    .filter(|c| !vendor.certifications.contains(c))
48                    .collect();
49                rejected.push(RejectedVendor {
50                    vendor_id: vendor.id.clone(),
51                    vendor_name: vendor.name.clone(),
52                    reason: format!(
53                        "Missing certifications: {}",
54                        missing
55                            .iter()
56                            .map(|s| s.as_str())
57                            .collect::<Vec<_>>()
58                            .join(", ")
59                    ),
60                });
61                continue;
62            }
63
64            // Check minimum score
65            if vendor.score < reqs.min_score {
66                rejected.push(RejectedVendor {
67                    vendor_id: vendor.id.clone(),
68                    vendor_name: vendor.name.clone(),
69                    reason: format!(
70                        "Score {:.1} below minimum {:.1}",
71                        vendor.score, reqs.min_score
72                    ),
73                });
74                continue;
75            }
76
77            // Check risk threshold
78            if vendor.risk_score > reqs.max_risk_score {
79                rejected.push(RejectedVendor {
80                    vendor_id: vendor.id.clone(),
81                    vendor_name: vendor.name.clone(),
82                    reason: format!(
83                        "Risk score {:.1} exceeds maximum {:.1}",
84                        vendor.risk_score, reqs.max_risk_score
85                    ),
86                });
87                continue;
88            }
89
90            // Vendor passes all checks
91            shortlist.push((vendor, vendor.composite_score()));
92        }
93
94        // Sort by composite score descending
95        shortlist.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
96
97        // Apply tie-breaking for equal scores
98        let tie_break = &spec.determinism.tie_break;
99
100        // Group by composite score and apply tie-breaking
101        let mut final_list: Vec<(&Vendor, f64)> = Vec::new();
102        let mut current_score = f64::INFINITY;
103        let mut score_group: Vec<(&Vendor, f64)> = vec![];
104
105        for (vendor, score) in shortlist {
106            if (score - current_score).abs() < 0.01 {
107                score_group.push((vendor, score));
108            } else {
109                if !score_group.is_empty() {
110                    // Sort by ID for deterministic tie-breaking
111                    score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
112                    if let Some(selected) =
113                        tie_break.select_by(&score_group, seed, |a, b| a.0.id.cmp(&b.0.id))
114                    {
115                        final_list.push(*selected);
116                    } else {
117                        final_list.extend(score_group.drain(..));
118                    }
119                }
120                score_group = vec![(vendor, score)];
121                current_score = score;
122            }
123        }
124        // Don't forget the last group
125        if !score_group.is_empty() {
126            score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
127            if let Some(selected) =
128                tie_break.select_by(&score_group, seed, |a, b| a.0.id.cmp(&b.0.id))
129            {
130                final_list.push(*selected);
131            } else {
132                final_list.extend(score_group.drain(..));
133            }
134        }
135
136        // Take top N
137        let top_n: Vec<_> = final_list.into_iter().take(reqs.max_vendors).collect();
138
139        // Build output
140        let shortlisted: Vec<ShortlistedVendor> = top_n
141            .iter()
142            .enumerate()
143            .map(|(i, (vendor, composite))| ShortlistedVendor {
144                vendor_id: vendor.id.clone(),
145                vendor_name: vendor.name.clone(),
146                rank: i + 1,
147                score: vendor.score,
148                composite_score: *composite,
149            })
150            .collect();
151
152        let total_shortlisted = shortlisted.len();
153        let average_score = if total_shortlisted > 0 {
154            shortlisted.iter().map(|v| v.score).sum::<f64>() / total_shortlisted as f64
155        } else {
156            0.0
157        };
158
159        let output = VendorShortlistOutput {
160            shortlist: shortlisted,
161            rejected,
162            stats: ShortlistStats {
163                total_evaluated: input.vendors.len(),
164                total_shortlisted,
165                total_rejected: input.vendors.len() - total_shortlisted,
166                average_score,
167                reason: if total_shortlisted > 0 {
168                    format!(
169                        "Selected top {} vendors by composite score",
170                        total_shortlisted
171                    )
172                } else {
173                    "No vendors met all requirements".to_string()
174                },
175            },
176        };
177
178        let replay = ReplayEnvelope::minimal(seed);
179        let report = if total_shortlisted > 0 {
180            SolverReport::optimal("score-rank-v1", average_score, replay)
181        } else {
182            SolverReport::infeasible("score-rank-v1", vec![], StopReason::NoFeasible, replay)
183        };
184
185        Ok((output, report))
186    }
187}
188
189impl PackSolver for ScoreRankingSolver {
190    fn id(&self) -> &'static str {
191        "score-rank-v1"
192    }
193
194    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
195        let input: VendorShortlistInput = spec.inputs_as()?;
196        let (output, report) = self.solve_shortlist(&input, spec)?;
197        let json = serde_json::to_value(&output)
198            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
199        Ok((json, report))
200    }
201
202    fn is_exact(&self) -> bool {
203        true
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use converge_pack::gate::ObjectiveSpec;
211
212    fn create_test_input() -> VendorShortlistInput {
213        VendorShortlistInput {
214            vendors: vec![
215                Vendor {
216                    id: "v1".to_string(),
217                    name: "Acme Corp".to_string(),
218                    score: 85.0,
219                    risk_score: 20.0,
220                    compliance_status: "compliant".to_string(),
221                    certifications: vec!["ISO9001".to_string(), "SOC2".to_string()],
222                },
223                Vendor {
224                    id: "v2".to_string(),
225                    name: "BetaCo".to_string(),
226                    score: 75.0,
227                    risk_score: 15.0,
228                    compliance_status: "compliant".to_string(),
229                    certifications: vec!["ISO9001".to_string()],
230                },
231                Vendor {
232                    id: "v3".to_string(),
233                    name: "GammaTech".to_string(),
234                    score: 90.0,
235                    risk_score: 60.0, // High risk
236                    compliance_status: "compliant".to_string(),
237                    certifications: vec!["ISO9001".to_string()],
238                },
239            ],
240            requirements: ShortlistRequirements {
241                max_vendors: 2,
242                min_score: 70.0,
243                max_risk_score: 50.0,
244                required_certifications: vec!["ISO9001".to_string()],
245            },
246        }
247    }
248
249    fn create_spec(input: &VendorShortlistInput, seed: u64) -> ProblemSpec {
250        ProblemSpec::builder("test", "tenant")
251            .objective(ObjectiveSpec::maximize("score"))
252            .inputs(input)
253            .unwrap()
254            .seed(seed)
255            .build()
256            .unwrap()
257    }
258
259    #[test]
260    fn test_shortlist_ranking() {
261        let solver = ScoreRankingSolver;
262        let input = create_test_input();
263        let spec = create_spec(&input, 42);
264
265        let (output, report) = solver.solve_shortlist(&input, &spec).unwrap();
266
267        assert_eq!(output.shortlist.len(), 2);
268        assert!(report.feasible);
269
270        // v1 should be first (85 - 10 = 75), v2 second (75 - 7.5 = 67.5)
271        assert_eq!(output.shortlist[0].vendor_id, "v1");
272        assert_eq!(output.shortlist[0].rank, 1);
273    }
274
275    #[test]
276    fn test_risk_filtering() {
277        let solver = ScoreRankingSolver;
278        let input = create_test_input();
279        let spec = create_spec(&input, 42);
280
281        let (output, _) = solver.solve_shortlist(&input, &spec).unwrap();
282
283        // v3 should be rejected due to high risk
284        let v3_rejected = output.rejected.iter().find(|r| r.vendor_id == "v3");
285        assert!(v3_rejected.is_some());
286        assert!(v3_rejected.unwrap().reason.contains("Risk score"));
287    }
288
289    #[test]
290    fn test_certification_filtering() {
291        let solver = ScoreRankingSolver;
292        let mut input = create_test_input();
293        input.requirements.required_certifications = vec!["SOC2".to_string()];
294
295        let spec = create_spec(&input, 42);
296        let (output, _) = solver.solve_shortlist(&input, &spec).unwrap();
297
298        // Only v1 has SOC2
299        assert_eq!(output.shortlist.len(), 1);
300        assert_eq!(output.shortlist[0].vendor_id, "v1");
301    }
302
303    #[test]
304    fn test_no_qualifying_vendors() {
305        let solver = ScoreRankingSolver;
306        let mut input = create_test_input();
307        input.requirements.min_score = 100.0; // No one meets this
308
309        let spec = create_spec(&input, 42);
310        let (output, report) = solver.solve_shortlist(&input, &spec).unwrap();
311
312        assert!(output.shortlist.is_empty());
313        assert!(!report.feasible);
314    }
315
316    #[test]
317    fn test_determinism() {
318        let solver = ScoreRankingSolver;
319        let input = create_test_input();
320
321        let spec1 = create_spec(&input, 12345);
322        let spec2 = create_spec(&input, 12345);
323
324        let (output1, _) = solver.solve_shortlist(&input, &spec1).unwrap();
325        let (output2, _) = solver.solve_shortlist(&input, &spec2).unwrap();
326
327        assert_eq!(output1.shortlist.len(), output2.shortlist.len());
328        for (a, b) in output1.shortlist.iter().zip(output2.shortlist.iter()) {
329            assert_eq!(a.vendor_id, b.vendor_id);
330        }
331    }
332}