datasynth_audit_optimizer/
group_audit.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct GroupAuditConfig {
26 pub group_entity: String,
28 pub components: Vec<ComponentConfig>,
30 pub group_blueprint: String,
32 pub overlay: String,
34 pub group_materiality: f64,
36 pub base_seed: u64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ComponentConfig {
43 pub entity_id: String,
45 pub component_type: ComponentType,
47 pub blueprint: String,
49 pub overlay: String,
51 pub component_materiality: f64,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum ComponentType {
58 Significant,
60 NonSignificant,
62 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#[derive(Debug, Clone, Serialize)]
82pub struct GroupAuditReport {
83 pub group_entity: String,
85 pub component_results: Vec<ComponentResult>,
87 pub aggregated_findings: usize,
89 pub aggregated_misstatements: Decimal,
91 pub group_coverage: f64,
93 pub components_with_findings: usize,
95 pub group_opinion_risk: String,
97}
98
99#[derive(Debug, Clone, Serialize)]
101pub struct ComponentResult {
102 pub entity_id: String,
104 pub component_type: String,
106 pub events: usize,
108 pub artifacts: usize,
110 pub findings: usize,
112 pub completion_rate: f64,
114}
115
116fn 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
160pub 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 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 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 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 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
274fn 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#[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 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 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}