Skip to main content

datasynth_generators/esg/
disclosure_generator.rs

1//! ESG disclosure and materiality generator — maps calculated metrics
2//! to framework-specific standard IDs (GRI, ESRS, SASB, TCFD, ISSB)
3//! and performs double-materiality assessments.
4use chrono::NaiveDate;
5use datasynth_config::schema::{ClimateScenarioConfig, EsgReportingConfig};
6use datasynth_core::models::{
7    AssuranceLevel, ClimateScenario, EsgDisclosure, EsgFramework, MaterialityAssessment,
8    ScenarioType, TimeHorizon,
9};
10use datasynth_core::utils::seeded_rng;
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14use rust_decimal_macros::dec;
15
16/// Standard ESG topics with framework-specific disclosure IDs.
17struct DisclosureTopic {
18    topic: &'static str,
19    gri_id: &'static str,
20    esrs_id: &'static str,
21}
22
23const DISCLOSURE_TOPICS: &[DisclosureTopic] = &[
24    DisclosureTopic {
25        topic: "GHG Emissions - Scope 1",
26        gri_id: "GRI 305-1",
27        esrs_id: "ESRS E1-6",
28    },
29    DisclosureTopic {
30        topic: "GHG Emissions - Scope 2",
31        gri_id: "GRI 305-2",
32        esrs_id: "ESRS E1-6",
33    },
34    DisclosureTopic {
35        topic: "GHG Emissions - Scope 3",
36        gri_id: "GRI 305-3",
37        esrs_id: "ESRS E1-6",
38    },
39    DisclosureTopic {
40        topic: "Energy Consumption",
41        gri_id: "GRI 302-1",
42        esrs_id: "ESRS E1-5",
43    },
44    DisclosureTopic {
45        topic: "Water Withdrawal",
46        gri_id: "GRI 303-3",
47        esrs_id: "ESRS E3-4",
48    },
49    DisclosureTopic {
50        topic: "Waste Generation",
51        gri_id: "GRI 306-3",
52        esrs_id: "ESRS E5-5",
53    },
54    DisclosureTopic {
55        topic: "Workforce Diversity",
56        gri_id: "GRI 405-1",
57        esrs_id: "ESRS S1-12",
58    },
59    DisclosureTopic {
60        topic: "Pay Equity",
61        gri_id: "GRI 405-2",
62        esrs_id: "ESRS S1-16",
63    },
64    DisclosureTopic {
65        topic: "Occupational Safety",
66        gri_id: "GRI 403-9",
67        esrs_id: "ESRS S1-14",
68    },
69    DisclosureTopic {
70        topic: "Board Composition",
71        gri_id: "GRI 405-1",
72        esrs_id: "ESRS G1-1",
73    },
74    DisclosureTopic {
75        topic: "Anti-Corruption",
76        gri_id: "GRI 205-3",
77        esrs_id: "ESRS G1-4",
78    },
79    DisclosureTopic {
80        topic: "Supply Chain Assessment",
81        gri_id: "GRI 308-1",
82        esrs_id: "ESRS S2-1",
83    },
84];
85
86/// Generates [`EsgDisclosure`] and [`MaterialityAssessment`] records.
87pub struct DisclosureGenerator {
88    rng: ChaCha8Rng,
89    config: EsgReportingConfig,
90    climate_config: ClimateScenarioConfig,
91    counter: u64,
92}
93
94impl DisclosureGenerator {
95    /// Create a new disclosure generator.
96    pub fn new(
97        seed: u64,
98        config: EsgReportingConfig,
99        climate_config: ClimateScenarioConfig,
100    ) -> Self {
101        Self {
102            rng: seeded_rng(seed, 0),
103            config,
104            climate_config,
105            counter: 0,
106        }
107    }
108
109    // ----- Materiality Assessment -----
110
111    /// Perform double-materiality assessment for all standard topics.
112    pub fn generate_materiality(
113        &mut self,
114        entity_id: &str,
115        period: NaiveDate,
116    ) -> Vec<MaterialityAssessment> {
117        if !self.config.materiality_assessment {
118            return Vec::new();
119        }
120
121        DISCLOSURE_TOPICS
122            .iter()
123            .map(|dt| {
124                self.counter += 1;
125
126                let impact_score = self.random_score();
127                let financial_score = self.random_score();
128                let combined = ((impact_score + financial_score) / dec!(2)).round_dp(2);
129
130                let impact_threshold =
131                    Decimal::from_f64_retain(self.config.impact_threshold).unwrap_or(dec!(0.6));
132                let financial_threshold =
133                    Decimal::from_f64_retain(self.config.financial_threshold).unwrap_or(dec!(0.6));
134
135                let is_material =
136                    impact_score >= impact_threshold || financial_score >= financial_threshold;
137
138                MaterialityAssessment {
139                    id: format!("MA-{:06}", self.counter),
140                    entity_id: entity_id.to_string(),
141                    period,
142                    topic: dt.topic.to_string(),
143                    impact_score,
144                    financial_score,
145                    combined_score: combined,
146                    is_material,
147                }
148            })
149            .collect()
150    }
151
152    // ----- Disclosures -----
153
154    /// Generate disclosures for material topics under configured frameworks.
155    pub fn generate_disclosures(
156        &mut self,
157        entity_id: &str,
158        materiality: &[MaterialityAssessment],
159        start_date: NaiveDate,
160        end_date: NaiveDate,
161    ) -> Vec<EsgDisclosure> {
162        if !self.config.enabled {
163            return Vec::new();
164        }
165
166        let material_topics: Vec<&str> = materiality
167            .iter()
168            .filter(|m| m.is_material)
169            .map(|m| m.topic.as_str())
170            .collect();
171
172        let frameworks = self.parse_frameworks();
173        let mut disclosures = Vec::new();
174
175        for framework in &frameworks {
176            for dt in DISCLOSURE_TOPICS {
177                // Only disclose material topics
178                if !material_topics.contains(&dt.topic) {
179                    continue;
180                }
181
182                self.counter += 1;
183
184                let standard_id = match framework {
185                    EsgFramework::Gri => dt.gri_id,
186                    EsgFramework::Esrs => dt.esrs_id,
187                    _ => dt.gri_id, // Fallback
188                };
189
190                let (metric_value, metric_unit) = self.metric_for_topic(dt.topic);
191
192                let assurance_level = if self.rng.random::<f64>() < 0.30 {
193                    AssuranceLevel::Reasonable
194                } else if self.rng.random::<f64>() < 0.60 {
195                    AssuranceLevel::Limited
196                } else {
197                    AssuranceLevel::None
198                };
199
200                disclosures.push(EsgDisclosure {
201                    id: format!("ED-{:06}", self.counter),
202                    entity_id: entity_id.to_string(),
203                    reporting_period_start: start_date,
204                    reporting_period_end: end_date,
205                    framework: *framework,
206                    assurance_level,
207                    disclosure_topic: format!("{} ({})", dt.topic, standard_id),
208                    metric_value,
209                    metric_unit,
210                    is_assured: !matches!(assurance_level, AssuranceLevel::None),
211                });
212            }
213        }
214
215        disclosures
216    }
217
218    // ----- Climate Scenarios -----
219
220    /// Generate climate scenario analysis records.
221    pub fn generate_climate_scenarios(&mut self, entity_id: &str) -> Vec<ClimateScenario> {
222        if !self.climate_config.enabled {
223            return Vec::new();
224        }
225
226        let scenarios = [
227            (
228                ScenarioType::WellBelow2C,
229                "Paris-aligned net zero by 2050",
230                dec!(1.5),
231            ),
232            (
233                ScenarioType::Orderly,
234                "Orderly transition with moderate carbon pricing",
235                dec!(2.0),
236            ),
237            (
238                ScenarioType::Disorderly,
239                "Delayed policy action with abrupt transition",
240                dec!(2.5),
241            ),
242            (
243                ScenarioType::HotHouse,
244                "Business as usual with severe physical risks",
245                dec!(4.0),
246            ),
247        ];
248
249        let horizons = [
250            (TimeHorizon::Short, 5),
251            (TimeHorizon::Medium, 10),
252            (TimeHorizon::Long, 30),
253        ];
254
255        let mut records = Vec::new();
256
257        for (scenario_type, description, temp_rise) in &scenarios {
258            for (horizon, _years) in &horizons {
259                self.counter += 1;
260
261                // Transition risk: higher for orderly/well-below-2C in short-medium term
262                let transition_risk = match (scenario_type, horizon) {
263                    (ScenarioType::WellBelow2C, TimeHorizon::Short) => self.random_impact(0.3, 0.7),
264                    (ScenarioType::WellBelow2C, _) => self.random_impact(0.2, 0.5),
265                    (ScenarioType::Orderly, _) => self.random_impact(0.15, 0.4),
266                    (ScenarioType::Disorderly, TimeHorizon::Medium) => self.random_impact(0.4, 0.8),
267                    (ScenarioType::HotHouse, _) => self.random_impact(0.05, 0.2),
268                    _ => self.random_impact(0.1, 0.5),
269                };
270
271                // Physical risk: higher for hot house, especially long term
272                let physical_risk = match (scenario_type, horizon) {
273                    (ScenarioType::HotHouse, TimeHorizon::Long) => self.random_impact(0.5, 0.9),
274                    (ScenarioType::HotHouse, _) => self.random_impact(0.3, 0.6),
275                    (ScenarioType::Disorderly, TimeHorizon::Long) => self.random_impact(0.2, 0.5),
276                    (ScenarioType::WellBelow2C, _) => self.random_impact(0.05, 0.15),
277                    _ => self.random_impact(0.1, 0.3),
278                };
279
280                // Financial impact = weighted combo
281                let financial = ((transition_risk * dec!(0.6) + physical_risk * dec!(0.4))
282                    * dec!(100))
283                .round_dp(2);
284
285                records.push(ClimateScenario {
286                    id: format!("CS-{:06}", self.counter),
287                    entity_id: entity_id.to_string(),
288                    scenario_type: *scenario_type,
289                    time_horizon: *horizon,
290                    description: description.to_string(),
291                    temperature_rise_c: *temp_rise,
292                    transition_risk_impact: transition_risk,
293                    physical_risk_impact: physical_risk,
294                    financial_impact: financial,
295                });
296            }
297        }
298
299        records
300    }
301
302    fn parse_frameworks(&self) -> Vec<EsgFramework> {
303        self.config
304            .frameworks
305            .iter()
306            .filter_map(|f| match f.to_uppercase().as_str() {
307                "GRI" => Some(EsgFramework::Gri),
308                "ESRS" => Some(EsgFramework::Esrs),
309                "SASB" => Some(EsgFramework::Sasb),
310                "TCFD" => Some(EsgFramework::Tcfd),
311                "ISSB" => Some(EsgFramework::Issb),
312                _ => None,
313            })
314            .collect()
315    }
316
317    fn random_score(&mut self) -> Decimal {
318        let v: f64 = self.rng.random_range(0.2..0.95);
319        Decimal::from_f64_retain(v).unwrap_or(dec!(0.5)).round_dp(2)
320    }
321
322    fn random_impact(&mut self, min: f64, max: f64) -> Decimal {
323        let v: f64 = self.rng.random_range(min..max);
324        Decimal::from_f64_retain(v).unwrap_or(dec!(0.3)).round_dp(4)
325    }
326
327    fn metric_for_topic(&mut self, topic: &str) -> (String, String) {
328        match topic {
329            "GHG Emissions - Scope 1" | "GHG Emissions - Scope 2" | "GHG Emissions - Scope 3" => {
330                let val: f64 = self.rng.random_range(100.0..50000.0);
331                (format!("{val:.1}"), "tonnes CO2e".to_string())
332            }
333            "Energy Consumption" => {
334                let val: f64 = self.rng.random_range(1_000_000.0..50_000_000.0);
335                (format!("{val:.0}"), "kWh".to_string())
336            }
337            "Water Withdrawal" => {
338                let val: f64 = self.rng.random_range(10_000.0..500_000.0);
339                (format!("{val:.0}"), "m3".to_string())
340            }
341            "Waste Generation" => {
342                let val: f64 = self.rng.random_range(100.0..10_000.0);
343                (format!("{val:.1}"), "tonnes".to_string())
344            }
345            "Workforce Diversity" => {
346                let val: f64 = self.rng.random_range(30.0..55.0);
347                (format!("{val:.1}%"), "percent female".to_string())
348            }
349            "Pay Equity" => {
350                let val: f64 = self.rng.random_range(0.85..1.05);
351                (format!("{val:.3}"), "ratio".to_string())
352            }
353            "Occupational Safety" => {
354                let val: f64 = self.rng.random_range(0.5..5.0);
355                (format!("{val:.2}"), "TRIR".to_string())
356            }
357            "Board Composition" => {
358                let val: f64 = self.rng.random_range(0.50..0.80);
359                (
360                    format!("{:.1}%", val * 100.0),
361                    "percent independent".to_string(),
362                )
363            }
364            "Anti-Corruption" => {
365                let val: u32 = self.rng.random_range(0..3);
366                (val.to_string(), "violations".to_string())
367            }
368            "Supply Chain Assessment" => {
369                let val: f64 = self.rng.random_range(60.0..95.0);
370                (format!("{val:.1}%"), "percent assessed".to_string())
371            }
372            _ => ("N/A".to_string(), "N/A".to_string()),
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn d(s: &str) -> NaiveDate {
382        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
383    }
384
385    #[test]
386    fn test_materiality_assessment() {
387        let config = EsgReportingConfig::default();
388        let climate = ClimateScenarioConfig::default();
389        let mut gen = DisclosureGenerator::new(42, config, climate);
390
391        let assessments = gen.generate_materiality("C001", d("2025-01-01"));
392
393        assert_eq!(assessments.len(), DISCLOSURE_TOPICS.len());
394        // Some should be material, some not (with default 0.6 threshold)
395        let material = assessments.iter().filter(|a| a.is_material).count();
396        assert!(
397            material > 0 && material < assessments.len(),
398            "Expected mix of material/non-material, got {}/{}",
399            material,
400            assessments.len()
401        );
402    }
403
404    #[test]
405    fn test_all_material_topics_have_disclosures() {
406        let config = EsgReportingConfig::default();
407        let climate = ClimateScenarioConfig::default();
408        let mut gen = DisclosureGenerator::new(42, config, climate);
409
410        let materiality = gen.generate_materiality("C001", d("2025-01-01"));
411        let disclosures =
412            gen.generate_disclosures("C001", &materiality, d("2025-01-01"), d("2025-12-31"));
413
414        let material_topics: Vec<_> = materiality
415            .iter()
416            .filter(|m| m.is_material)
417            .map(|m| m.topic.as_str())
418            .collect();
419
420        // Each material topic should have at least one disclosure per framework
421        for topic in &material_topics {
422            let has_disclosure = disclosures
423                .iter()
424                .any(|d| d.disclosure_topic.contains(topic));
425            assert!(
426                has_disclosure,
427                "Material topic '{}' should have a disclosure",
428                topic
429            );
430        }
431    }
432
433    #[test]
434    fn test_framework_ids_are_valid() {
435        let config = EsgReportingConfig::default();
436        let climate = ClimateScenarioConfig::default();
437        let mut gen = DisclosureGenerator::new(42, config, climate);
438
439        let materiality = gen.generate_materiality("C001", d("2025-01-01"));
440        let disclosures =
441            gen.generate_disclosures("C001", &materiality, d("2025-01-01"), d("2025-12-31"));
442
443        for d in &disclosures {
444            // Should contain a framework-specific ID like "GRI 305-1" or "ESRS E1-6"
445            assert!(
446                d.disclosure_topic.contains("GRI") || d.disclosure_topic.contains("ESRS"),
447                "Disclosure topic should contain framework ID: {}",
448                d.disclosure_topic
449            );
450        }
451    }
452
453    #[test]
454    fn test_climate_scenarios() {
455        let config = EsgReportingConfig::default();
456        let climate = ClimateScenarioConfig {
457            enabled: true,
458            scenarios: vec![
459                "net_zero_2050".into(),
460                "stated_policies".into(),
461                "current_trajectory".into(),
462            ],
463            time_horizons: vec![5, 10, 30],
464        };
465        let mut gen = DisclosureGenerator::new(42, config, climate);
466        let scenarios = gen.generate_climate_scenarios("C001");
467
468        // 4 scenario types × 3 horizons = 12
469        assert_eq!(scenarios.len(), 12);
470
471        // Hot house should have highest physical risk in long term
472        let hot_house_long: Vec<_> = scenarios
473            .iter()
474            .filter(|s| {
475                s.scenario_type == ScenarioType::HotHouse && s.time_horizon == TimeHorizon::Long
476            })
477            .collect();
478        assert_eq!(hot_house_long.len(), 1);
479        assert!(hot_house_long[0].physical_risk_impact > dec!(0.4));
480    }
481
482    #[test]
483    fn test_climate_disabled() {
484        let config = EsgReportingConfig::default();
485        let climate = ClimateScenarioConfig {
486            enabled: false,
487            ..Default::default()
488        };
489        let mut gen = DisclosureGenerator::new(42, config, climate);
490        let scenarios = gen.generate_climate_scenarios("C001");
491        assert!(scenarios.is_empty());
492    }
493
494    #[test]
495    fn test_disclosure_assurance_levels() {
496        let config = EsgReportingConfig::default();
497        let climate = ClimateScenarioConfig::default();
498        let mut gen = DisclosureGenerator::new(42, config, climate);
499
500        let materiality = gen.generate_materiality("C001", d("2025-01-01"));
501        let disclosures =
502            gen.generate_disclosures("C001", &materiality, d("2025-01-01"), d("2025-12-31"));
503
504        // Check is_assured matches assurance_level
505        for d in &disclosures {
506            assert_eq!(
507                d.is_assured,
508                !matches!(d.assurance_level, AssuranceLevel::None),
509                "is_assured should match assurance_level"
510            );
511        }
512    }
513}