Skip to main content

datasynth_eval/coherence/
esg.rs

1//! ESG (Environmental, Social, Governance) coherence evaluator.
2//!
3//! Validates water consumption formulas, safety incident rates (TRIR/LTIR),
4//! board governance ratios, and supplier ESG scoring consistency.
5
6use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9/// Thresholds for ESG evaluation.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct EsgThresholds {
12    /// Minimum accuracy for metric calculations.
13    pub min_metric_accuracy: f64,
14    /// Minimum accuracy for safety rate calculations.
15    pub min_safety_rate_accuracy: f64,
16    /// Tolerance for metric comparisons.
17    pub metric_tolerance: f64,
18}
19
20impl Default for EsgThresholds {
21    fn default() -> Self {
22        Self {
23            min_metric_accuracy: 0.99,
24            min_safety_rate_accuracy: 0.999,
25            metric_tolerance: 0.01,
26        }
27    }
28}
29
30/// Water usage data for consumption validation.
31#[derive(Debug, Clone)]
32pub struct WaterUsageData {
33    /// Record identifier.
34    pub record_id: String,
35    /// Water withdrawal in cubic meters.
36    pub withdrawal_m3: f64,
37    /// Water discharge in cubic meters.
38    pub discharge_m3: f64,
39    /// Water consumption (withdrawal - discharge).
40    pub consumption_m3: f64,
41}
42
43/// Safety metric data for incident rate validation.
44#[derive(Debug, Clone)]
45pub struct SafetyMetricData {
46    /// Metric identifier.
47    pub metric_id: String,
48    /// Total hours worked.
49    pub total_hours_worked: f64,
50    /// Number of recordable incidents.
51    pub recordable_incidents: u32,
52    /// Total Recordable Incident Rate.
53    pub trir: f64,
54    /// Number of lost time incidents.
55    pub lost_time_incidents: u32,
56    /// Lost Time Incident Rate.
57    pub ltir: f64,
58}
59
60/// Governance data for board ratio validation.
61#[derive(Debug, Clone)]
62pub struct GovernanceData {
63    /// Metric identifier.
64    pub metric_id: String,
65    /// Total board size.
66    pub board_size: u32,
67    /// Number of independent directors.
68    pub independent_directors: u32,
69    /// Reported independence ratio.
70    pub independence_ratio: f64,
71}
72
73/// Supplier ESG scoring data.
74#[derive(Debug, Clone)]
75pub struct SupplierEsgData {
76    /// Assessment identifier.
77    pub assessment_id: String,
78    /// Environmental score (0-100).
79    pub environmental_score: f64,
80    /// Social score (0-100).
81    pub social_score: f64,
82    /// Governance score (0-100).
83    pub governance_score: f64,
84    /// Overall score (should be average of E, S, G).
85    pub overall_score: f64,
86}
87
88/// Results of ESG coherence evaluation.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct EsgEvaluation {
91    /// Fraction of water records with correct consumption.
92    pub water_accuracy: f64,
93    /// Fraction of safety records with correct TRIR.
94    pub trir_accuracy: f64,
95    /// Fraction of safety records with correct LTIR.
96    pub ltir_accuracy: f64,
97    /// Fraction of governance records with correct independence ratio.
98    pub governance_accuracy: f64,
99    /// Fraction of supplier assessments with correct overall score.
100    pub supplier_scoring_accuracy: f64,
101    /// Total water records evaluated.
102    pub total_water_records: usize,
103    /// Total safety records evaluated.
104    pub total_safety_records: usize,
105    /// Total governance records evaluated.
106    pub total_governance_records: usize,
107    /// Total supplier assessments evaluated.
108    pub total_supplier_assessments: usize,
109    /// Overall pass/fail.
110    pub passes: bool,
111    /// Issues found.
112    pub issues: Vec<String>,
113}
114
115/// Evaluator for ESG coherence.
116pub struct EsgEvaluator {
117    thresholds: EsgThresholds,
118}
119
120impl EsgEvaluator {
121    /// Create a new evaluator with default thresholds.
122    pub fn new() -> Self {
123        Self {
124            thresholds: EsgThresholds::default(),
125        }
126    }
127
128    /// Create with custom thresholds.
129    pub fn with_thresholds(thresholds: EsgThresholds) -> Self {
130        Self { thresholds }
131    }
132
133    /// Evaluate ESG data coherence.
134    pub fn evaluate(
135        &self,
136        water: &[WaterUsageData],
137        safety: &[SafetyMetricData],
138        governance: &[GovernanceData],
139        suppliers: &[SupplierEsgData],
140    ) -> EvalResult<EsgEvaluation> {
141        let mut issues = Vec::new();
142        let tolerance = self.thresholds.metric_tolerance;
143
144        // 1. Water: consumption ≈ withdrawal - discharge
145        let water_ok = water
146            .iter()
147            .filter(|w| {
148                let expected = w.withdrawal_m3 - w.discharge_m3;
149                (w.consumption_m3 - expected).abs() <= tolerance * w.withdrawal_m3.abs().max(1.0)
150            })
151            .count();
152        let water_accuracy = if water.is_empty() {
153            1.0
154        } else {
155            water_ok as f64 / water.len() as f64
156        };
157
158        // 2. TRIR: recordable_incidents * 200_000 / total_hours_worked
159        let trir_ok = safety
160            .iter()
161            .filter(|s| {
162                if s.total_hours_worked <= 0.0 {
163                    return true;
164                }
165                let expected = s.recordable_incidents as f64 * 200_000.0 / s.total_hours_worked;
166                (s.trir - expected).abs() <= tolerance * expected.abs().max(0.001)
167            })
168            .count();
169        let trir_accuracy = if safety.is_empty() {
170            1.0
171        } else {
172            trir_ok as f64 / safety.len() as f64
173        };
174
175        // 3. LTIR: lost_time_incidents * 200_000 / total_hours_worked
176        let ltir_ok = safety
177            .iter()
178            .filter(|s| {
179                if s.total_hours_worked <= 0.0 {
180                    return true;
181                }
182                let expected = s.lost_time_incidents as f64 * 200_000.0 / s.total_hours_worked;
183                (s.ltir - expected).abs() <= tolerance * expected.abs().max(0.001)
184            })
185            .count();
186        let ltir_accuracy = if safety.is_empty() {
187            1.0
188        } else {
189            ltir_ok as f64 / safety.len() as f64
190        };
191
192        // 4. Governance: independence_ratio ≈ independent_directors / board_size
193        let gov_ok = governance
194            .iter()
195            .filter(|g| {
196                if g.board_size == 0 {
197                    return true;
198                }
199                let expected = g.independent_directors as f64 / g.board_size as f64;
200                (g.independence_ratio - expected).abs() <= tolerance
201            })
202            .count();
203        let governance_accuracy = if governance.is_empty() {
204            1.0
205        } else {
206            gov_ok as f64 / governance.len() as f64
207        };
208
209        // 5. Supplier scoring: overall ≈ (E + S + G) / 3
210        let supplier_ok = suppliers
211            .iter()
212            .filter(|s| {
213                let expected = (s.environmental_score + s.social_score + s.governance_score) / 3.0;
214                (s.overall_score - expected).abs() <= tolerance * expected.abs().max(1.0)
215            })
216            .count();
217        let supplier_scoring_accuracy = if suppliers.is_empty() {
218            1.0
219        } else {
220            supplier_ok as f64 / suppliers.len() as f64
221        };
222
223        // Check thresholds
224        if water_accuracy < self.thresholds.min_metric_accuracy {
225            issues.push(format!(
226                "Water consumption accuracy {:.4} < {:.4}",
227                water_accuracy, self.thresholds.min_metric_accuracy
228            ));
229        }
230        if trir_accuracy < self.thresholds.min_safety_rate_accuracy {
231            issues.push(format!(
232                "TRIR accuracy {:.4} < {:.4}",
233                trir_accuracy, self.thresholds.min_safety_rate_accuracy
234            ));
235        }
236        if ltir_accuracy < self.thresholds.min_safety_rate_accuracy {
237            issues.push(format!(
238                "LTIR accuracy {:.4} < {:.4}",
239                ltir_accuracy, self.thresholds.min_safety_rate_accuracy
240            ));
241        }
242        if governance_accuracy < self.thresholds.min_metric_accuracy {
243            issues.push(format!(
244                "Governance ratio accuracy {:.4} < {:.4}",
245                governance_accuracy, self.thresholds.min_metric_accuracy
246            ));
247        }
248        if supplier_scoring_accuracy < self.thresholds.min_metric_accuracy {
249            issues.push(format!(
250                "Supplier ESG scoring accuracy {:.4} < {:.4}",
251                supplier_scoring_accuracy, self.thresholds.min_metric_accuracy
252            ));
253        }
254
255        let passes = issues.is_empty();
256
257        Ok(EsgEvaluation {
258            water_accuracy,
259            trir_accuracy,
260            ltir_accuracy,
261            governance_accuracy,
262            supplier_scoring_accuracy,
263            total_water_records: water.len(),
264            total_safety_records: safety.len(),
265            total_governance_records: governance.len(),
266            total_supplier_assessments: suppliers.len(),
267            passes,
268            issues,
269        })
270    }
271}
272
273impl Default for EsgEvaluator {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279#[cfg(test)]
280#[allow(clippy::unwrap_used)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_valid_esg_data() {
286        let evaluator = EsgEvaluator::new();
287        let water = vec![WaterUsageData {
288            record_id: "W001".to_string(),
289            withdrawal_m3: 1000.0,
290            discharge_m3: 700.0,
291            consumption_m3: 300.0,
292        }];
293        let safety = vec![SafetyMetricData {
294            metric_id: "S001".to_string(),
295            total_hours_worked: 1_000_000.0,
296            recordable_incidents: 5,
297            trir: 1.0, // 5 * 200_000 / 1_000_000
298            lost_time_incidents: 2,
299            ltir: 0.4, // 2 * 200_000 / 1_000_000
300        }];
301        let governance = vec![GovernanceData {
302            metric_id: "G001".to_string(),
303            board_size: 10,
304            independent_directors: 7,
305            independence_ratio: 0.7,
306        }];
307        let suppliers = vec![SupplierEsgData {
308            assessment_id: "ESG001".to_string(),
309            environmental_score: 80.0,
310            social_score: 70.0,
311            governance_score: 90.0,
312            overall_score: 80.0,
313        }];
314
315        let result = evaluator
316            .evaluate(&water, &safety, &governance, &suppliers)
317            .unwrap();
318        assert!(result.passes);
319        assert_eq!(result.total_water_records, 1);
320        assert_eq!(result.total_safety_records, 1);
321    }
322
323    #[test]
324    fn test_wrong_water_consumption() {
325        let evaluator = EsgEvaluator::new();
326        let water = vec![WaterUsageData {
327            record_id: "W001".to_string(),
328            withdrawal_m3: 1000.0,
329            discharge_m3: 700.0,
330            consumption_m3: 500.0, // Wrong: should be 300
331        }];
332
333        let result = evaluator.evaluate(&water, &[], &[], &[]).unwrap();
334        assert!(!result.passes);
335        assert!(result.issues[0].contains("Water consumption"));
336    }
337
338    #[test]
339    fn test_wrong_trir() {
340        let evaluator = EsgEvaluator::new();
341        let safety = vec![SafetyMetricData {
342            metric_id: "S001".to_string(),
343            total_hours_worked: 1_000_000.0,
344            recordable_incidents: 5,
345            trir: 5.0, // Wrong: should be 1.0
346            lost_time_incidents: 2,
347            ltir: 0.4,
348        }];
349
350        let result = evaluator.evaluate(&[], &safety, &[], &[]).unwrap();
351        assert!(!result.passes);
352        assert!(result.issues.iter().any(|i| i.contains("TRIR")));
353    }
354
355    #[test]
356    fn test_wrong_supplier_scoring() {
357        let evaluator = EsgEvaluator::new();
358        let suppliers = vec![SupplierEsgData {
359            assessment_id: "ESG001".to_string(),
360            environmental_score: 80.0,
361            social_score: 70.0,
362            governance_score: 90.0,
363            overall_score: 90.0, // Wrong: should be 80.0
364        }];
365
366        let result = evaluator.evaluate(&[], &[], &[], &suppliers).unwrap();
367        assert!(!result.passes);
368        assert!(result.issues[0].contains("Supplier ESG"));
369    }
370
371    #[test]
372    fn test_wrong_ltir() {
373        let evaluator = EsgEvaluator::new();
374        let safety = vec![SafetyMetricData {
375            metric_id: "S001".to_string(),
376            total_hours_worked: 1_000_000.0,
377            recordable_incidents: 5,
378            trir: 1.0, // Correct
379            lost_time_incidents: 2,
380            ltir: 2.0, // Wrong: should be 0.4
381        }];
382
383        let result = evaluator.evaluate(&[], &safety, &[], &[]).unwrap();
384        assert!(!result.passes);
385        assert!(result.issues.iter().any(|i| i.contains("LTIR")));
386    }
387
388    #[test]
389    fn test_wrong_governance_ratio() {
390        let evaluator = EsgEvaluator::new();
391        let governance = vec![GovernanceData {
392            metric_id: "G001".to_string(),
393            board_size: 10,
394            independent_directors: 7,
395            independence_ratio: 0.5, // Wrong: should be 0.7
396        }];
397
398        let result = evaluator.evaluate(&[], &[], &governance, &[]).unwrap();
399        assert!(!result.passes);
400        assert!(result.issues.iter().any(|i| i.contains("Governance ratio")));
401    }
402
403    #[test]
404    fn test_empty_data() {
405        let evaluator = EsgEvaluator::new();
406        let result = evaluator.evaluate(&[], &[], &[], &[]).unwrap();
407        assert!(result.passes);
408    }
409}