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)]
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 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 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 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 assert_eq!(scenarios.len(), 12);
471
472 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 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}