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)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_valid_esg_data() {
285        let evaluator = EsgEvaluator::new();
286        let water = vec![WaterUsageData {
287            record_id: "W001".to_string(),
288            withdrawal_m3: 1000.0,
289            discharge_m3: 700.0,
290            consumption_m3: 300.0,
291        }];
292        let safety = vec![SafetyMetricData {
293            metric_id: "S001".to_string(),
294            total_hours_worked: 1_000_000.0,
295            recordable_incidents: 5,
296            trir: 1.0, // 5 * 200_000 / 1_000_000
297            lost_time_incidents: 2,
298            ltir: 0.4, // 2 * 200_000 / 1_000_000
299        }];
300        let governance = vec![GovernanceData {
301            metric_id: "G001".to_string(),
302            board_size: 10,
303            independent_directors: 7,
304            independence_ratio: 0.7,
305        }];
306        let suppliers = vec![SupplierEsgData {
307            assessment_id: "ESG001".to_string(),
308            environmental_score: 80.0,
309            social_score: 70.0,
310            governance_score: 90.0,
311            overall_score: 80.0,
312        }];
313
314        let result = evaluator
315            .evaluate(&water, &safety, &governance, &suppliers)
316            .unwrap();
317        assert!(result.passes);
318        assert_eq!(result.total_water_records, 1);
319        assert_eq!(result.total_safety_records, 1);
320    }
321
322    #[test]
323    fn test_wrong_water_consumption() {
324        let evaluator = EsgEvaluator::new();
325        let water = vec![WaterUsageData {
326            record_id: "W001".to_string(),
327            withdrawal_m3: 1000.0,
328            discharge_m3: 700.0,
329            consumption_m3: 500.0, // Wrong: should be 300
330        }];
331
332        let result = evaluator.evaluate(&water, &[], &[], &[]).unwrap();
333        assert!(!result.passes);
334        assert!(result.issues[0].contains("Water consumption"));
335    }
336
337    #[test]
338    fn test_wrong_trir() {
339        let evaluator = EsgEvaluator::new();
340        let safety = vec![SafetyMetricData {
341            metric_id: "S001".to_string(),
342            total_hours_worked: 1_000_000.0,
343            recordable_incidents: 5,
344            trir: 5.0, // Wrong: should be 1.0
345            lost_time_incidents: 2,
346            ltir: 0.4,
347        }];
348
349        let result = evaluator.evaluate(&[], &safety, &[], &[]).unwrap();
350        assert!(!result.passes);
351        assert!(result.issues.iter().any(|i| i.contains("TRIR")));
352    }
353
354    #[test]
355    fn test_wrong_supplier_scoring() {
356        let evaluator = EsgEvaluator::new();
357        let suppliers = vec![SupplierEsgData {
358            assessment_id: "ESG001".to_string(),
359            environmental_score: 80.0,
360            social_score: 70.0,
361            governance_score: 90.0,
362            overall_score: 90.0, // Wrong: should be 80.0
363        }];
364
365        let result = evaluator.evaluate(&[], &[], &[], &suppliers).unwrap();
366        assert!(!result.passes);
367        assert!(result.issues[0].contains("Supplier ESG"));
368    }
369
370    #[test]
371    fn test_wrong_ltir() {
372        let evaluator = EsgEvaluator::new();
373        let safety = vec![SafetyMetricData {
374            metric_id: "S001".to_string(),
375            total_hours_worked: 1_000_000.0,
376            recordable_incidents: 5,
377            trir: 1.0, // Correct
378            lost_time_incidents: 2,
379            ltir: 2.0, // Wrong: should be 0.4
380        }];
381
382        let result = evaluator.evaluate(&[], &safety, &[], &[]).unwrap();
383        assert!(!result.passes);
384        assert!(result.issues.iter().any(|i| i.contains("LTIR")));
385    }
386
387    #[test]
388    fn test_wrong_governance_ratio() {
389        let evaluator = EsgEvaluator::new();
390        let governance = vec![GovernanceData {
391            metric_id: "G001".to_string(),
392            board_size: 10,
393            independent_directors: 7,
394            independence_ratio: 0.5, // Wrong: should be 0.7
395        }];
396
397        let result = evaluator.evaluate(&[], &[], &governance, &[]).unwrap();
398        assert!(!result.passes);
399        assert!(result.issues.iter().any(|i| i.contains("Governance ratio")));
400    }
401
402    #[test]
403    fn test_empty_data() {
404        let evaluator = EsgEvaluator::new();
405        let result = evaluator.evaluate(&[], &[], &[], &[]).unwrap();
406        assert!(result.passes);
407    }
408}