Skip to main content

datasynth_generators/audit/
procedure_step_generator.rs

1//! Procedure step generator for audit workpapers.
2//!
3//! Generates individual `AuditProcedureStep` records for a given workpaper,
4//! aligned to the workpaper's `ProcedureType` per ISA 330.
5
6use 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/// Configuration for the procedure step generator (ISA 330).
15#[derive(Debug, Clone)]
16pub struct ProcedureStepGeneratorConfig {
17    /// Number of steps to generate per workpaper (min, max)
18    pub steps_per_workpaper: (u32, u32),
19    /// Fraction of completed steps that pass with no exception
20    pub pass_ratio: f64,
21    /// Fraction of completed steps that yield an exception
22    pub exception_ratio: f64,
23    /// Fraction of completed steps that fail (material deviation)
24    pub fail_ratio: f64,
25    /// Fraction of planned steps that are actually performed
26    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
41/// Generator for `AuditProcedureStep` records per ISA 330.
42pub struct ProcedureStepGenerator {
43    /// Seeded random number generator
44    rng: ChaCha8Rng,
45    /// Configuration
46    config: ProcedureStepGeneratorConfig,
47}
48
49impl ProcedureStepGenerator {
50    /// Create a new generator with the given seed and default configuration.
51    pub fn new(seed: u64) -> Self {
52        Self {
53            rng: seeded_rng(seed, 0),
54            config: ProcedureStepGeneratorConfig::default(),
55        }
56    }
57
58    /// Create a new generator with custom configuration.
59    pub fn with_config(seed: u64, config: ProcedureStepGeneratorConfig) -> Self {
60        Self {
61            rng: seeded_rng(seed, 0),
62            config,
63        }
64    }
65
66    /// Generate procedure steps for a workpaper.
67    ///
68    /// # Arguments
69    /// * `workpaper` — The workpaper these steps belong to.
70    /// * `engagement_id` — The engagement UUID (used directly; matches `workpaper.engagement_id`).
71    /// * `team_members` — Slice of `(employee_id, employee_name)` pairs used to assign performers.
72    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        // Build an ordered list of assertions to cycle through.
83        let assertions = self.assertions_for_procedure(workpaper.procedure_type);
84        // Build the procedure types appropriate for this workpaper.
85        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            // Determine whether this step is actually performed.
106            if self.rng.random::<f64>() < self.config.completion_ratio {
107                // Choose a random team member (fall back to a generic performer if none).
108                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                // Pick a performance date within fieldwork — we use a simple offset so
116                // the generator doesn't need the engagement (keeps the API minimal).
117                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    // -------------------------------------------------------------------------
135    // Private helpers
136    // -------------------------------------------------------------------------
137
138    /// Choose assertion cycle based on workpaper procedure type.
139    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    /// Choose step procedure types based on workpaper procedure type.
173    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    /// Build a human-readable step description.
199    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    /// Pick a step result according to configured ratios.
228    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        // Anything above exception_cutoff → Pass (majority).
233
234        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    /// Return a short textual description of the exception.
244    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// =============================================================================
274// Tests
275// =============================================================================
276
277#[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    // -------------------------------------------------------------------------
306
307    /// Count falls within the configured (min, max) range.
308    #[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    /// With the default completion_ratio of 0.90 most steps should be Complete.
325    #[test]
326    fn test_step_completion() {
327        let wp = make_workpaper(ProcedureType::TestOfControls);
328        // Use a large fixed count so the ratio is measurable.
329        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        // Expect within ±15% of the 80% target.
343        assert!(
344            (0.65..=0.95).contains(&ratio),
345            "completion ratio {ratio:.2} outside expected 65–95%"
346        );
347    }
348
349    /// Pass / exception / fail distribution should roughly match configured ratios.
350    #[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, // perform every step so we get full sample
356            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        // Pass ratio should be within ±15% of 85%.
370        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    /// TestOfControls workpapers should only get Reperformance / Observation / Inquiry steps.
378    #[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    /// Same seed produces identical output.
403    #[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}