Skip to main content

datasynth_generators/compliance/
procedure_generator.rs

1//! Audit procedure template generator.
2//!
3//! Generates audit procedure instances from ISA/PCAOB standards,
4//! including sampling parameters, assertion coverage, and step definitions.
5
6use chrono::NaiveDate;
7use rand::Rng;
8use rand_chacha::ChaCha8Rng;
9use serde::Serialize;
10
11use datasynth_core::models::compliance::StandardCategory;
12use datasynth_core::utils::seeded_rng;
13use datasynth_standards::registry::StandardRegistry;
14
15/// An audit procedure step.
16#[derive(Debug, Clone, Serialize)]
17pub struct ProcedureStep {
18    pub step_number: u32,
19    pub step_type: String,
20    pub description: String,
21    pub assertion: String,
22}
23
24/// An audit procedure instance.
25#[derive(Debug, Clone, Serialize)]
26pub struct AuditProcedureRecord {
27    pub procedure_id: String,
28    pub standard_id: String,
29    pub procedure_type: String,
30    pub title: String,
31    pub description: String,
32    pub sampling_method: String,
33    pub sample_size: u32,
34    pub confidence_level: f64,
35    pub tolerable_misstatement: f64,
36    pub assertions_tested: Vec<String>,
37    pub jurisdiction: String,
38    pub reference_date: String,
39    pub steps: Vec<ProcedureStep>,
40}
41
42/// Configuration for procedure generation.
43#[derive(Debug, Clone)]
44pub struct ProcedureGeneratorConfig {
45    pub procedures_per_standard: usize,
46    pub sampling_method: String,
47    pub confidence_level: f64,
48    pub tolerable_misstatement: f64,
49}
50
51impl Default for ProcedureGeneratorConfig {
52    fn default() -> Self {
53        Self {
54            procedures_per_standard: 3,
55            sampling_method: "statistical".to_string(),
56            confidence_level: 0.95,
57            tolerable_misstatement: 0.05,
58        }
59    }
60}
61
62/// Procedure type templates derived from ISA standards.
63const PROCEDURE_TEMPLATES: &[(&str, &str, &[&str])] = &[
64    (
65        "substantive_detail",
66        "Test of Details",
67        &["Occurrence", "Completeness", "Accuracy"],
68    ),
69    (
70        "analytical",
71        "Analytical Procedure",
72        &["Accuracy", "ValuationAndAllocation", "Completeness"],
73    ),
74    (
75        "controls_test",
76        "Test of Operating Effectiveness",
77        &["Occurrence", "Cutoff", "Classification"],
78    ),
79    (
80        "inspection",
81        "Inspection of Records/Documents",
82        &[
83            "Existence",
84            "RightsAndObligations",
85            "ValuationAndAllocation",
86        ],
87    ),
88    (
89        "confirmation",
90        "External Confirmation",
91        &["Existence", "CompletenessBalance", "RightsAndObligations"],
92    ),
93    (
94        "recalculation",
95        "Recalculation",
96        &["Accuracy", "ValuationAndAllocation"],
97    ),
98    (
99        "observation",
100        "Observation of Process",
101        &["Occurrence", "Completeness"],
102    ),
103    (
104        "inquiry",
105        "Inquiry of Management",
106        &["CompletenessDisclosure", "AccuracyAndValuation"],
107    ),
108    (
109        "cutoff_test",
110        "Cutoff Testing",
111        &["Cutoff", "Occurrence", "Completeness"],
112    ),
113];
114
115/// Generator for audit procedure instances.
116pub struct ProcedureGenerator {
117    rng: ChaCha8Rng,
118    config: ProcedureGeneratorConfig,
119    counter: u32,
120}
121
122impl ProcedureGenerator {
123    /// Creates a new generator with the given seed.
124    pub fn new(seed: u64) -> Self {
125        Self {
126            rng: seeded_rng(seed, 0),
127            config: ProcedureGeneratorConfig::default(),
128            counter: 0,
129        }
130    }
131
132    /// Creates a generator with custom configuration.
133    pub fn with_config(seed: u64, config: ProcedureGeneratorConfig) -> Self {
134        Self {
135            rng: seeded_rng(seed, 0),
136            config,
137            counter: 0,
138        }
139    }
140
141    /// Generates audit procedures for a set of standards in a jurisdiction.
142    pub fn generate_procedures(
143        &mut self,
144        registry: &StandardRegistry,
145        jurisdiction: &str,
146        reference_date: NaiveDate,
147    ) -> Vec<AuditProcedureRecord> {
148        let standards = registry.standards_for_jurisdiction(jurisdiction, reference_date);
149        let mut procedures = Vec::new();
150
151        for std in &standards {
152            // Only generate procedures for auditing and regulatory standards
153            let is_audit = matches!(
154                std.category,
155                StandardCategory::AuditingStandard | StandardCategory::RegulatoryRequirement
156            );
157            if !is_audit {
158                continue;
159            }
160
161            let count = self
162                .config
163                .procedures_per_standard
164                .min(PROCEDURE_TEMPLATES.len());
165            for i in 0..count {
166                let template_idx = (self.counter as usize + i) % PROCEDURE_TEMPLATES.len();
167                let (proc_type, title, assertions) = PROCEDURE_TEMPLATES[template_idx];
168
169                self.counter += 1;
170                let procedure_id = format!("PROC-{:05}", self.counter);
171
172                let sample_size = self.compute_sample_size();
173
174                let steps = self.generate_steps(proc_type, assertions);
175
176                procedures.push(AuditProcedureRecord {
177                    procedure_id,
178                    standard_id: std.id.as_str().to_string(),
179                    procedure_type: proc_type.to_string(),
180                    title: format!("{} — {}", title, std.title),
181                    description: format!(
182                        "{} procedure for {} compliance in jurisdiction {}",
183                        title, std.id, jurisdiction
184                    ),
185                    sampling_method: self.config.sampling_method.clone(),
186                    sample_size,
187                    confidence_level: self.config.confidence_level,
188                    tolerable_misstatement: self.config.tolerable_misstatement,
189                    assertions_tested: assertions.iter().map(|a| a.to_string()).collect(),
190                    jurisdiction: jurisdiction.to_string(),
191                    reference_date: reference_date.to_string(),
192                    steps,
193                });
194            }
195        }
196
197        procedures
198    }
199
200    fn compute_sample_size(&mut self) -> u32 {
201        // Simplified sample size based on confidence level
202        let base = if self.config.confidence_level >= 0.95 {
203            58 // ~95% confidence for 5% tolerable
204        } else if self.config.confidence_level >= 0.90 {
205            38
206        } else {
207            25
208        };
209
210        // Add randomness ±20%
211        let variation = self.rng.random_range(0.8f64..1.2f64);
212        (base as f64 * variation) as u32
213    }
214
215    fn generate_steps(&self, proc_type: &str, assertions: &[&str]) -> Vec<ProcedureStep> {
216        let base_steps: &[(&str, &str)] = match proc_type {
217            "substantive_detail" => &[
218                (
219                    "selection",
220                    "Select sample from population using statistical sampling",
221                ),
222                (
223                    "inspection",
224                    "Inspect supporting documentation for each item",
225                ),
226                ("verification", "Verify amounts agree to source documents"),
227                (
228                    "evaluation",
229                    "Evaluate exceptions and project to population",
230                ),
231            ],
232            "analytical" => &[
233                (
234                    "expectation",
235                    "Develop independent expectation using prior-year data and trends",
236                ),
237                ("comparison", "Compare recorded amounts to expectation"),
238                (
239                    "investigation",
240                    "Investigate significant variances exceeding threshold",
241                ),
242                (
243                    "conclusion",
244                    "Form conclusion on reasonableness of recorded amounts",
245                ),
246            ],
247            "controls_test" => &[
248                (
249                    "selection",
250                    "Select sample of transactions processed during the period",
251                ),
252                ("inspection", "Inspect evidence of control operation"),
253                ("reperformance", "Reperform the control procedure"),
254                (
255                    "evaluation",
256                    "Evaluate control exceptions and determine impact",
257                ),
258            ],
259            "confirmation" => &[
260                ("selection", "Select accounts for external confirmation"),
261                ("dispatch", "Send confirmation requests to third parties"),
262                ("receipt", "Receive and evaluate confirmation responses"),
263                (
264                    "alternative",
265                    "Perform alternative procedures for non-responses",
266                ),
267            ],
268            "cutoff_test" => &[
269                ("selection", "Select transactions around period end"),
270                ("inspection", "Inspect dates on source documents"),
271                ("verification", "Verify recording in correct period"),
272                ("evaluation", "Evaluate cutoff exceptions"),
273            ],
274            _ => &[
275                ("planning", "Plan the procedure scope and approach"),
276                ("execution", "Execute the procedure steps"),
277                ("evaluation", "Evaluate results and form conclusion"),
278            ],
279        };
280
281        base_steps
282            .iter()
283            .enumerate()
284            .map(|(i, (step_type, desc))| {
285                let assertion = if i < assertions.len() {
286                    assertions[i].to_string()
287                } else {
288                    assertions[0].to_string()
289                };
290
291                ProcedureStep {
292                    step_number: (i + 1) as u32,
293                    step_type: step_type.to_string(),
294                    description: desc.to_string(),
295                    assertion,
296                }
297            })
298            .collect()
299    }
300}
301
302#[cfg(test)]
303#[allow(clippy::unwrap_used)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_generate_procedures() {
309        let registry = StandardRegistry::with_built_in();
310        let mut gen = ProcedureGenerator::new(42);
311        let date = NaiveDate::from_ymd_opt(2025, 6, 30).unwrap();
312        let procedures = gen.generate_procedures(&registry, "US", date);
313        assert!(
314            !procedures.is_empty(),
315            "Should generate procedures for US standards"
316        );
317
318        // Each procedure should have steps
319        for proc in &procedures {
320            assert!(!proc.steps.is_empty());
321            assert!(!proc.assertions_tested.is_empty());
322        }
323    }
324}