use super::types::*;
use crate::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
use crate::packs::PackSolver;
use crate::Result;
pub struct ScoreRankingSolver;
impl ScoreRankingSolver {
pub fn solve_shortlist(
&self,
input: &VendorShortlistInput,
spec: &ProblemSpec,
) -> Result<(VendorShortlistOutput, SolverReport)> {
let seed = spec.seed();
let reqs = &input.requirements;
let mut shortlist = Vec::new();
let mut rejected = Vec::new();
for vendor in &input.vendors {
if !vendor.is_compliant() {
rejected.push(RejectedVendor {
vendor_id: vendor.id.clone(),
vendor_name: vendor.name.clone(),
reason: format!("Non-compliant status: {}", vendor.compliance_status),
});
continue;
}
if !vendor.has_certifications(&reqs.required_certifications) {
let missing: Vec<_> = reqs.required_certifications
.iter()
.filter(|c| !vendor.certifications.contains(c))
.collect();
rejected.push(RejectedVendor {
vendor_id: vendor.id.clone(),
vendor_name: vendor.name.clone(),
reason: format!("Missing certifications: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")),
});
continue;
}
if vendor.score < reqs.min_score {
rejected.push(RejectedVendor {
vendor_id: vendor.id.clone(),
vendor_name: vendor.name.clone(),
reason: format!("Score {:.1} below minimum {:.1}", vendor.score, reqs.min_score),
});
continue;
}
if vendor.risk_score > reqs.max_risk_score {
rejected.push(RejectedVendor {
vendor_id: vendor.id.clone(),
vendor_name: vendor.name.clone(),
reason: format!("Risk score {:.1} exceeds maximum {:.1}", vendor.risk_score, reqs.max_risk_score),
});
continue;
}
shortlist.push((vendor, vendor.composite_score()));
}
shortlist.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
let tie_break = &spec.determinism.tie_break;
let mut final_list: Vec<(&Vendor, f64)> = Vec::new();
let mut current_score = f64::INFINITY;
let mut score_group: Vec<(&Vendor, f64)> = vec![];
for (vendor, score) in shortlist {
if (score - current_score).abs() < 0.01 {
score_group.push((vendor, score));
} else {
if !score_group.is_empty() {
score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
if let Some(selected) = tie_break.select_by(&score_group, seed, |a, b| a.0.id.cmp(&b.0.id)) {
final_list.push(*selected);
} else {
final_list.extend(score_group.drain(..));
}
}
score_group = vec![(vendor, score)];
current_score = score;
}
}
if !score_group.is_empty() {
score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
if let Some(selected) = tie_break.select_by(&score_group, seed, |a, b| a.0.id.cmp(&b.0.id)) {
final_list.push(*selected);
} else {
final_list.extend(score_group.drain(..));
}
}
let top_n: Vec<_> = final_list.into_iter().take(reqs.max_vendors).collect();
let shortlisted: Vec<ShortlistedVendor> = top_n
.iter()
.enumerate()
.map(|(i, (vendor, composite))| ShortlistedVendor {
vendor_id: vendor.id.clone(),
vendor_name: vendor.name.clone(),
rank: i + 1,
score: vendor.score,
composite_score: *composite,
})
.collect();
let total_shortlisted = shortlisted.len();
let average_score = if total_shortlisted > 0 {
shortlisted.iter().map(|v| v.score).sum::<f64>() / total_shortlisted as f64
} else {
0.0
};
let output = VendorShortlistOutput {
shortlist: shortlisted,
rejected,
stats: ShortlistStats {
total_evaluated: input.vendors.len(),
total_shortlisted,
total_rejected: input.vendors.len() - total_shortlisted,
average_score,
reason: if total_shortlisted > 0 {
format!("Selected top {} vendors by composite score", total_shortlisted)
} else {
"No vendors met all requirements".to_string()
},
},
};
let replay = ReplayEnvelope::minimal(seed);
let report = if total_shortlisted > 0 {
SolverReport::optimal("score-rank-v1", average_score, replay)
} else {
SolverReport::infeasible("score-rank-v1", vec![], StopReason::NoFeasible, replay)
};
Ok((output, report))
}
}
impl PackSolver for ScoreRankingSolver {
fn id(&self) -> &'static str {
"score-rank-v1"
}
fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
let input: VendorShortlistInput = spec.inputs_as()?;
let (output, report) = self.solve_shortlist(&input, spec)?;
let json = serde_json::to_value(&output)
.map_err(|e| crate::Error::invalid_input(e.to_string()))?;
Ok((json, report))
}
fn is_exact(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gate::ObjectiveSpec;
fn create_test_input() -> VendorShortlistInput {
VendorShortlistInput {
vendors: vec![
Vendor {
id: "v1".to_string(),
name: "Acme Corp".to_string(),
score: 85.0,
risk_score: 20.0,
compliance_status: "compliant".to_string(),
certifications: vec!["ISO9001".to_string(), "SOC2".to_string()],
},
Vendor {
id: "v2".to_string(),
name: "BetaCo".to_string(),
score: 75.0,
risk_score: 15.0,
compliance_status: "compliant".to_string(),
certifications: vec!["ISO9001".to_string()],
},
Vendor {
id: "v3".to_string(),
name: "GammaTech".to_string(),
score: 90.0,
risk_score: 60.0, compliance_status: "compliant".to_string(),
certifications: vec!["ISO9001".to_string()],
},
],
requirements: ShortlistRequirements {
max_vendors: 2,
min_score: 70.0,
max_risk_score: 50.0,
required_certifications: vec!["ISO9001".to_string()],
},
}
}
fn create_spec(input: &VendorShortlistInput, seed: u64) -> ProblemSpec {
ProblemSpec::builder("test", "tenant")
.objective(ObjectiveSpec::maximize("score"))
.inputs(input)
.unwrap()
.seed(seed)
.build()
.unwrap()
}
#[test]
fn test_shortlist_ranking() {
let solver = ScoreRankingSolver;
let input = create_test_input();
let spec = create_spec(&input, 42);
let (output, report) = solver.solve_shortlist(&input, &spec).unwrap();
assert_eq!(output.shortlist.len(), 2);
assert!(report.feasible);
assert_eq!(output.shortlist[0].vendor_id, "v1");
assert_eq!(output.shortlist[0].rank, 1);
}
#[test]
fn test_risk_filtering() {
let solver = ScoreRankingSolver;
let input = create_test_input();
let spec = create_spec(&input, 42);
let (output, _) = solver.solve_shortlist(&input, &spec).unwrap();
let v3_rejected = output.rejected.iter().find(|r| r.vendor_id == "v3");
assert!(v3_rejected.is_some());
assert!(v3_rejected.unwrap().reason.contains("Risk score"));
}
#[test]
fn test_certification_filtering() {
let solver = ScoreRankingSolver;
let mut input = create_test_input();
input.requirements.required_certifications = vec!["SOC2".to_string()];
let spec = create_spec(&input, 42);
let (output, _) = solver.solve_shortlist(&input, &spec).unwrap();
assert_eq!(output.shortlist.len(), 1);
assert_eq!(output.shortlist[0].vendor_id, "v1");
}
#[test]
fn test_no_qualifying_vendors() {
let solver = ScoreRankingSolver;
let mut input = create_test_input();
input.requirements.min_score = 100.0;
let spec = create_spec(&input, 42);
let (output, report) = solver.solve_shortlist(&input, &spec).unwrap();
assert!(output.shortlist.is_empty());
assert!(!report.feasible);
}
#[test]
fn test_determinism() {
let solver = ScoreRankingSolver;
let input = create_test_input();
let spec1 = create_spec(&input, 12345);
let spec2 = create_spec(&input, 12345);
let (output1, _) = solver.solve_shortlist(&input, &spec1).unwrap();
let (output2, _) = solver.solve_shortlist(&input, &spec2).unwrap();
assert_eq!(output1.shortlist.len(), output2.shortlist.len());
for (a, b) in output1.shortlist.iter().zip(output2.shortlist.iter()) {
assert_eq!(a.vendor_id, b.vendor_id);
}
}
}