1use datasynth_core::models::audit::materiality_calculation::{
21 AdjustmentType, MaterialityBenchmark, MaterialityCalculation, NormalizationAdjustment,
22 NormalizedEarnings,
23};
24use datasynth_core::utils::seeded_rng;
25use rand::Rng;
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)]
361#[allow(clippy::unwrap_used)]
362mod tests {
363 use super::*;
364
365 fn sample_input() -> MaterialityInput {
366 MaterialityInput {
367 entity_code: "C001".into(),
368 period: "FY2024".into(),
369 revenue: dec!(10_000_000),
370 pretax_income: dec!(1_000_000),
371 total_assets: dec!(8_000_000),
372 equity: dec!(4_000_000),
373 gross_profit: dec!(3_500_000),
374 }
375 }
376
377 #[test]
378 fn materiality_formula_holds() {
379 let mut gen = MaterialityGenerator::new(42);
380 let calc = gen.generate(&sample_input());
381 let expected = (calc.benchmark_amount * calc.benchmark_percentage).round_dp(10);
383 assert_eq!(
384 calc.overall_materiality.round_dp(10),
385 expected,
386 "overall_materiality must equal benchmark_amount × benchmark_percentage"
387 );
388 }
389
390 #[test]
391 fn pm_is_between_50_and_75_percent_of_overall() {
392 let mut gen = MaterialityGenerator::new(42);
393 let calc = gen.generate(&sample_input());
394 let ratio = calc.performance_materiality / calc.overall_materiality;
395 assert!(
396 ratio >= dec!(0.50),
397 "PM ({}) < 50% of overall ({})",
398 calc.performance_materiality,
399 calc.overall_materiality
400 );
401 assert!(
402 ratio <= dec!(0.75),
403 "PM ({}) > 75% of overall ({})",
404 calc.performance_materiality,
405 calc.overall_materiality
406 );
407 }
408
409 #[test]
410 fn clearly_trivial_is_five_percent_of_overall() {
411 let mut gen = MaterialityGenerator::new(42);
412 let calc = gen.generate(&sample_input());
413 let expected_ct = calc.overall_materiality * dec!(0.05);
414 assert_eq!(calc.clearly_trivial, expected_ct);
415 }
416
417 #[test]
418 fn sad_nominal_is_five_percent_of_overall() {
419 let mut gen = MaterialityGenerator::new(42);
420 let calc = gen.generate(&sample_input());
421 let expected = calc.overall_materiality * dec!(0.05);
423 assert_eq!(calc.sad_nominal, expected);
424 }
425
426 #[test]
427 fn minimum_materiality_floor_applied() {
428 let mut gen = MaterialityGenerator::new(42);
429 let tiny_input = MaterialityInput {
430 entity_code: "TINY".into(),
431 period: "FY2024".into(),
432 revenue: dec!(10_000),
433 pretax_income: dec!(500),
434 total_assets: dec!(5_000),
435 equity: dec!(2_000),
436 gross_profit: dec!(2_000),
437 };
438 let calc = gen.generate(&tiny_input);
439 assert!(
440 calc.overall_materiality >= dec!(5_000),
441 "Minimum floor should apply; got {}",
442 calc.overall_materiality
443 );
444 }
445
446 #[test]
447 fn asset_heavy_entity_uses_total_assets() {
448 let mut gen = MaterialityGenerator::new(42);
449 let asset_input = MaterialityInput {
450 entity_code: "BANK".into(),
451 period: "FY2024".into(),
452 revenue: dec!(1_000_000),
453 pretax_income: dec!(200_000),
454 total_assets: dec!(50_000_000), equity: dec!(5_000_000),
456 gross_profit: dec!(800_000),
457 };
458 let calc = gen.generate(&asset_input);
459 assert_eq!(
460 calc.benchmark,
461 MaterialityBenchmark::TotalAssets,
462 "Asset-heavy entity should use TotalAssets benchmark"
463 );
464 }
465
466 #[test]
467 fn loss_making_entity_uses_revenue() {
468 let mut gen = MaterialityGenerator::new(42);
469 let loss_input = MaterialityInput {
470 entity_code: "LOSS".into(),
471 period: "FY2024".into(),
472 revenue: dec!(5_000_000),
473 pretax_income: dec!(-200_000),
474 total_assets: dec!(3_000_000),
475 equity: dec!(1_000_000),
476 gross_profit: dec!(500_000),
477 };
478 let calc = gen.generate(&loss_input);
479 assert_eq!(
480 calc.benchmark,
481 MaterialityBenchmark::Revenue,
482 "Loss-making entity should use Revenue benchmark"
483 );
484 }
485}