datasynth_generators/compliance/
finding_generator.rs1use chrono::NaiveDate;
7use rand::Rng;
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10
11use datasynth_core::models::compliance::{
12 ComplianceAssertion, ComplianceFinding, DeficiencyLevel, FindingSeverity, RemediationStatus,
13 StandardId,
14};
15use datasynth_core::utils::seeded_rng;
16
17use super::procedure_generator::AuditProcedureRecord;
18
19#[derive(Debug, Clone)]
21pub struct ComplianceFindingGeneratorConfig {
22 pub finding_rate: f64,
24 pub material_weakness_rate: f64,
26 pub significant_deficiency_rate: f64,
28 pub generate_remediation: bool,
30}
31
32impl Default for ComplianceFindingGeneratorConfig {
33 fn default() -> Self {
34 Self {
35 finding_rate: 0.05,
36 material_weakness_rate: 0.02,
37 significant_deficiency_rate: 0.08,
38 generate_remediation: true,
39 }
40 }
41}
42
43const FINDING_TEMPLATES: &[(&str, &str, &str)] = &[
45 (
46 "Revenue cutoff exception",
47 "Revenue was recognized in the incorrect period due to delayed shipment recording",
48 "Cutoff",
49 ),
50 (
51 "Three-way match failure",
52 "Purchase order, goods receipt, and invoice amounts did not agree within tolerance",
53 "Accuracy",
54 ),
55 (
56 "Segregation of duties violation",
57 "Same user created and approved the transaction, violating SoD policy",
58 "Occurrence",
59 ),
60 (
61 "Inadequate journal entry review",
62 "Manual journal entries were posted without required supervisory approval",
63 "Occurrence",
64 ),
65 (
66 "Inventory valuation discrepancy",
67 "Physical inventory count differed from book records by more than tolerable threshold",
68 "ValuationAndAllocation",
69 ),
70 (
71 "Fixed asset existence",
72 "Selected fixed assets could not be physically verified during inspection",
73 "Existence",
74 ),
75 (
76 "Related party disclosure gap",
77 "Related party transactions were not fully disclosed in the financial statements",
78 "CompletenessDisclosure",
79 ),
80 (
81 "Lease classification error",
82 "Operating lease incorrectly classified as finance lease under ASC 842/IFRS 16",
83 "Classification",
84 ),
85 (
86 "Revenue recognition timing",
87 "Performance obligation satisfied over time incorrectly recognized at point in time",
88 "Accuracy",
89 ),
90 (
91 "Bank reconciliation delay",
92 "Bank reconciliations not completed within 5 business days of month-end",
93 "Timeliness",
94 ),
95];
96
97pub struct ComplianceFindingGenerator {
99 rng: ChaCha8Rng,
100 config: ComplianceFindingGeneratorConfig,
101 counter: u32,
102}
103
104impl ComplianceFindingGenerator {
105 pub fn new(seed: u64) -> Self {
107 Self {
108 rng: seeded_rng(seed, 0),
109 config: ComplianceFindingGeneratorConfig::default(),
110 counter: 0,
111 }
112 }
113
114 pub fn with_config(seed: u64, config: ComplianceFindingGeneratorConfig) -> Self {
116 Self {
117 rng: seeded_rng(seed, 0),
118 config,
119 counter: 0,
120 }
121 }
122
123 pub fn generate_findings(
125 &mut self,
126 procedures: &[AuditProcedureRecord],
127 company_code: &str,
128 reference_date: NaiveDate,
129 ) -> Vec<ComplianceFinding> {
130 let mut findings = Vec::new();
131
132 for procedure in procedures {
133 if self.rng.random::<f64>() > self.config.finding_rate {
134 continue;
135 }
136
137 self.counter += 1;
138 let template_idx = self.counter as usize % FINDING_TEMPLATES.len();
139 let (title, description, assertion_str) = FINDING_TEMPLATES[template_idx];
140
141 let deficiency_level = self.determine_deficiency_level();
142 let severity = match deficiency_level {
143 DeficiencyLevel::MaterialWeakness => FindingSeverity::High,
144 DeficiencyLevel::SignificantDeficiency => FindingSeverity::Moderate,
145 DeficiencyLevel::ControlDeficiency => FindingSeverity::Low,
146 };
147
148 let assertion = match assertion_str {
149 "Occurrence" => ComplianceAssertion::Occurrence,
150 "Completeness" => ComplianceAssertion::Completeness,
151 "Accuracy" => ComplianceAssertion::Accuracy,
152 "Cutoff" => ComplianceAssertion::Cutoff,
153 "Classification" => ComplianceAssertion::Classification,
154 "Existence" => ComplianceAssertion::Existence,
155 "ValuationAndAllocation" => ComplianceAssertion::ValuationAndAllocation,
156 "CompletenessDisclosure" => ComplianceAssertion::CompletenessDisclosure,
157 "Timeliness" => ComplianceAssertion::Timeliness,
158 _ => ComplianceAssertion::Occurrence,
159 };
160
161 let standard_id = StandardId::parse(&procedure.standard_id);
162
163 let financial_impact = if matches!(
164 deficiency_level,
165 DeficiencyLevel::MaterialWeakness | DeficiencyLevel::SignificantDeficiency
166 ) {
167 let amount = self.rng.random_range(5_000i64..500_000i64);
168 Some(Decimal::from(amount))
169 } else {
170 None
171 };
172
173 let remediation_status = if self.config.generate_remediation {
174 let r: f64 = self.rng.random();
175 if r < 0.3 {
176 RemediationStatus::Remediated
177 } else if r < 0.7 {
178 RemediationStatus::InProgress
179 } else {
180 RemediationStatus::Open
181 }
182 } else {
183 RemediationStatus::Open
184 };
185
186 let is_repeat = self.rng.random::<f64>() < 0.15;
187
188 let mut finding = ComplianceFinding::new(
189 company_code,
190 title,
191 severity,
192 deficiency_level,
193 reference_date,
194 )
195 .with_description(description)
196 .identified_by(&procedure.procedure_id)
197 .with_assertion(assertion)
198 .with_standard(standard_id)
199 .with_remediation(remediation_status);
200
201 if is_repeat {
202 finding = finding.as_repeat();
203 }
204
205 if let Some(impact) = financial_impact {
206 finding.financial_impact = Some(impact);
207 }
208
209 findings.push(finding);
210 }
211
212 findings
213 }
214
215 fn determine_deficiency_level(&mut self) -> DeficiencyLevel {
216 let r: f64 = self.rng.random();
217 if r < self.config.material_weakness_rate {
218 DeficiencyLevel::MaterialWeakness
219 } else if r < self.config.material_weakness_rate + self.config.significant_deficiency_rate {
220 DeficiencyLevel::SignificantDeficiency
221 } else {
222 DeficiencyLevel::ControlDeficiency
223 }
224 }
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used)]
229mod tests {
230 use super::*;
231 use crate::compliance::ProcedureGenerator;
232 use datasynth_standards::registry::StandardRegistry;
233
234 #[test]
235 fn test_generate_findings() {
236 let registry = StandardRegistry::with_built_in();
237 let date = NaiveDate::from_ymd_opt(2025, 6, 30).unwrap();
238
239 let mut proc_gen = ProcedureGenerator::new(42);
240 let procedures = proc_gen.generate_procedures(®istry, "US", date);
241
242 let config = ComplianceFindingGeneratorConfig {
244 finding_rate: 1.0, ..Default::default()
246 };
247 let mut finding_gen = ComplianceFindingGenerator::with_config(42, config);
248 let findings = finding_gen.generate_findings(&procedures, "C001", date);
249
250 assert!(!findings.is_empty(), "Should generate findings");
251 for f in &findings {
252 assert_eq!(f.company_code, "C001");
253 assert!(!f.related_standards.is_empty());
254 }
255 }
256}