1use 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
16struct 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
86pub struct DisclosureGenerator {
88 rng: ChaCha8Rng,
89 config: EsgReportingConfig,
90 climate_config: ClimateScenarioConfig,
91 counter: u64,
92}
93
94impl DisclosureGenerator {
95 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 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 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 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, };
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 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 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 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 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 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 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 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 assert_eq!(scenarios.len(), 12);
470
471 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 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}