use datasynth_core::models::audit::materiality_calculation::{
AdjustmentType, MaterialityBenchmark, MaterialityCalculation, NormalizationAdjustment,
NormalizedEarnings,
};
use datasynth_core::utils::seeded_rng;
use rand::RngExt;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use tracing::info;
#[derive(Debug, Clone)]
pub struct MaterialityInput {
pub entity_code: String,
pub period: String,
pub revenue: Decimal,
pub pretax_income: Decimal,
pub total_assets: Decimal,
pub equity: Decimal,
pub gross_profit: Decimal,
}
#[derive(Debug, Clone)]
pub struct MaterialityGeneratorConfig {
pub pm_percentage: Decimal,
pub minimum_overall_materiality: Decimal,
}
impl Default for MaterialityGeneratorConfig {
fn default() -> Self {
Self {
pm_percentage: dec!(0.65),
minimum_overall_materiality: dec!(5_000),
}
}
}
pub struct MaterialityGenerator {
rng: ChaCha8Rng,
config: MaterialityGeneratorConfig,
}
impl MaterialityGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0x320), config: MaterialityGeneratorConfig::default(),
}
}
pub fn with_config(seed: u64, config: MaterialityGeneratorConfig) -> Self {
Self {
rng: seeded_rng(seed, 0x320),
config,
}
}
pub fn generate(&mut self, input: &MaterialityInput) -> MaterialityCalculation {
info!(
"Generating materiality calculation for entity {} period {}",
input.entity_code, input.period
);
let (benchmark, benchmark_amount, benchmark_pct, rationale) = self.select_benchmark(input);
let raw_overall = benchmark_amount * benchmark_pct;
let effective_overall = raw_overall.max(self.config.minimum_overall_materiality);
let effective_pct = if benchmark_amount > Decimal::ZERO {
effective_overall / benchmark_amount
} else {
benchmark_pct
};
let normalized_earnings = self.maybe_generate_normalization(input);
let calc = MaterialityCalculation::new(
&input.entity_code,
&input.period,
benchmark,
benchmark_amount,
effective_pct,
self.config.pm_percentage,
normalized_earnings,
&rationale,
);
info!(
"Materiality for {} {}: overall={} PM={} benchmark={:?}",
input.entity_code,
input.period,
calc.overall_materiality,
calc.performance_materiality,
calc.benchmark
);
calc
}
pub fn generate_batch(&mut self, inputs: &[MaterialityInput]) -> Vec<MaterialityCalculation> {
inputs.iter().map(|i| self.generate(i)).collect()
}
fn select_benchmark(
&mut self,
input: &MaterialityInput,
) -> (MaterialityBenchmark, Decimal, Decimal, String) {
let asset_heavy =
input.revenue > Decimal::ZERO && input.total_assets > input.revenue * dec!(10);
let healthy_profit = input.pretax_income > Decimal::ZERO
&& (input.revenue == Decimal::ZERO || input.pretax_income > input.revenue * dec!(0.05));
let thin_margin = input.pretax_income > Decimal::ZERO
&& input.revenue > Decimal::ZERO
&& input.pretax_income < input.revenue * dec!(0.02);
if asset_heavy && input.total_assets > Decimal::ZERO {
let pct = self.random_pct(dec!(0.005), dec!(0.010));
let rationale = format!(
"Total assets selected as benchmark (asset-intensive entity; assets {:.0}× revenue). \
{:.2}% of total assets applied.",
(input.total_assets / input.revenue.max(dec!(1))).round(),
pct * dec!(100)
);
(
MaterialityBenchmark::TotalAssets,
input.total_assets,
pct,
rationale,
)
} else if healthy_profit && !thin_margin {
let pct = self.random_pct(dec!(0.03), dec!(0.07));
let rationale = format!(
"Pre-tax income selected as benchmark (profit-making entity with healthy margins). \
{:.0}% applied.",
pct * dec!(100)
);
(
MaterialityBenchmark::PretaxIncome,
input.pretax_income,
pct,
rationale,
)
} else if input.pretax_income <= Decimal::ZERO || thin_margin {
let pct = self.random_pct(dec!(0.005), dec!(0.010));
let rationale =
format!(
"Revenue selected as benchmark (entity has {} pre-tax income; revenue provides \
more stable benchmark). {:.2}% applied.",
if input.pretax_income <= Decimal::ZERO { "negative" } else { "thin" },
pct * dec!(100)
);
(
MaterialityBenchmark::Revenue,
input.revenue.max(dec!(1)),
pct,
rationale,
)
} else if input.equity > Decimal::ZERO {
let pct = self.random_pct(dec!(0.01), dec!(0.02));
let rationale = format!(
"Equity selected as benchmark (equity-focused entity). {:.0}% applied.",
pct * dec!(100)
);
(MaterialityBenchmark::Equity, input.equity, pct, rationale)
} else {
let pct = self.random_pct(dec!(0.005), dec!(0.010));
let rationale = format!(
"Revenue selected as default benchmark. {:.2}% applied.",
pct * dec!(100)
);
(
MaterialityBenchmark::Revenue,
input.revenue.max(dec!(1)),
pct,
rationale,
)
}
}
fn maybe_generate_normalization(
&mut self,
input: &MaterialityInput,
) -> Option<NormalizedEarnings> {
if input.pretax_income <= Decimal::ZERO {
return None;
}
let is_unusual =
input.revenue > Decimal::ZERO && input.pretax_income < input.revenue * dec!(0.03);
if !is_unusual {
return None;
}
let roll: f64 = self.rng.random();
if roll > 0.60 {
return None;
}
let n_adjustments: u32 = self.rng.random_range(1u32..=2);
let mut adjustments = Vec::new();
for i in 0..n_adjustments {
let (description, amount, adj_type) = self.random_adjustment(input, i);
adjustments.push(NormalizationAdjustment {
description,
amount,
adjustment_type: adj_type,
});
}
let ne = NormalizedEarnings::new(input.pretax_income, adjustments);
Some(ne)
}
fn random_adjustment(
&mut self,
input: &MaterialityInput,
index: u32,
) -> (String, Decimal, AdjustmentType) {
let templates = [
(
"Restructuring charge — one-time plant closure costs",
AdjustmentType::NonRecurring,
0.01_f64,
),
(
"Impairment of goodwill — non-recurring write-down",
AdjustmentType::NonRecurring,
0.02_f64,
),
(
"Gain on disposal of subsidiary — non-recurring",
AdjustmentType::Extraordinary,
-0.015_f64,
),
(
"Litigation settlement — one-time charge",
AdjustmentType::NonRecurring,
0.008_f64,
),
(
"COVID-19 related costs — non-recurring operational impact",
AdjustmentType::NonRecurring,
0.005_f64,
),
];
let idx =
(index as usize + self.rng.random_range(0usize..templates.len())) % templates.len();
let (desc, adj_type, revenue_frac) = &templates[idx];
let base = input.revenue.max(dec!(100_000));
let frac = Decimal::try_from(*revenue_frac).unwrap_or(dec!(0.01));
let amount = (base * frac).round_dp(0);
(desc.to_string(), amount, *adj_type)
}
fn random_pct(&mut self, lo: Decimal, hi: Decimal) -> Decimal {
use rust_decimal::prelude::ToPrimitive;
let lo_f = lo.to_f64().unwrap_or(0.005);
let hi_f = hi.to_f64().unwrap_or(0.010);
let val = self.rng.random_range(lo_f..=hi_f);
Decimal::try_from(val).unwrap_or(lo).round_dp(4)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_input() -> MaterialityInput {
MaterialityInput {
entity_code: "C001".into(),
period: "FY2024".into(),
revenue: dec!(10_000_000),
pretax_income: dec!(1_000_000),
total_assets: dec!(8_000_000),
equity: dec!(4_000_000),
gross_profit: dec!(3_500_000),
}
}
#[test]
fn materiality_formula_holds() {
let mut gen = MaterialityGenerator::new(42);
let calc = gen.generate(&sample_input());
let expected = (calc.benchmark_amount * calc.benchmark_percentage).round_dp(10);
assert_eq!(
calc.overall_materiality.round_dp(10),
expected,
"overall_materiality must equal benchmark_amount × benchmark_percentage"
);
}
#[test]
fn pm_is_between_50_and_75_percent_of_overall() {
let mut gen = MaterialityGenerator::new(42);
let calc = gen.generate(&sample_input());
let ratio = calc.performance_materiality / calc.overall_materiality;
assert!(
ratio >= dec!(0.50),
"PM ({}) < 50% of overall ({})",
calc.performance_materiality,
calc.overall_materiality
);
assert!(
ratio <= dec!(0.75),
"PM ({}) > 75% of overall ({})",
calc.performance_materiality,
calc.overall_materiality
);
}
#[test]
fn clearly_trivial_is_five_percent_of_overall() {
let mut gen = MaterialityGenerator::new(42);
let calc = gen.generate(&sample_input());
let expected_ct = calc.overall_materiality * dec!(0.05);
assert_eq!(calc.clearly_trivial, expected_ct);
}
#[test]
fn sad_nominal_is_five_percent_of_overall() {
let mut gen = MaterialityGenerator::new(42);
let calc = gen.generate(&sample_input());
let expected = calc.overall_materiality * dec!(0.05);
assert_eq!(calc.sad_nominal, expected);
}
#[test]
fn minimum_materiality_floor_applied() {
let mut gen = MaterialityGenerator::new(42);
let tiny_input = MaterialityInput {
entity_code: "TINY".into(),
period: "FY2024".into(),
revenue: dec!(10_000),
pretax_income: dec!(500),
total_assets: dec!(5_000),
equity: dec!(2_000),
gross_profit: dec!(2_000),
};
let calc = gen.generate(&tiny_input);
assert!(
calc.overall_materiality >= dec!(5_000),
"Minimum floor should apply; got {}",
calc.overall_materiality
);
}
#[test]
fn asset_heavy_entity_uses_total_assets() {
let mut gen = MaterialityGenerator::new(42);
let asset_input = MaterialityInput {
entity_code: "BANK".into(),
period: "FY2024".into(),
revenue: dec!(1_000_000),
pretax_income: dec!(200_000),
total_assets: dec!(50_000_000), equity: dec!(5_000_000),
gross_profit: dec!(800_000),
};
let calc = gen.generate(&asset_input);
assert_eq!(
calc.benchmark,
MaterialityBenchmark::TotalAssets,
"Asset-heavy entity should use TotalAssets benchmark"
);
}
#[test]
fn loss_making_entity_uses_revenue() {
let mut gen = MaterialityGenerator::new(42);
let loss_input = MaterialityInput {
entity_code: "LOSS".into(),
period: "FY2024".into(),
revenue: dec!(5_000_000),
pretax_income: dec!(-200_000),
total_assets: dec!(3_000_000),
equity: dec!(1_000_000),
gross_profit: dec!(500_000),
};
let calc = gen.generate(&loss_input);
assert_eq!(
calc.benchmark,
MaterialityBenchmark::Revenue,
"Loss-making entity should use Revenue benchmark"
);
}
}