datasynth_generators/audit/
procedure_step_generator.rs1use datasynth_core::utils::seeded_rng;
7use rand::RngExt;
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)]
278mod tests {
279 use super::*;
280 use datasynth_core::models::audit::{StepStatus, Workpaper, WorkpaperSection};
281 use uuid::Uuid;
282
283 fn make_gen(seed: u64) -> ProcedureStepGenerator {
284 ProcedureStepGenerator::new(seed)
285 }
286
287 fn make_workpaper(proc_type: ProcedureType) -> Workpaper {
288 Workpaper::new(
289 Uuid::new_v4(),
290 "C-100",
291 "Test Workpaper",
292 WorkpaperSection::ControlTesting,
293 )
294 .with_procedure("Test procedure", proc_type)
295 }
296
297 fn team() -> Vec<(String, String)> {
298 vec![
299 ("EMP001".to_string(), "Alice Auditor".to_string()),
300 ("EMP002".to_string(), "Bob Checker".to_string()),
301 ]
302 }
303
304 #[test]
308 fn test_generates_steps() {
309 let wp = make_workpaper(ProcedureType::SubstantiveTest);
310 let mut gen = make_gen(42);
311 let steps = gen.generate_steps(&wp, &team());
312
313 let cfg = ProcedureStepGeneratorConfig::default();
314 let min = cfg.steps_per_workpaper.0 as usize;
315 let max = cfg.steps_per_workpaper.1 as usize;
316 assert!(
317 steps.len() >= min && steps.len() <= max,
318 "expected {min}..={max}, got {}",
319 steps.len()
320 );
321 }
322
323 #[test]
325 fn test_step_completion() {
326 let wp = make_workpaper(ProcedureType::TestOfControls);
327 let config = ProcedureStepGeneratorConfig {
329 steps_per_workpaper: (100, 100),
330 completion_ratio: 0.80,
331 ..Default::default()
332 };
333 let mut gen = ProcedureStepGenerator::with_config(99, config);
334 let steps = gen.generate_steps(&wp, &team());
335
336 let completed = steps
337 .iter()
338 .filter(|s| s.status == StepStatus::Complete)
339 .count();
340 let ratio = completed as f64 / steps.len() as f64;
341 assert!(
343 (0.65..=0.95).contains(&ratio),
344 "completion ratio {ratio:.2} outside expected 65–95%"
345 );
346 }
347
348 #[test]
350 fn test_result_distribution() {
351 let wp = make_workpaper(ProcedureType::SubstantiveTest);
352 let config = ProcedureStepGeneratorConfig {
353 steps_per_workpaper: (200, 200),
354 completion_ratio: 1.0, pass_ratio: 0.85,
356 exception_ratio: 0.10,
357 fail_ratio: 0.05,
358 };
359 let mut gen = ProcedureStepGenerator::with_config(77, config);
360 let steps = gen.generate_steps(&wp, &team());
361
362 let pass_count = steps
363 .iter()
364 .filter(|s| s.result == Some(StepResult::Pass))
365 .count() as f64;
366 let total = steps.len() as f64;
367
368 let pass_ratio = pass_count / total;
370 assert!(
371 (0.70..=1.00).contains(&pass_ratio),
372 "pass ratio {pass_ratio:.2} outside expected 70–100%"
373 );
374 }
375
376 #[test]
378 fn test_procedure_type_alignment() {
379 let wp = make_workpaper(ProcedureType::TestOfControls);
380 let config = ProcedureStepGeneratorConfig {
381 steps_per_workpaper: (50, 50),
382 ..Default::default()
383 };
384 let mut gen = ProcedureStepGenerator::with_config(11, config);
385 let steps = gen.generate_steps(&wp, &team());
386
387 let expected = [
388 StepProcedureType::Reperformance,
389 StepProcedureType::Observation,
390 StepProcedureType::Inquiry,
391 ];
392 for step in &steps {
393 assert!(
394 expected.contains(&step.procedure_type),
395 "unexpected procedure_type {:?} for TestOfControls workpaper",
396 step.procedure_type,
397 );
398 }
399 }
400
401 #[test]
403 fn test_deterministic() {
404 let wp = make_workpaper(ProcedureType::SubstantiveTest);
405
406 let steps_a = ProcedureStepGenerator::new(1234).generate_steps(&wp, &team());
407 let steps_b = ProcedureStepGenerator::new(1234).generate_steps(&wp, &team());
408
409 assert_eq!(steps_a.len(), steps_b.len());
410 for (a, b) in steps_a.iter().zip(steps_b.iter()) {
411 assert_eq!(a.step_number, b.step_number);
412 assert_eq!(a.procedure_type, b.procedure_type);
413 assert_eq!(a.assertion, b.assertion);
414 assert_eq!(a.status, b.status);
415 assert_eq!(a.result, b.result);
416 }
417 }
418}