datasynth_generators/audit/
procedure_step_generator.rs1use datasynth_core::utils::seeded_rng;
7use rand::Rng;
8use rand_chacha::ChaCha8Rng;
9
10use datasynth_core::models::audit::{
11 Assertion, AuditProcedureStep, ProcedureType, StepProcedureType, StepResult, Workpaper,
12};
13
14#[derive(Debug, Clone)]
16pub struct ProcedureStepGeneratorConfig {
17 pub steps_per_workpaper: (u32, u32),
19 pub pass_ratio: f64,
21 pub exception_ratio: f64,
23 pub fail_ratio: f64,
25 pub completion_ratio: f64,
27}
28
29impl Default for ProcedureStepGeneratorConfig {
30 fn default() -> Self {
31 Self {
32 steps_per_workpaper: (3, 8),
33 pass_ratio: 0.85,
34 exception_ratio: 0.10,
35 fail_ratio: 0.05,
36 completion_ratio: 0.90,
37 }
38 }
39}
40
41pub struct ProcedureStepGenerator {
43 rng: ChaCha8Rng,
45 config: ProcedureStepGeneratorConfig,
47}
48
49impl ProcedureStepGenerator {
50 pub fn new(seed: u64) -> Self {
52 Self {
53 rng: seeded_rng(seed, 0),
54 config: ProcedureStepGeneratorConfig::default(),
55 }
56 }
57
58 pub fn with_config(seed: u64, config: ProcedureStepGeneratorConfig) -> Self {
60 Self {
61 rng: seeded_rng(seed, 0),
62 config,
63 }
64 }
65
66 pub fn generate_steps(
73 &mut self,
74 workpaper: &Workpaper,
75 team_members: &[(String, String)],
76 ) -> Vec<AuditProcedureStep> {
77 let count = self
78 .rng
79 .random_range(self.config.steps_per_workpaper.0..=self.config.steps_per_workpaper.1)
80 as usize;
81
82 let assertions = self.assertions_for_procedure(workpaper.procedure_type);
84 let step_types = self.step_types_for_procedure(workpaper.procedure_type);
86
87 let mut steps = Vec::with_capacity(count);
88
89 for i in 0..count {
90 let step_number = (i + 1) as u32;
91
92 let assertion = assertions[i % assertions.len()];
93 let proc_type = step_types[i % step_types.len()];
94 let description = self.description_for(proc_type, assertion);
95
96 let mut step = AuditProcedureStep::new(
97 workpaper.workpaper_id,
98 workpaper.engagement_id,
99 step_number,
100 description,
101 proc_type,
102 assertion,
103 );
104
105 if self.rng.random::<f64>() < self.config.completion_ratio {
107 let (performer_id, performer_name) = if !team_members.is_empty() {
109 let idx = self.rng.random_range(0..team_members.len());
110 (team_members[idx].0.clone(), team_members[idx].1.clone())
111 } else {
112 ("STAFF001".to_string(), "Audit Staff".to_string())
113 };
114
115 let performed_date = workpaper.preparer_date;
118
119 let result = self.random_result();
120
121 step.perform(performer_id, performer_name, performed_date, result);
122
123 if matches!(result, StepResult::Exception | StepResult::Fail) {
124 step.exception_description = Some(self.exception_text(assertion).to_string());
125 }
126 }
127
128 steps.push(step);
129 }
130
131 steps
132 }
133
134 fn assertions_for_procedure(&self, proc_type: ProcedureType) -> Vec<Assertion> {
140 match proc_type {
141 ProcedureType::TestOfControls => vec![
142 Assertion::Occurrence,
143 Assertion::Completeness,
144 Assertion::Accuracy,
145 Assertion::Cutoff,
146 Assertion::Classification,
147 ],
148 ProcedureType::SubstantiveTest => vec![
149 Assertion::Existence,
150 Assertion::Completeness,
151 Assertion::ValuationAndAllocation,
152 Assertion::RightsAndObligations,
153 Assertion::Cutoff,
154 ],
155 ProcedureType::AnalyticalProcedures => vec![
156 Assertion::Completeness,
157 Assertion::ValuationAndAllocation,
158 Assertion::Occurrence,
159 Assertion::PresentationAndDisclosure,
160 ],
161 _ => vec![
162 Assertion::Existence,
163 Assertion::Completeness,
164 Assertion::Accuracy,
165 Assertion::Occurrence,
166 Assertion::ValuationAndAllocation,
167 Assertion::Classification,
168 ],
169 }
170 }
171
172 fn step_types_for_procedure(&self, proc_type: ProcedureType) -> Vec<StepProcedureType> {
174 match proc_type {
175 ProcedureType::TestOfControls => vec![
176 StepProcedureType::Reperformance,
177 StepProcedureType::Observation,
178 StepProcedureType::Inquiry,
179 ],
180 ProcedureType::SubstantiveTest => vec![
181 StepProcedureType::Inspection,
182 StepProcedureType::Vouching,
183 StepProcedureType::Recalculation,
184 ],
185 ProcedureType::AnalyticalProcedures => {
186 vec![StepProcedureType::AnalyticalProcedure]
187 }
188 _ => vec![
189 StepProcedureType::Inspection,
190 StepProcedureType::Observation,
191 StepProcedureType::Inquiry,
192 StepProcedureType::Reperformance,
193 StepProcedureType::Vouching,
194 ],
195 }
196 }
197
198 fn description_for(&self, proc_type: StepProcedureType, assertion: Assertion) -> String {
200 let proc_name = match proc_type {
201 StepProcedureType::Inspection => "Inspect documents to verify",
202 StepProcedureType::Observation => "Observe process controls to confirm",
203 StepProcedureType::Inquiry => "Inquire of management regarding",
204 StepProcedureType::Confirmation => "Obtain external confirmation of",
205 StepProcedureType::Recalculation => "Recalculate amounts to verify",
206 StepProcedureType::Reperformance => "Re-perform procedure to test",
207 StepProcedureType::AnalyticalProcedure => "Apply analytical procedure to evaluate",
208 StepProcedureType::Vouching => "Vouch transactions back to source documents for",
209 StepProcedureType::Scanning => "Scan population for unusual items affecting",
210 };
211
212 let assertion_name = match assertion {
213 Assertion::Occurrence => "occurrence of transactions",
214 Assertion::Completeness => "completeness of recording",
215 Assertion::Accuracy => "accuracy of amounts",
216 Assertion::Cutoff => "period-end cutoff",
217 Assertion::Classification => "proper classification",
218 Assertion::Existence => "existence of balances",
219 Assertion::RightsAndObligations => "rights and obligations",
220 Assertion::ValuationAndAllocation => "valuation and allocation",
221 Assertion::PresentationAndDisclosure => "presentation and disclosure",
222 };
223
224 format!("{proc_name} {assertion_name}.")
225 }
226
227 fn random_result(&mut self) -> StepResult {
229 let roll: f64 = self.rng.random();
230 let fail_cutoff = self.config.fail_ratio;
231 let exception_cutoff = fail_cutoff + self.config.exception_ratio;
232 if roll < fail_cutoff {
235 StepResult::Fail
236 } else if roll < exception_cutoff {
237 StepResult::Exception
238 } else {
239 StepResult::Pass
240 }
241 }
242
243 fn exception_text(&self, assertion: Assertion) -> &'static str {
245 match assertion {
246 Assertion::Occurrence => "Transaction cannot be traced to an approved source document.",
247 Assertion::Completeness => {
248 "Item exists in the population but was not recorded in the ledger."
249 }
250 Assertion::Accuracy => {
251 "Recorded amount differs from the supporting document by more than 1%."
252 }
253 Assertion::Cutoff => "Transaction recorded in the wrong accounting period.",
254 Assertion::Classification => {
255 "Amount posted to incorrect expense or balance sheet account."
256 }
257 Assertion::Existence => {
258 "Asset could not be physically located or confirmed with a third party."
259 }
260 Assertion::RightsAndObligations => {
261 "Evidence of ownership or obligation could not be obtained."
262 }
263 Assertion::ValuationAndAllocation => {
264 "Carrying value is inconsistent with observable market inputs."
265 }
266 Assertion::PresentationAndDisclosure => {
267 "Disclosure is incomplete or does not meet the applicable framework."
268 }
269 }
270 }
271}
272
273#[cfg(test)]
278#[allow(clippy::unwrap_used)]
279mod tests {
280 use super::*;
281 use datasynth_core::models::audit::{StepStatus, Workpaper, WorkpaperSection};
282 use uuid::Uuid;
283
284 fn make_gen(seed: u64) -> ProcedureStepGenerator {
285 ProcedureStepGenerator::new(seed)
286 }
287
288 fn make_workpaper(proc_type: ProcedureType) -> Workpaper {
289 Workpaper::new(
290 Uuid::new_v4(),
291 "C-100",
292 "Test Workpaper",
293 WorkpaperSection::ControlTesting,
294 )
295 .with_procedure("Test procedure", proc_type)
296 }
297
298 fn team() -> Vec<(String, String)> {
299 vec![
300 ("EMP001".to_string(), "Alice Auditor".to_string()),
301 ("EMP002".to_string(), "Bob Checker".to_string()),
302 ]
303 }
304
305 #[test]
309 fn test_generates_steps() {
310 let wp = make_workpaper(ProcedureType::SubstantiveTest);
311 let mut gen = make_gen(42);
312 let steps = gen.generate_steps(&wp, &team());
313
314 let cfg = ProcedureStepGeneratorConfig::default();
315 let min = cfg.steps_per_workpaper.0 as usize;
316 let max = cfg.steps_per_workpaper.1 as usize;
317 assert!(
318 steps.len() >= min && steps.len() <= max,
319 "expected {min}..={max}, got {}",
320 steps.len()
321 );
322 }
323
324 #[test]
326 fn test_step_completion() {
327 let wp = make_workpaper(ProcedureType::TestOfControls);
328 let config = ProcedureStepGeneratorConfig {
330 steps_per_workpaper: (100, 100),
331 completion_ratio: 0.80,
332 ..Default::default()
333 };
334 let mut gen = ProcedureStepGenerator::with_config(99, config);
335 let steps = gen.generate_steps(&wp, &team());
336
337 let completed = steps
338 .iter()
339 .filter(|s| s.status == StepStatus::Complete)
340 .count();
341 let ratio = completed as f64 / steps.len() as f64;
342 assert!(
344 (0.65..=0.95).contains(&ratio),
345 "completion ratio {ratio:.2} outside expected 65–95%"
346 );
347 }
348
349 #[test]
351 fn test_result_distribution() {
352 let wp = make_workpaper(ProcedureType::SubstantiveTest);
353 let config = ProcedureStepGeneratorConfig {
354 steps_per_workpaper: (200, 200),
355 completion_ratio: 1.0, pass_ratio: 0.85,
357 exception_ratio: 0.10,
358 fail_ratio: 0.05,
359 };
360 let mut gen = ProcedureStepGenerator::with_config(77, config);
361 let steps = gen.generate_steps(&wp, &team());
362
363 let pass_count = steps
364 .iter()
365 .filter(|s| s.result == Some(StepResult::Pass))
366 .count() as f64;
367 let total = steps.len() as f64;
368
369 let pass_ratio = pass_count / total;
371 assert!(
372 (0.70..=1.00).contains(&pass_ratio),
373 "pass ratio {pass_ratio:.2} outside expected 70–100%"
374 );
375 }
376
377 #[test]
379 fn test_procedure_type_alignment() {
380 let wp = make_workpaper(ProcedureType::TestOfControls);
381 let config = ProcedureStepGeneratorConfig {
382 steps_per_workpaper: (50, 50),
383 ..Default::default()
384 };
385 let mut gen = ProcedureStepGenerator::with_config(11, config);
386 let steps = gen.generate_steps(&wp, &team());
387
388 let expected = [
389 StepProcedureType::Reperformance,
390 StepProcedureType::Observation,
391 StepProcedureType::Inquiry,
392 ];
393 for step in &steps {
394 assert!(
395 expected.contains(&step.procedure_type),
396 "unexpected procedure_type {:?} for TestOfControls workpaper",
397 step.procedure_type,
398 );
399 }
400 }
401
402 #[test]
404 fn test_deterministic() {
405 let wp = make_workpaper(ProcedureType::SubstantiveTest);
406
407 let steps_a = ProcedureStepGenerator::new(1234).generate_steps(&wp, &team());
408 let steps_b = ProcedureStepGenerator::new(1234).generate_steps(&wp, &team());
409
410 assert_eq!(steps_a.len(), steps_b.len());
411 for (a, b) in steps_a.iter().zip(steps_b.iter()) {
412 assert_eq!(a.step_number, b.step_number);
413 assert_eq!(a.procedure_type, b.procedure_type);
414 assert_eq!(a.assertion, b.assertion);
415 assert_eq!(a.status, b.status);
416 assert_eq!(a.result, b.result);
417 }
418 }
419}