Skip to main content

datasynth_audit_optimizer/
group_audit.rs

1//! ISA 600 group audit simulation.
2//!
3//! Runs independent FSM engagements for each component entity, then
4//! consolidates findings, coverage, and misstatement amounts at the group
5//! level.  Components marked [`ComponentType::NotInScope`] are skipped
6//! entirely, while significant components receive full FSM execution.
7
8use std::path::PathBuf;
9
10use datasynth_audit_fsm::context::EngagementContext;
11use datasynth_audit_fsm::engine::AuditFsmEngine;
12use datasynth_audit_fsm::error::AuditFsmError;
13use datasynth_audit_fsm::loader::*;
14use rand::SeedableRng;
15use rand_chacha::ChaCha8Rng;
16use rust_decimal::Decimal;
17use serde::{Deserialize, Serialize};
18
19// ---------------------------------------------------------------------------
20// Config types
21// ---------------------------------------------------------------------------
22
23/// Top-level configuration for a group audit simulation.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct GroupAuditConfig {
26    /// Identifier for the group (parent) entity.
27    pub group_entity: String,
28    /// Component configurations.
29    pub components: Vec<ComponentConfig>,
30    /// Blueprint used for the group-level engagement.
31    pub group_blueprint: String,
32    /// Overlay used for the group-level engagement.
33    pub overlay: String,
34    /// Group materiality threshold.
35    pub group_materiality: f64,
36    /// Base RNG seed; component `i` uses `base_seed + i + 1`.
37    pub base_seed: u64,
38}
39
40/// Configuration for a single component within the group.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ComponentConfig {
43    /// Entity identifier for the component.
44    pub entity_id: String,
45    /// Significance classification.
46    pub component_type: ComponentType,
47    /// Blueprint selector for the component's engagement.
48    pub blueprint: String,
49    /// Overlay selector.
50    pub overlay: String,
51    /// Component materiality (should be <= group materiality).
52    pub component_materiality: f64,
53}
54
55/// ISA 600 component significance classification.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum ComponentType {
58    /// Significant component — requires full audit or specified procedures.
59    Significant,
60    /// Non-significant component — analytical procedures at group level.
61    NonSignificant,
62    /// Not in scope — excluded from the group audit.
63    NotInScope,
64}
65
66impl std::fmt::Display for ComponentType {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            ComponentType::Significant => write!(f, "significant"),
70            ComponentType::NonSignificant => write!(f, "non_significant"),
71            ComponentType::NotInScope => write!(f, "not_in_scope"),
72        }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Report types
78// ---------------------------------------------------------------------------
79
80/// Consolidated group audit report.
81#[derive(Debug, Clone, Serialize)]
82pub struct GroupAuditReport {
83    /// Group (parent) entity identifier.
84    pub group_entity: String,
85    /// Per-component results.
86    pub component_results: Vec<ComponentResult>,
87    /// Total findings aggregated across all components.
88    pub aggregated_findings: usize,
89    /// Total misstatement amount (sum of component finding amounts).
90    pub aggregated_misstatements: Decimal,
91    /// Fraction of group covered by significant components.
92    pub group_coverage: f64,
93    /// Number of components with at least one finding.
94    pub components_with_findings: usize,
95    /// Overall group opinion risk assessment.
96    pub group_opinion_risk: String,
97}
98
99/// Result of a single component's engagement.
100#[derive(Debug, Clone, Serialize)]
101pub struct ComponentResult {
102    /// Entity identifier.
103    pub entity_id: String,
104    /// Component significance type.
105    pub component_type: String,
106    /// FSM events emitted.
107    pub events: usize,
108    /// Typed artifacts generated.
109    pub artifacts: usize,
110    /// Audit findings generated.
111    pub findings: usize,
112    /// Fraction of procedures that completed.
113    pub completion_rate: f64,
114}
115
116// ---------------------------------------------------------------------------
117// Blueprint / overlay resolution
118// ---------------------------------------------------------------------------
119
120fn resolve_blueprint(name: &str) -> Result<BlueprintWithPreconditions, AuditFsmError> {
121    match name {
122        "fsa" | "builtin:fsa" => BlueprintWithPreconditions::load_builtin_fsa(),
123        "ia" | "builtin:ia" => BlueprintWithPreconditions::load_builtin_ia(),
124        "kpmg" | "builtin:kpmg" => BlueprintWithPreconditions::load_builtin_kpmg(),
125        "pwc" | "builtin:pwc" => BlueprintWithPreconditions::load_builtin_pwc(),
126        "deloitte" | "builtin:deloitte" => BlueprintWithPreconditions::load_builtin_deloitte(),
127        "ey_gam_lite" | "builtin:ey_gam_lite" => {
128            BlueprintWithPreconditions::load_builtin_ey_gam_lite()
129        }
130        path => BlueprintWithPreconditions::load_from_file(PathBuf::from(path)),
131    }
132}
133
134fn resolve_overlay(
135    name: &str,
136) -> Result<datasynth_audit_fsm::schema::GenerationOverlay, AuditFsmError> {
137    match name {
138        "default" | "builtin:default" => {
139            load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Default))
140        }
141        "thorough" | "builtin:thorough" => {
142            load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Thorough))
143        }
144        "rushed" | "builtin:rushed" => {
145            load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Rushed))
146        }
147        "retail" | "builtin:retail" => {
148            load_overlay(&OverlaySource::Builtin(BuiltinOverlay::IndustryRetail))
149        }
150        "manufacturing" | "builtin:manufacturing" => load_overlay(&OverlaySource::Builtin(
151            BuiltinOverlay::IndustryManufacturing,
152        )),
153        "financial_services" | "builtin:financial_services" => load_overlay(
154            &OverlaySource::Builtin(BuiltinOverlay::IndustryFinancialServices),
155        ),
156        path => load_overlay(&OverlaySource::Custom(PathBuf::from(path))),
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Main entry point
162// ---------------------------------------------------------------------------
163
164/// Run a group audit simulation.
165///
166/// Each in-scope component entity runs its own FSM engagement independently.
167/// Components marked [`ComponentType::NotInScope`] are skipped.  After all
168/// component engagements complete, findings are aggregated, group coverage
169/// is computed, and an overall opinion risk is assessed.
170///
171/// # Errors
172///
173/// Returns an error if any blueprint/overlay cannot be loaded or if the
174/// engine fails for any in-scope component.
175pub fn run_group_audit(config: &GroupAuditConfig) -> Result<GroupAuditReport, AuditFsmError> {
176    let mut component_results = Vec::with_capacity(config.components.len());
177    let mut total_findings: usize = 0;
178    let mut total_misstatement = Decimal::ZERO;
179    let mut components_with_findings: usize = 0;
180    let mut significant_count: usize = 0;
181    let mut in_scope_count: usize = 0;
182
183    for (i, comp) in config.components.iter().enumerate() {
184        // Skip not-in-scope components entirely.
185        if comp.component_type == ComponentType::NotInScope {
186            component_results.push(ComponentResult {
187                entity_id: comp.entity_id.clone(),
188                component_type: comp.component_type.to_string(),
189                events: 0,
190                artifacts: 0,
191                findings: 0,
192                completion_rate: 0.0,
193            });
194            continue;
195        }
196
197        in_scope_count += 1;
198        if comp.component_type == ComponentType::Significant {
199            significant_count += 1;
200        }
201
202        let bwp = resolve_blueprint(&comp.blueprint)?;
203        let overlay = resolve_overlay(&comp.overlay)?;
204        let seed = config.base_seed.wrapping_add(i as u64 + 1);
205        let rng = ChaCha8Rng::seed_from_u64(seed);
206
207        let mut engine = AuditFsmEngine::new(bwp, overlay, rng);
208
209        let mut ctx = EngagementContext::demo();
210        ctx.company_code = comp.entity_id.clone();
211
212        let result = engine.run_engagement(&ctx)?;
213
214        let findings = result.artifacts.findings.len();
215        let total_procs = result.procedure_states.len();
216        let completed = result
217            .procedure_states
218            .values()
219            .filter(|s| s.as_str() == "completed" || s.as_str() == "closed")
220            .count();
221
222        if findings > 0 {
223            components_with_findings += 1;
224        }
225        total_findings += findings;
226
227        // Accumulate misstatement amounts from findings.
228        for finding in &result.artifacts.findings {
229            if let Some(amount) = finding.monetary_impact {
230                total_misstatement += amount.abs();
231            }
232        }
233
234        component_results.push(ComponentResult {
235            entity_id: comp.entity_id.clone(),
236            component_type: comp.component_type.to_string(),
237            events: result.event_log.len(),
238            artifacts: result.artifacts.total_artifacts(),
239            findings,
240            completion_rate: if total_procs > 0 {
241                completed as f64 / total_procs as f64
242            } else {
243                0.0
244            },
245        });
246    }
247
248    // Group coverage: significant / in-scope.
249    let group_coverage = if in_scope_count > 0 {
250        significant_count as f64 / in_scope_count as f64
251    } else {
252        0.0
253    };
254
255    // Assess group opinion risk.
256    let group_opinion_risk = assess_opinion_risk(
257        total_findings,
258        total_misstatement,
259        config.group_materiality,
260        group_coverage,
261    );
262
263    Ok(GroupAuditReport {
264        group_entity: config.group_entity.clone(),
265        component_results,
266        aggregated_findings: total_findings,
267        aggregated_misstatements: total_misstatement,
268        group_coverage,
269        components_with_findings,
270        group_opinion_risk,
271    })
272}
273
274/// Determine the group opinion risk level based on aggregate metrics.
275fn assess_opinion_risk(
276    total_findings: usize,
277    total_misstatement: Decimal,
278    group_materiality: f64,
279    group_coverage: f64,
280) -> String {
281    let mat_decimal =
282        Decimal::from_f64_retain(group_materiality).unwrap_or_else(|| Decimal::new(1_000_000, 0));
283
284    if total_misstatement > mat_decimal || total_findings > 10 {
285        "high".to_string()
286    } else if group_coverage < 0.5 || total_findings > 5 {
287        "medium".to_string()
288    } else {
289        "low".to_string()
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn make_group_config() -> GroupAuditConfig {
302        GroupAuditConfig {
303            group_entity: "GROUP_PARENT".into(),
304            components: vec![
305                ComponentConfig {
306                    entity_id: "COMP_A".into(),
307                    component_type: ComponentType::Significant,
308                    blueprint: "fsa".into(),
309                    overlay: "default".into(),
310                    component_materiality: 50_000.0,
311                },
312                ComponentConfig {
313                    entity_id: "COMP_B".into(),
314                    component_type: ComponentType::NonSignificant,
315                    blueprint: "fsa".into(),
316                    overlay: "default".into(),
317                    component_materiality: 100_000.0,
318                },
319                ComponentConfig {
320                    entity_id: "COMP_C".into(),
321                    component_type: ComponentType::NotInScope,
322                    blueprint: "fsa".into(),
323                    overlay: "default".into(),
324                    component_materiality: 200_000.0,
325                },
326            ],
327            group_blueprint: "fsa".into(),
328            overlay: "default".into(),
329            group_materiality: 500_000.0,
330            base_seed: 42,
331        }
332    }
333
334    #[test]
335    fn test_group_with_three_components() {
336        let config = make_group_config();
337        let report = run_group_audit(&config).unwrap();
338
339        assert_eq!(report.group_entity, "GROUP_PARENT");
340        assert_eq!(report.component_results.len(), 3);
341
342        // All three should appear in results, but only two are in-scope.
343        let in_scope: Vec<&ComponentResult> = report
344            .component_results
345            .iter()
346            .filter(|c| c.events > 0)
347            .collect();
348        assert_eq!(
349            in_scope.len(),
350            2,
351            "expected 2 in-scope component results with events"
352        );
353    }
354
355    #[test]
356    fn test_significant_component_coverage() {
357        let config = make_group_config();
358        let report = run_group_audit(&config).unwrap();
359
360        // 1 Significant out of 2 in-scope = 0.5 coverage.
361        assert!(
362            (report.group_coverage - 0.5).abs() < 0.01,
363            "expected 50% coverage, got {}",
364            report.group_coverage
365        );
366    }
367
368    #[test]
369    fn test_findings_aggregation() {
370        let config = make_group_config();
371        let report = run_group_audit(&config).unwrap();
372
373        let sum: usize = report.component_results.iter().map(|c| c.findings).sum();
374        assert_eq!(
375            report.aggregated_findings, sum,
376            "aggregated findings should equal sum of component findings"
377        );
378    }
379
380    #[test]
381    fn test_not_in_scope_skipped() {
382        let config = make_group_config();
383        let report = run_group_audit(&config).unwrap();
384
385        let comp_c = report
386            .component_results
387            .iter()
388            .find(|c| c.entity_id == "COMP_C")
389            .expect("COMP_C should be in results");
390
391        assert_eq!(
392            comp_c.events, 0,
393            "not-in-scope component should have 0 events"
394        );
395        assert_eq!(
396            comp_c.artifacts, 0,
397            "not-in-scope component should have 0 artifacts"
398        );
399        assert_eq!(
400            comp_c.findings, 0,
401            "not-in-scope component should have 0 findings"
402        );
403        assert_eq!(
404            comp_c.component_type, "not_in_scope",
405            "component type should be not_in_scope"
406        );
407    }
408}