datasynth_generators/audit/
materiality_generator.rs1use 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;
29
30#[derive(Debug, Clone)]
37pub struct MaterialityInput {
38 pub entity_code: String,
40 pub period: String,
42 pub revenue: Decimal,
44 pub pretax_income: Decimal,
46 pub total_assets: Decimal,
48 pub equity: Decimal,
50 pub gross_profit: Decimal,
52}
53
54#[derive(Debug, Clone)]
60pub struct MaterialityGeneratorConfig {
61 pub pm_percentage: Decimal,
64 pub minimum_overall_materiality: Decimal,
67}
68
69impl Default for MaterialityGeneratorConfig {
70 fn default() -> Self {
71 Self {
72 pm_percentage: dec!(0.65),
73 minimum_overall_materiality: dec!(5_000),
74 }
75 }
76}
77
78pub struct MaterialityGenerator {
84 rng: ChaCha8Rng,
85 config: MaterialityGeneratorConfig,
86}
87
88impl MaterialityGenerator {
89 pub fn new(seed: u64) -> Self {
91 Self {
92 rng: seeded_rng(seed, 0x320), config: MaterialityGeneratorConfig::default(),
94 }
95 }
96
97 pub fn with_config(seed: u64, config: MaterialityGeneratorConfig) -> Self {
99 Self {
100 rng: seeded_rng(seed, 0x320),
101 config,
102 }
103 }
104
105 pub fn generate(&mut self, input: &MaterialityInput) -> MaterialityCalculation {
107 let (benchmark, benchmark_amount, benchmark_pct, rationale) = self.select_benchmark(input);
108
109 let raw_overall = benchmark_amount * benchmark_pct;
111 let effective_overall = raw_overall.max(self.config.minimum_overall_materiality);
112
113 let effective_pct = if benchmark_amount > Decimal::ZERO {
115 effective_overall / benchmark_amount
116 } else {
117 benchmark_pct
118 };
119
120 let normalized_earnings = self.maybe_generate_normalization(input);
121
122 MaterialityCalculation::new(
123 &input.entity_code,
124 &input.period,
125 benchmark,
126 benchmark_amount,
127 effective_pct,
128 self.config.pm_percentage,
129 normalized_earnings,
130 &rationale,
131 )
132 }
133
134 pub fn generate_batch(&mut self, inputs: &[MaterialityInput]) -> Vec<MaterialityCalculation> {
136 inputs.iter().map(|i| self.generate(i)).collect()
137 }
138
139 fn select_benchmark(
152 &mut self,
153 input: &MaterialityInput,
154 ) -> (MaterialityBenchmark, Decimal, Decimal, String) {
155 let asset_heavy =
157 input.revenue > Decimal::ZERO && input.total_assets > input.revenue * dec!(10);
158
159 let healthy_profit = input.pretax_income > Decimal::ZERO
161 && (input.revenue == Decimal::ZERO || input.pretax_income > input.revenue * dec!(0.05));
162
163 let thin_margin = input.pretax_income > Decimal::ZERO
165 && input.revenue > Decimal::ZERO
166 && input.pretax_income < input.revenue * dec!(0.02);
167
168 if asset_heavy && input.total_assets > Decimal::ZERO {
169 let pct = self.random_pct(dec!(0.005), dec!(0.010));
171 let rationale = format!(
172 "Total assets selected as benchmark (asset-intensive entity; assets {:.0}× revenue). \
173 {:.2}% of total assets applied.",
174 (input.total_assets / input.revenue.max(dec!(1))).round(),
175 pct * dec!(100)
176 );
177 (
178 MaterialityBenchmark::TotalAssets,
179 input.total_assets,
180 pct,
181 rationale,
182 )
183 } else if healthy_profit && !thin_margin {
184 let pct = self.random_pct(dec!(0.03), dec!(0.07));
186 let rationale = format!(
187 "Pre-tax income selected as benchmark (profit-making entity with healthy margins). \
188 {:.0}% applied.",
189 pct * dec!(100)
190 );
191 (
192 MaterialityBenchmark::PretaxIncome,
193 input.pretax_income,
194 pct,
195 rationale,
196 )
197 } else if input.pretax_income <= Decimal::ZERO || thin_margin {
198 let pct = self.random_pct(dec!(0.005), dec!(0.010));
200 let rationale =
201 format!(
202 "Revenue selected as benchmark (entity has {} pre-tax income; revenue provides \
203 more stable benchmark). {:.2}% applied.",
204 if input.pretax_income <= Decimal::ZERO { "negative" } else { "thin" },
205 pct * dec!(100)
206 );
207 (
208 MaterialityBenchmark::Revenue,
209 input.revenue.max(dec!(1)),
210 pct,
211 rationale,
212 )
213 } else if input.equity > Decimal::ZERO {
214 let pct = self.random_pct(dec!(0.01), dec!(0.02));
216 let rationale = format!(
217 "Equity selected as benchmark (equity-focused entity). {:.0}% applied.",
218 pct * dec!(100)
219 );
220 (MaterialityBenchmark::Equity, input.equity, pct, rationale)
221 } else {
222 let pct = self.random_pct(dec!(0.005), dec!(0.010));
224 let rationale = format!(
225 "Revenue selected as default benchmark. {:.2}% applied.",
226 pct * dec!(100)
227 );
228 (
229 MaterialityBenchmark::Revenue,
230 input.revenue.max(dec!(1)),
231 pct,
232 rationale,
233 )
234 }
235 }
236
237 fn maybe_generate_normalization(
248 &mut self,
249 input: &MaterialityInput,
250 ) -> Option<NormalizedEarnings> {
251 if input.pretax_income <= Decimal::ZERO {
253 return None;
254 }
255 let is_unusual =
257 input.revenue > Decimal::ZERO && input.pretax_income < input.revenue * dec!(0.03);
258 if !is_unusual {
259 return None;
260 }
261 let roll: f64 = self.rng.random();
263 if roll > 0.60 {
264 return None;
265 }
266
267 let n_adjustments: u32 = self.rng.random_range(1u32..=2);
268 let mut adjustments = Vec::new();
269
270 for i in 0..n_adjustments {
271 let (description, amount, adj_type) = self.random_adjustment(input, i);
272 adjustments.push(NormalizationAdjustment {
273 description,
274 amount,
275 adjustment_type: adj_type,
276 });
277 }
278
279 let ne = NormalizedEarnings::new(input.pretax_income, adjustments);
280 Some(ne)
281 }
282
283 fn random_adjustment(
285 &mut self,
286 input: &MaterialityInput,
287 index: u32,
288 ) -> (String, Decimal, AdjustmentType) {
289 let templates = [
290 (
291 "Restructuring charge — one-time plant closure costs",
292 AdjustmentType::NonRecurring,
293 0.01_f64,
294 ),
295 (
296 "Impairment of goodwill — non-recurring write-down",
297 AdjustmentType::NonRecurring,
298 0.02_f64,
299 ),
300 (
301 "Gain on disposal of subsidiary — non-recurring",
302 AdjustmentType::Extraordinary,
303 -0.015_f64,
304 ),
305 (
306 "Litigation settlement — one-time charge",
307 AdjustmentType::NonRecurring,
308 0.008_f64,
309 ),
310 (
311 "COVID-19 related costs — non-recurring operational impact",
312 AdjustmentType::NonRecurring,
313 0.005_f64,
314 ),
315 ];
316
317 let idx =
318 (index as usize + self.rng.random_range(0usize..templates.len())) % templates.len();
319 let (desc, adj_type, revenue_frac) = &templates[idx];
320 let base = input.revenue.max(dec!(100_000));
321 let frac = Decimal::try_from(*revenue_frac).unwrap_or(dec!(0.01));
322 let amount = (base * frac).round_dp(0);
323
324 (desc.to_string(), amount, *adj_type)
325 }
326
327 fn random_pct(&mut self, lo: Decimal, hi: Decimal) -> Decimal {
333 use rust_decimal::prelude::ToPrimitive;
334 let lo_f = lo.to_f64().unwrap_or(0.005);
335 let hi_f = hi.to_f64().unwrap_or(0.010);
336 let val = self.rng.random_range(lo_f..=hi_f);
337 Decimal::try_from(val).unwrap_or(lo).round_dp(4)
339 }
340}
341
342#[cfg(test)]
347#[allow(clippy::unwrap_used)]
348mod tests {
349 use super::*;
350
351 fn sample_input() -> MaterialityInput {
352 MaterialityInput {
353 entity_code: "C001".into(),
354 period: "FY2024".into(),
355 revenue: dec!(10_000_000),
356 pretax_income: dec!(1_000_000),
357 total_assets: dec!(8_000_000),
358 equity: dec!(4_000_000),
359 gross_profit: dec!(3_500_000),
360 }
361 }
362
363 #[test]
364 fn materiality_formula_holds() {
365 let mut gen = MaterialityGenerator::new(42);
366 let calc = gen.generate(&sample_input());
367 let expected = (calc.benchmark_amount * calc.benchmark_percentage).round_dp(10);
369 assert_eq!(
370 calc.overall_materiality.round_dp(10),
371 expected,
372 "overall_materiality must equal benchmark_amount × benchmark_percentage"
373 );
374 }
375
376 #[test]
377 fn pm_is_between_50_and_75_percent_of_overall() {
378 let mut gen = MaterialityGenerator::new(42);
379 let calc = gen.generate(&sample_input());
380 let ratio = calc.performance_materiality / calc.overall_materiality;
381 assert!(
382 ratio >= dec!(0.50),
383 "PM ({}) < 50% of overall ({})",
384 calc.performance_materiality,
385 calc.overall_materiality
386 );
387 assert!(
388 ratio <= dec!(0.75),
389 "PM ({}) > 75% of overall ({})",
390 calc.performance_materiality,
391 calc.overall_materiality
392 );
393 }
394
395 #[test]
396 fn clearly_trivial_is_five_percent_of_overall() {
397 let mut gen = MaterialityGenerator::new(42);
398 let calc = gen.generate(&sample_input());
399 let expected_ct = calc.overall_materiality * dec!(0.05);
400 assert_eq!(calc.clearly_trivial, expected_ct);
401 }
402
403 #[test]
404 fn sad_nominal_is_five_percent_of_overall() {
405 let mut gen = MaterialityGenerator::new(42);
406 let calc = gen.generate(&sample_input());
407 let expected = calc.overall_materiality * dec!(0.05);
409 assert_eq!(calc.sad_nominal, expected);
410 }
411
412 #[test]
413 fn minimum_materiality_floor_applied() {
414 let mut gen = MaterialityGenerator::new(42);
415 let tiny_input = MaterialityInput {
416 entity_code: "TINY".into(),
417 period: "FY2024".into(),
418 revenue: dec!(10_000),
419 pretax_income: dec!(500),
420 total_assets: dec!(5_000),
421 equity: dec!(2_000),
422 gross_profit: dec!(2_000),
423 };
424 let calc = gen.generate(&tiny_input);
425 assert!(
426 calc.overall_materiality >= dec!(5_000),
427 "Minimum floor should apply; got {}",
428 calc.overall_materiality
429 );
430 }
431
432 #[test]
433 fn asset_heavy_entity_uses_total_assets() {
434 let mut gen = MaterialityGenerator::new(42);
435 let asset_input = MaterialityInput {
436 entity_code: "BANK".into(),
437 period: "FY2024".into(),
438 revenue: dec!(1_000_000),
439 pretax_income: dec!(200_000),
440 total_assets: dec!(50_000_000), equity: dec!(5_000_000),
442 gross_profit: dec!(800_000),
443 };
444 let calc = gen.generate(&asset_input);
445 assert_eq!(
446 calc.benchmark,
447 MaterialityBenchmark::TotalAssets,
448 "Asset-heavy entity should use TotalAssets benchmark"
449 );
450 }
451
452 #[test]
453 fn loss_making_entity_uses_revenue() {
454 let mut gen = MaterialityGenerator::new(42);
455 let loss_input = MaterialityInput {
456 entity_code: "LOSS".into(),
457 period: "FY2024".into(),
458 revenue: dec!(5_000_000),
459 pretax_income: dec!(-200_000),
460 total_assets: dec!(3_000_000),
461 equity: dec!(1_000_000),
462 gross_profit: dec!(500_000),
463 };
464 let calc = gen.generate(&loss_input);
465 assert_eq!(
466 calc.benchmark,
467 MaterialityBenchmark::Revenue,
468 "Loss-making entity should use Revenue benchmark"
469 );
470 }
471}