datasynth_eval/coherence/
sourcing.rs1use crate::error::EvalResult;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SourcingThresholds {
13 pub min_rfx_completion: f64,
15 pub min_bid_receipt: f64,
17 pub min_ranking_consistency: f64,
19 pub min_evaluation_completion: f64,
21}
22
23impl Default for SourcingThresholds {
24 fn default() -> Self {
25 Self {
26 min_rfx_completion: 0.90,
27 min_bid_receipt: 0.80,
28 min_ranking_consistency: 0.95,
29 min_evaluation_completion: 0.85,
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct SourcingProjectData {
37 pub project_id: String,
39 pub has_rfx: bool,
41 pub has_bids: bool,
43 pub has_evaluation: bool,
45 pub has_contract: bool,
47}
48
49#[derive(Debug, Clone)]
51pub struct BidEvaluationData {
52 pub evaluation_id: String,
54 pub criteria_weights: Vec<f64>,
56 pub bid_scores: Vec<f64>,
58 pub bid_rankings: Vec<u32>,
60 pub recommended_vendor_idx: Option<usize>,
62}
63
64#[derive(Debug, Clone)]
66pub struct SpendAnalysisData {
67 pub vendor_spends: Vec<f64>,
69}
70
71#[derive(Debug, Clone)]
73pub struct ScorecardCoverageData {
74 pub total_active_vendors: usize,
76 pub vendors_with_scorecards: usize,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct SourcingEvaluation {
83 pub rfx_completion_rate: f64,
85 pub bid_receipt_rate: f64,
87 pub evaluation_completion_rate: f64,
89 pub contract_award_rate: f64,
91 pub criteria_weight_compliance: f64,
93 pub ranking_consistency: f64,
95 pub recommendation_match_rate: f64,
97 pub spend_hhi: f64,
99 pub scorecard_coverage: f64,
101 pub total_projects: usize,
103 pub passes: bool,
105 pub issues: Vec<String>,
107}
108
109pub struct SourcingEvaluator {
111 thresholds: SourcingThresholds,
112}
113
114impl SourcingEvaluator {
115 pub fn new() -> Self {
117 Self {
118 thresholds: SourcingThresholds::default(),
119 }
120 }
121
122 pub fn with_thresholds(thresholds: SourcingThresholds) -> Self {
124 Self { thresholds }
125 }
126
127 pub fn evaluate(
129 &self,
130 projects: &[SourcingProjectData],
131 evaluations: &[BidEvaluationData],
132 spend: &Option<SpendAnalysisData>,
133 scorecard: &Option<ScorecardCoverageData>,
134 ) -> EvalResult<SourcingEvaluation> {
135 let mut issues = Vec::new();
136 let total_projects = projects.len();
137
138 let rfx_count = projects.iter().filter(|p| p.has_rfx).count();
140 let bid_count = projects.iter().filter(|p| p.has_bids).count();
141 let eval_count = projects.iter().filter(|p| p.has_evaluation).count();
142 let contract_count = projects.iter().filter(|p| p.has_contract).count();
143
144 let rfx_completion_rate = if total_projects > 0 {
145 rfx_count as f64 / total_projects as f64
146 } else {
147 1.0
148 };
149 let bid_receipt_rate = if rfx_count > 0 {
150 bid_count as f64 / rfx_count as f64
151 } else {
152 1.0
153 };
154 let evaluation_completion_rate = if bid_count > 0 {
155 eval_count as f64 / bid_count as f64
156 } else {
157 1.0
158 };
159 let contract_award_rate = if eval_count > 0 {
160 contract_count as f64 / eval_count as f64
161 } else {
162 1.0
163 };
164
165 let weight_ok = evaluations
167 .iter()
168 .filter(|e| {
169 if e.criteria_weights.is_empty() {
170 return true;
171 }
172 let sum: f64 = e.criteria_weights.iter().sum();
173 (sum - 1.0).abs() <= 0.01
174 })
175 .count();
176 let criteria_weight_compliance = if evaluations.is_empty() {
177 1.0
178 } else {
179 weight_ok as f64 / evaluations.len() as f64
180 };
181
182 let ranking_ok = evaluations
184 .iter()
185 .filter(|e| {
186 if e.bid_scores.len() != e.bid_rankings.len() || e.bid_scores.is_empty() {
187 return true;
188 }
189 let mut pairs: Vec<(f64, u32)> = e
191 .bid_scores
192 .iter()
193 .zip(e.bid_rankings.iter())
194 .map(|(&s, &r)| (s, r))
195 .collect();
196 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
197 pairs.windows(2).all(|w| w[0].1 <= w[1].1)
199 })
200 .count();
201 let ranking_consistency = if evaluations.is_empty() {
202 1.0
203 } else {
204 ranking_ok as f64 / evaluations.len() as f64
205 };
206
207 let rec_ok = evaluations
209 .iter()
210 .filter(|e| {
211 if let Some(rec_idx) = e.recommended_vendor_idx {
212 if rec_idx < e.bid_rankings.len() {
213 return e.bid_rankings[rec_idx] == 1;
214 }
215 }
216 true })
218 .count();
219 let recommendation_match_rate = if evaluations.is_empty() {
220 1.0
221 } else {
222 rec_ok as f64 / evaluations.len() as f64
223 };
224
225 let spend_hhi = if let Some(ref sp) = spend {
227 let total_spend: f64 = sp.vendor_spends.iter().sum();
228 if total_spend > 0.0 {
229 sp.vendor_spends
230 .iter()
231 .map(|s| (s / total_spend).powi(2))
232 .sum::<f64>()
233 } else {
234 0.0
235 }
236 } else {
237 0.0
238 };
239
240 let scorecard_coverage = if let Some(ref sc) = scorecard {
242 if sc.total_active_vendors > 0 {
243 sc.vendors_with_scorecards as f64 / sc.total_active_vendors as f64
244 } else {
245 1.0
246 }
247 } else {
248 1.0
249 };
250
251 if rfx_completion_rate < self.thresholds.min_rfx_completion {
253 issues.push(format!(
254 "RFx completion rate {:.3} < {:.3}",
255 rfx_completion_rate, self.thresholds.min_rfx_completion
256 ));
257 }
258 if bid_receipt_rate < self.thresholds.min_bid_receipt {
259 issues.push(format!(
260 "Bid receipt rate {:.3} < {:.3}",
261 bid_receipt_rate, self.thresholds.min_bid_receipt
262 ));
263 }
264 if ranking_consistency < self.thresholds.min_ranking_consistency {
265 issues.push(format!(
266 "Ranking consistency {:.3} < {:.3}",
267 ranking_consistency, self.thresholds.min_ranking_consistency
268 ));
269 }
270
271 let passes = issues.is_empty();
272
273 Ok(SourcingEvaluation {
274 rfx_completion_rate,
275 bid_receipt_rate,
276 evaluation_completion_rate,
277 contract_award_rate,
278 criteria_weight_compliance,
279 ranking_consistency,
280 recommendation_match_rate,
281 spend_hhi,
282 scorecard_coverage,
283 total_projects,
284 passes,
285 issues,
286 })
287 }
288}
289
290impl Default for SourcingEvaluator {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296#[cfg(test)]
297#[allow(clippy::unwrap_used)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_valid_sourcing_chain() {
303 let evaluator = SourcingEvaluator::new();
304 let projects = vec![SourcingProjectData {
305 project_id: "SP001".to_string(),
306 has_rfx: true,
307 has_bids: true,
308 has_evaluation: true,
309 has_contract: true,
310 }];
311 let evals = vec![BidEvaluationData {
312 evaluation_id: "EV001".to_string(),
313 criteria_weights: vec![0.4, 0.3, 0.3],
314 bid_scores: vec![90.0, 80.0, 70.0],
315 bid_rankings: vec![1, 2, 3],
316 recommended_vendor_idx: Some(0),
317 }];
318
319 let result = evaluator.evaluate(&projects, &evals, &None, &None).unwrap();
320 assert!(result.passes);
321 assert_eq!(result.rfx_completion_rate, 1.0);
322 assert_eq!(result.ranking_consistency, 1.0);
323 }
324
325 #[test]
326 fn test_inconsistent_rankings() {
327 let evaluator = SourcingEvaluator::new();
328 let evals = vec![BidEvaluationData {
329 evaluation_id: "EV001".to_string(),
330 criteria_weights: vec![0.5, 0.5],
331 bid_scores: vec![90.0, 80.0],
332 bid_rankings: vec![2, 1], recommended_vendor_idx: None,
334 }];
335
336 let result = evaluator.evaluate(&[], &evals, &None, &None).unwrap();
337 assert_eq!(result.ranking_consistency, 0.0);
338 }
339
340 #[test]
341 fn test_empty_data() {
342 let evaluator = SourcingEvaluator::new();
343 let result = evaluator.evaluate(&[], &[], &None, &None).unwrap();
344 assert!(result.passes);
345 }
346
347 #[test]
348 fn test_spend_hhi() {
349 let evaluator = SourcingEvaluator::new();
350 let spend = Some(SpendAnalysisData {
351 vendor_spends: vec![50.0, 50.0],
352 });
353 let result = evaluator.evaluate(&[], &[], &spend, &None).unwrap();
354 assert!((result.spend_hhi - 0.5).abs() < 0.001); }
356}