converge_optimization/packs/vendor_shortlist/
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 ScoreRankingSolver;
16
17impl ScoreRankingSolver {
18 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 for vendor in &input.vendors {
32 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 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 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 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 shortlist.push((vendor, vendor.composite_score()));
92 }
93
94 shortlist.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
96
97 let tie_break = &spec.determinism.tie_break;
99
100 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 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 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 let top_n: Vec<_> = final_list.into_iter().take(reqs.max_vendors).collect();
138
139 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, 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 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 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 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; 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}