Skip to main content

datasynth_eval/coherence/
sourcing.rs

1//! Source-to-Contract (S2C) evaluator.
2//!
3//! Validates sourcing chain completeness including project-to-contract flow,
4//! bid scoring consistency, evaluation-recommendation matching,
5//! spend concentration, and scorecard coverage.
6
7use crate::error::EvalResult;
8use serde::{Deserialize, Serialize};
9
10/// Thresholds for S2C evaluation.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SourcingThresholds {
13    /// Minimum RFx completion rate (projects that reach RFx stage).
14    pub min_rfx_completion: f64,
15    /// Minimum bid receipt rate (RFx events that receive bids).
16    pub min_bid_receipt: f64,
17    /// Minimum ranking consistency (rankings match scores).
18    pub min_ranking_consistency: f64,
19    /// Minimum evaluation completion rate.
20    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/// Sourcing project data for chain validation.
35#[derive(Debug, Clone)]
36pub struct SourcingProjectData {
37    /// Project identifier.
38    pub project_id: String,
39    /// Whether an RFx event was created.
40    pub has_rfx: bool,
41    /// Whether bids were received.
42    pub has_bids: bool,
43    /// Whether evaluation was completed.
44    pub has_evaluation: bool,
45    /// Whether a contract was awarded.
46    pub has_contract: bool,
47}
48
49/// Bid evaluation data for scoring validation.
50#[derive(Debug, Clone)]
51pub struct BidEvaluationData {
52    /// Evaluation identifier.
53    pub evaluation_id: String,
54    /// Criteria weights (should sum to 1.0).
55    pub criteria_weights: Vec<f64>,
56    /// Bid scores (one per bid, computed from weighted criteria).
57    pub bid_scores: Vec<f64>,
58    /// Bid rankings (1 = best).
59    pub bid_rankings: Vec<u32>,
60    /// Recommended vendor index (into bid arrays).
61    pub recommended_vendor_idx: Option<usize>,
62}
63
64/// Spend analysis data.
65#[derive(Debug, Clone)]
66pub struct SpendAnalysisData {
67    /// Vendor spend amounts.
68    pub vendor_spends: Vec<f64>,
69}
70
71/// Scorecard coverage data.
72#[derive(Debug, Clone)]
73pub struct ScorecardCoverageData {
74    /// Total active vendors.
75    pub total_active_vendors: usize,
76    /// Vendors with scorecards.
77    pub vendors_with_scorecards: usize,
78}
79
80/// Results of S2C evaluation.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct SourcingEvaluation {
83    /// RFx completion rate: projects that reach RFx stage.
84    pub rfx_completion_rate: f64,
85    /// Bid receipt rate: RFx events that receive bids.
86    pub bid_receipt_rate: f64,
87    /// Evaluation completion rate.
88    pub evaluation_completion_rate: f64,
89    /// Contract award rate.
90    pub contract_award_rate: f64,
91    /// Criteria weight compliance: fraction of evaluations with weights summing to 1.0.
92    pub criteria_weight_compliance: f64,
93    /// Ranking consistency: fraction where rankings match score ordering.
94    pub ranking_consistency: f64,
95    /// Recommendation match rate: recommended vendor = top-ranked bid.
96    pub recommendation_match_rate: f64,
97    /// HHI (Herfindahl-Hirschman Index) for spend concentration.
98    pub spend_hhi: f64,
99    /// Scorecard coverage: fraction of active vendors with scorecards.
100    pub scorecard_coverage: f64,
101    /// Total projects evaluated.
102    pub total_projects: usize,
103    /// Overall pass/fail.
104    pub passes: bool,
105    /// Issues found.
106    pub issues: Vec<String>,
107}
108
109/// Evaluator for S2C chain coherence.
110pub struct SourcingEvaluator {
111    thresholds: SourcingThresholds,
112}
113
114impl SourcingEvaluator {
115    /// Create a new evaluator with default thresholds.
116    pub fn new() -> Self {
117        Self {
118            thresholds: SourcingThresholds::default(),
119        }
120    }
121
122    /// Create with custom thresholds.
123    pub fn with_thresholds(thresholds: SourcingThresholds) -> Self {
124        Self { thresholds }
125    }
126
127    /// Evaluate sourcing data.
128    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        // 1. Chain completion rates
139        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        // 2. Criteria weight compliance: weights sum to 1.0 (±0.01)
166        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        // 3. Ranking consistency: rankings should match score ordering
183        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                // Create pairs (score, ranking) and sort by score descending
190                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                // Rankings should be ascending
198                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        // 4. Recommendation match: recommended = top-ranked (rank 1)
208        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 // No recommendation = not checked
217            })
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        // 5. Spend HHI
226        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        // 6. Scorecard coverage
241        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        // Check thresholds
252        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], // Wrong: highest score should be rank 1
333            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); // 0.25 + 0.25 = 0.5
355    }
356}