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)]
378#[allow(clippy::unwrap_used)]
379mod tests {
380    use super::*;
381
382    fn d(s: &str) -> NaiveDate {
383        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
384    }
385
386    #[test]
387    fn test_materiality_assessment() {
388        let config = EsgReportingConfig::default();
389        let climate = ClimateScenarioConfig::default();
390        let mut gen = DisclosureGenerator::new(42, config, climate);
391
392        let assessments = gen.generate_materiality("C001", d("2025-01-01"));
393
394        assert_eq!(assessments.len(), DISCLOSURE_TOPICS.len());
395        // Some should be material, some not (with default 0.6 threshold)
396        let material = assessments.iter().filter(|a| a.is_material).count();
397        assert!(
398            material > 0 && material < assessments.len(),
399            "Expected mix of material/non-material, got {}/{}",
400            material,
401            assessments.len()
402        );
403    }
404
405    #[test]
406    fn test_all_material_topics_have_disclosures() {
407        let config = EsgReportingConfig::default();
408        let climate = ClimateScenarioConfig::default();
409        let mut gen = DisclosureGenerator::new(42, config, climate);
410
411        let materiality = gen.generate_materiality("C001", d("2025-01-01"));
412        let disclosures =
413            gen.generate_disclosures("C001", &materiality, d("2025-01-01"), d("2025-12-31"));
414
415        let material_topics: Vec<_> = materiality
416            .iter()
417            .filter(|m| m.is_material)
418            .map(|m| m.topic.as_str())
419            .collect();
420
421        // Each material topic should have at least one disclosure per framework
422        for topic in &material_topics {
423            let has_disclosure = disclosures
424                .iter()
425                .any(|d| d.disclosure_topic.contains(topic));
426            assert!(
427                has_disclosure,
428                "Material topic '{}' should have a disclosure",
429                topic
430            );
431        }
432    }
433
434    #[test]
435    fn test_framework_ids_are_valid() {
436        let config = EsgReportingConfig::default();
437        let climate = ClimateScenarioConfig::default();
438        let mut gen = DisclosureGenerator::new(42, config, climate);
439
440        let materiality = gen.generate_materiality("C001", d("2025-01-01"));
441        let disclosures =
442            gen.generate_disclosures("C001", &materiality, d("2025-01-01"), d("2025-12-31"));
443
444        for d in &disclosures {
445            // Should contain a framework-specific ID like "GRI 305-1" or "ESRS E1-6"
446            assert!(
447                d.disclosure_topic.contains("GRI") || d.disclosure_topic.contains("ESRS"),
448                "Disclosure topic should contain framework ID: {}",
449                d.disclosure_topic
450            );
451        }
452    }
453
454    #[test]
455    fn test_climate_scenarios() {
456        let config = EsgReportingConfig::default();
457        let climate = ClimateScenarioConfig {
458            enabled: true,
459            scenarios: vec![
460                "net_zero_2050".into(),
461                "stated_policies".into(),
462                "current_trajectory".into(),
463            ],
464            time_horizons: vec![5, 10, 30],
465        };
466        let mut gen = DisclosureGenerator::new(42, config, climate);
467        let scenarios = gen.generate_climate_scenarios("C001");
468
469        // 4 scenario types × 3 horizons = 12
470        assert_eq!(scenarios.len(), 12);
471
472        // Hot house should have highest physical risk in long term
473        let hot_house_long: Vec<_> = scenarios
474            .iter()
475            .filter(|s| {
476                s.scenario_type == ScenarioType::HotHouse && s.time_horizon == TimeHorizon::Long
477            })
478            .collect();
479        assert_eq!(hot_house_long.len(), 1);
480        assert!(hot_house_long[0].physical_risk_impact > dec!(0.4));
481    }
482
483    #[test]
484    fn test_climate_disabled() {
485        let config = EsgReportingConfig::default();
486        let climate = ClimateScenarioConfig {
487            enabled: false,
488            ..Default::default()
489        };
490        let mut gen = DisclosureGenerator::new(42, config, climate);
491        let scenarios = gen.generate_climate_scenarios("C001");
492        assert!(scenarios.is_empty());
493    }
494
495    #[test]
496    fn test_disclosure_assurance_levels() {
497        let config = EsgReportingConfig::default();
498        let climate = ClimateScenarioConfig::default();
499        let mut gen = DisclosureGenerator::new(42, config, climate);
500
501        let materiality = gen.generate_materiality("C001", d("2025-01-01"));
502        let disclosures =
503            gen.generate_disclosures("C001", &materiality, d("2025-01-01"), d("2025-12-31"));
504
505        // Check is_assured matches assurance_level
506        for d in &disclosures {
507            assert_eq!(
508                d.is_assured,
509                !matches!(d.assurance_level, AssuranceLevel::None),
510                "is_assured should match assurance_level"
511            );
512        }
513    }
514}