1use datasynth_core::models::audit::materiality_calculation::{
21 AdjustmentType, MaterialityBenchmark, MaterialityCalculation, NormalizationAdjustment,
22 NormalizedEarnings,
23};
24use datasynth_core::utils::seeded_rng;
25use rand::RngExt;
26use rand_chacha::ChaCha8Rng;
27use rust_decimal::Decimal;
28use rust_decimal_macros::dec;
29use tracing::info;
30
31#[derive(Debug, Clone)]
38pub struct MaterialityInput {
39 pub entity_code: String,
41 pub period: String,
43 pub revenue: Decimal,
45 pub pretax_income: Decimal,
47 pub total_assets: Decimal,
49 pub equity: Decimal,
51 pub gross_profit: Decimal,
53}
54
55#[derive(Debug, Clone)]
61pub struct MaterialityGeneratorConfig {
62 pub pm_percentage: Decimal,
65 pub minimum_overall_materiality: Decimal,
68}
69
70impl Default for MaterialityGeneratorConfig {
71 fn default() -> Self {
72 Self {
73 pm_percentage: dec!(0.65),
74 minimum_overall_materiality: dec!(5_000),
75 }
76 }
77}
78
79pub struct MaterialityGenerator {
85 rng: ChaCha8Rng,
86 config: MaterialityGeneratorConfig,
87}
88
89impl MaterialityGenerator {
90 pub fn new(seed: u64) -> Self {
92 Self {
93 rng: seeded_rng(seed, 0x320), config: MaterialityGeneratorConfig::default(),
95 }
96 }
97
98 pub fn with_config(seed: u64, config: MaterialityGeneratorConfig) -> Self {
100 Self {
101 rng: seeded_rng(seed, 0x320),
102 config,
103 }
104 }
105
106 pub fn generate(&mut self, input: &MaterialityInput) -> MaterialityCalculation {
108 info!(
109 "Generating materiality calculation for entity {} period {}",
110 input.entity_code, input.period
111 );
112 let (benchmark, benchmark_amount, benchmark_pct, rationale) = self.select_benchmark(input);
113
114 let raw_overall = benchmark_amount * benchmark_pct;
116 let effective_overall = raw_overall.max(self.config.minimum_overall_materiality);
117
118 let effective_pct = if benchmark_amount > Decimal::ZERO {
120 effective_overall / benchmark_amount
121 } else {
122 benchmark_pct
123 };
124
125 let normalized_earnings = self.maybe_generate_normalization(input);
126
127 let calc = MaterialityCalculation::new(
128 &input.entity_code,
129 &input.period,
130 benchmark,
131 benchmark_amount,
132 effective_pct,
133 self.config.pm_percentage,
134 normalized_earnings,
135 &rationale,
136 );
137 info!(
138 "Materiality for {} {}: overall={} PM={} benchmark={:?}",
139 input.entity_code,
140 input.period,
141 calc.overall_materiality,
142 calc.performance_materiality,
143 calc.benchmark
144 );
145 calc
146 }
147
148 pub fn generate_batch(&mut self, inputs: &[MaterialityInput]) -> Vec<MaterialityCalculation> {
150 inputs.iter().map(|i| self.generate(i)).collect()
151 }
152
153 fn select_benchmark(
166 &mut self,
167 input: &MaterialityInput,
168 ) -> (MaterialityBenchmark, Decimal, Decimal, String) {
169 let asset_heavy =
171 input.revenue > Decimal::ZERO && input.total_assets > input.revenue * dec!(10);
172
173 let healthy_profit = input.pretax_income > Decimal::ZERO
175 && (input.revenue == Decimal::ZERO || input.pretax_income > input.revenue * dec!(0.05));
176
177 let thin_margin = input.pretax_income > Decimal::ZERO
179 && input.revenue > Decimal::ZERO
180 && input.pretax_income < input.revenue * dec!(0.02);
181
182 if asset_heavy && input.total_assets > Decimal::ZERO {
183 let pct = self.random_pct(dec!(0.005), dec!(0.010));
185 let rationale = format!(
186 "Total assets selected as benchmark (asset-intensive entity; assets {:.0}× revenue). \
187 {:.2}% of total assets applied.",
188 (input.total_assets / input.revenue.max(dec!(1))).round(),
189 pct * dec!(100)
190 );
191 (
192 MaterialityBenchmark::TotalAssets,
193 input.total_assets,
194 pct,
195 rationale,
196 )
197 } else if healthy_profit && !thin_margin {
198 let pct = self.random_pct(dec!(0.03), dec!(0.07));
200 let rationale = format!(
201 "Pre-tax income selected as benchmark (profit-making entity with healthy margins). \
202 {:.0}% applied.",
203 pct * dec!(100)
204 );
205 (
206 MaterialityBenchmark::PretaxIncome,
207 input.pretax_income,
208 pct,
209 rationale,
210 )
211 } else if input.pretax_income <= Decimal::ZERO || thin_margin {
212 let pct = self.random_pct(dec!(0.005), dec!(0.010));
214 let rationale =
215 format!(
216 "Revenue selected as benchmark (entity has {} pre-tax income; revenue provides \
217 more stable benchmark). {:.2}% applied.",
218 if input.pretax_income <= Decimal::ZERO { "negative" } else { "thin" },
219 pct * dec!(100)
220 );
221 (
222 MaterialityBenchmark::Revenue,
223 input.revenue.max(dec!(1)),
224 pct,
225 rationale,
226 )
227 } else if input.equity > Decimal::ZERO {
228 let pct = self.random_pct(dec!(0.01), dec!(0.02));
230 let rationale = format!(
231 "Equity selected as benchmark (equity-focused entity). {:.0}% applied.",
232 pct * dec!(100)
233 );
234 (MaterialityBenchmark::Equity, input.equity, pct, rationale)
235 } else {
236 let pct = self.random_pct(dec!(0.005), dec!(0.010));
238 let rationale = format!(
239 "Revenue selected as default benchmark. {:.2}% applied.",
240 pct * dec!(100)
241 );
242 (
243 MaterialityBenchmark::Revenue,
244 input.revenue.max(dec!(1)),
245 pct,
246 rationale,
247 )
248 }
249 }
250
251 fn maybe_generate_normalization(
262 &mut self,
263 input: &MaterialityInput,
264 ) -> Option<NormalizedEarnings> {
265 if input.pretax_income <= Decimal::ZERO {
267 return None;
268 }
269 let is_unusual =
271 input.revenue > Decimal::ZERO && input.pretax_income < input.revenue * dec!(0.03);
272 if !is_unusual {
273 return None;
274 }
275 let roll: f64 = self.rng.random();
277 if roll > 0.60 {
278 return None;
279 }
280
281 let n_adjustments: u32 = self.rng.random_range(1u32..=2);
282 let mut adjustments = Vec::new();
283
284 for i in 0..n_adjustments {
285 let (description, amount, adj_type) = self.random_adjustment(input, i);
286 adjustments.push(NormalizationAdjustment {
287 description,
288 amount,
289 adjustment_type: adj_type,
290 });
291 }
292
293 let ne = NormalizedEarnings::new(input.pretax_income, adjustments);
294 Some(ne)
295 }
296
297 fn random_adjustment(
299 &mut self,
300 input: &MaterialityInput,
301 index: u32,
302 ) -> (String, Decimal, AdjustmentType) {
303 let templates = [
304 (
305 "Restructuring charge — one-time plant closure costs",
306 AdjustmentType::NonRecurring,
307 0.01_f64,
308 ),
309 (
310 "Impairment of goodwill — non-recurring write-down",
311 AdjustmentType::NonRecurring,
312 0.02_f64,
313 ),
314 (
315 "Gain on disposal of subsidiary — non-recurring",
316 AdjustmentType::Extraordinary,
317 -0.015_f64,
318 ),
319 (
320 "Litigation settlement — one-time charge",
321 AdjustmentType::NonRecurring,
322 0.008_f64,
323 ),
324 (
325 "COVID-19 related costs — non-recurring operational impact",
326 AdjustmentType::NonRecurring,
327 0.005_f64,
328 ),
329 ];
330
331 let idx =
332 (index as usize + self.rng.random_range(0usize..templates.len())) % templates.len();
333 let (desc, adj_type, revenue_frac) = &templates[idx];
334 let base = input.revenue.max(dec!(100_000));
335 let frac = Decimal::try_from(*revenue_frac).unwrap_or(dec!(0.01));
336 let amount = (base * frac).round_dp(0);
337
338 (desc.to_string(), amount, *adj_type)
339 }
340
341 fn random_pct(&mut self, lo: Decimal, hi: Decimal) -> Decimal {
347 use rust_decimal::prelude::ToPrimitive;
348 let lo_f = lo.to_f64().unwrap_or(0.005);
349 let hi_f = hi.to_f64().unwrap_or(0.010);
350 let val = self.rng.random_range(lo_f..=hi_f);
351 Decimal::try_from(val).unwrap_or(lo).round_dp(4)
353 }
354}
355
356#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn sample_input() -> MaterialityInput {
365 MaterialityInput {
366 entity_code: "C001".into(),
367 period: "FY2024".into(),
368 revenue: dec!(10_000_000),
369 pretax_income: dec!(1_000_000),
370 total_assets: dec!(8_000_000),
371 equity: dec!(4_000_000),
372 gross_profit: dec!(3_500_000),
373 }
374 }
375
376 #[test]
377 fn materiality_formula_holds() {
378 let mut gen = MaterialityGenerator::new(42);
379 let calc = gen.generate(&sample_input());
380 let expected = (calc.benchmark_amount * calc.benchmark_percentage).round_dp(10);
382 assert_eq!(
383 calc.overall_materiality.round_dp(10),
384 expected,
385 "overall_materiality must equal benchmark_amount × benchmark_percentage"
386 );
387 }
388
389 #[test]
390 fn pm_is_between_50_and_75_percent_of_overall() {
391 let mut gen = MaterialityGenerator::new(42);
392 let calc = gen.generate(&sample_input());
393 let ratio = calc.performance_materiality / calc.overall_materiality;
394 assert!(
395 ratio >= dec!(0.50),
396 "PM ({}) < 50% of overall ({})",
397 calc.performance_materiality,
398 calc.overall_materiality
399 );
400 assert!(
401 ratio <= dec!(0.75),
402 "PM ({}) > 75% of overall ({})",
403 calc.performance_materiality,
404 calc.overall_materiality
405 );
406 }
407
408 #[test]
409 fn clearly_trivial_is_five_percent_of_overall() {
410 let mut gen = MaterialityGenerator::new(42);
411 let calc = gen.generate(&sample_input());
412 let expected_ct = calc.overall_materiality * dec!(0.05);
413 assert_eq!(calc.clearly_trivial, expected_ct);
414 }
415
416 #[test]
417 fn sad_nominal_is_five_percent_of_overall() {
418 let mut gen = MaterialityGenerator::new(42);
419 let calc = gen.generate(&sample_input());
420 let expected = calc.overall_materiality * dec!(0.05);
422 assert_eq!(calc.sad_nominal, expected);
423 }
424
425 #[test]
426 fn minimum_materiality_floor_applied() {
427 let mut gen = MaterialityGenerator::new(42);
428 let tiny_input = MaterialityInput {
429 entity_code: "TINY".into(),
430 period: "FY2024".into(),
431 revenue: dec!(10_000),
432 pretax_income: dec!(500),
433 total_assets: dec!(5_000),
434 equity: dec!(2_000),
435 gross_profit: dec!(2_000),
436 };
437 let calc = gen.generate(&tiny_input);
438 assert!(
439 calc.overall_materiality >= dec!(5_000),
440 "Minimum floor should apply; got {}",
441 calc.overall_materiality
442 );
443 }
444
445 #[test]
446 fn asset_heavy_entity_uses_total_assets() {
447 let mut gen = MaterialityGenerator::new(42);
448 let asset_input = MaterialityInput {
449 entity_code: "BANK".into(),
450 period: "FY2024".into(),
451 revenue: dec!(1_000_000),
452 pretax_income: dec!(200_000),
453 total_assets: dec!(50_000_000), equity: dec!(5_000_000),
455 gross_profit: dec!(800_000),
456 };
457 let calc = gen.generate(&asset_input);
458 assert_eq!(
459 calc.benchmark,
460 MaterialityBenchmark::TotalAssets,
461 "Asset-heavy entity should use TotalAssets benchmark"
462 );
463 }
464
465 #[test]
466 fn loss_making_entity_uses_revenue() {
467 let mut gen = MaterialityGenerator::new(42);
468 let loss_input = MaterialityInput {
469 entity_code: "LOSS".into(),
470 period: "FY2024".into(),
471 revenue: dec!(5_000_000),
472 pretax_income: dec!(-200_000),
473 total_assets: dec!(3_000_000),
474 equity: dec!(1_000_000),
475 gross_profit: dec!(500_000),
476 };
477 let calc = gen.generate(&loss_input);
478 assert_eq!(
479 calc.benchmark,
480 MaterialityBenchmark::Revenue,
481 "Loss-making entity should use Revenue benchmark"
482 );
483 }
484}