1use std::collections::HashMap;
11use std::path::PathBuf;
12
13use chrono::Datelike;
14use datasynth_audit_fsm::context::EngagementContext;
15use datasynth_audit_fsm::engine::AuditFsmEngine;
16use datasynth_audit_fsm::error::AuditFsmError;
17use datasynth_audit_fsm::loader::*;
18use datasynth_audit_fsm::schema::GenerationOverlay;
19use rand::SeedableRng;
20use rand_chacha::ChaCha8Rng;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PortfolioConfig {
30 pub engagements: Vec<EngagementSpec>,
32 pub shared_resources: ResourcePool,
34 pub correlation: CorrelationConfig,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct EngagementSpec {
41 pub entity_id: String,
43 pub blueprint: String,
46 pub overlay: String,
49 pub industry: String,
51 pub risk_profile: RiskProfile,
53 pub seed: u64,
55}
56
57#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum RiskProfile {
61 High,
62 Medium,
63 Low,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ResourcePool {
69 pub roles: HashMap<String, ResourceSlot>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ResourceSlot {
76 pub count: usize,
78 pub hours_per_person: f64,
80 pub unavailable_periods: Vec<(String, String)>,
84}
85
86impl ResourceSlot {
87 pub fn effective_hours_per_person(&self) -> f64 {
89 let unavailable_hours: f64 = self
90 .unavailable_periods
91 .iter()
92 .map(|(start, end)| {
93 let start_date = chrono::NaiveDate::parse_from_str(start, "%Y-%m-%d");
94 let end_date = chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d");
95 match (start_date, end_date) {
96 (Ok(s), Ok(e)) => {
97 let mut days = 0;
99 let mut d = s;
100 while d <= e {
101 let wd = d.weekday();
102 if wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun {
103 days += 1;
104 }
105 d += chrono::Duration::days(1);
106 }
107 days as f64 * 8.0 }
109 _ => 0.0,
110 }
111 })
112 .sum();
113 (self.hours_per_person - unavailable_hours).max(0.0)
114 }
115}
116
117impl ResourcePool {
118 pub fn total_hours(&self, role: &str) -> f64 {
120 self.roles
121 .get(role)
122 .map(|s| s.count as f64 * s.effective_hours_per_person())
123 .unwrap_or(0.0)
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CorrelationConfig {
130 pub systemic_finding_probability: f64,
133 pub industry_correlation: f64,
135}
136
137impl Default for CorrelationConfig {
138 fn default() -> Self {
139 Self {
140 systemic_finding_probability: 0.3,
141 industry_correlation: 0.5,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize)]
152pub struct PortfolioReport {
153 pub engagement_summaries: Vec<EngagementSummary>,
155 pub total_hours: f64,
157 pub total_cost: f64,
159 pub resource_utilization: HashMap<String, f64>,
161 pub scheduling_conflicts: Vec<SchedulingConflict>,
163 pub systemic_findings: Vec<SystemicFinding>,
165 pub risk_heatmap: Vec<RiskHeatmapEntry>,
167}
168
169#[derive(Debug, Clone, Serialize)]
171pub struct EngagementSummary {
172 pub entity_id: String,
174 pub blueprint: String,
176 pub events: usize,
178 pub artifacts: usize,
180 pub hours: f64,
182 pub cost: f64,
184 pub findings_count: usize,
186 pub completion_rate: f64,
188}
189
190#[derive(Debug, Clone, Serialize)]
192pub struct SchedulingConflict {
193 pub role: String,
195 pub required_hours: f64,
197 pub available_hours: f64,
199 pub engagements_affected: Vec<String>,
201}
202
203#[derive(Debug, Clone, Serialize)]
205pub struct SystemicFinding {
206 pub finding_type: String,
208 pub industry: String,
210 pub affected_entities: Vec<String>,
212}
213
214#[derive(Debug, Clone, Serialize)]
216pub struct RiskHeatmapEntry {
217 pub entity_id: String,
219 pub category: String,
221 pub score: f64,
223}
224
225fn resolve_blueprint(name: &str) -> Result<BlueprintWithPreconditions, AuditFsmError> {
230 match name {
231 "fsa" | "builtin:fsa" => BlueprintWithPreconditions::load_builtin_fsa(),
232 "ia" | "builtin:ia" => BlueprintWithPreconditions::load_builtin_ia(),
233 path => BlueprintWithPreconditions::load_from_file(PathBuf::from(path)),
234 }
235}
236
237fn resolve_overlay(name: &str) -> Result<GenerationOverlay, AuditFsmError> {
238 match name {
239 "default" | "builtin:default" => {
240 load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Default))
241 }
242 "thorough" | "builtin:thorough" => {
243 load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Thorough))
244 }
245 "rushed" | "builtin:rushed" => {
246 load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Rushed))
247 }
248 "retail" | "builtin:retail" => {
249 load_overlay(&OverlaySource::Builtin(BuiltinOverlay::IndustryRetail))
250 }
251 "manufacturing" | "builtin:manufacturing" => load_overlay(&OverlaySource::Builtin(
252 BuiltinOverlay::IndustryManufacturing,
253 )),
254 "financial_services" | "builtin:financial_services" => load_overlay(
255 &OverlaySource::Builtin(BuiltinOverlay::IndustryFinancialServices),
256 ),
257 path => load_overlay(&OverlaySource::Custom(PathBuf::from(path))),
258 }
259}
260
261pub fn simulate_portfolio(config: &PortfolioConfig) -> Result<PortfolioReport, AuditFsmError> {
271 let mut summaries = Vec::new();
272 let mut total_role_hours: HashMap<String, f64> = HashMap::new();
273 let mut findings_by_industry: HashMap<String, Vec<(String, String)>> = HashMap::new();
275
276 for spec in &config.engagements {
278 let bwp = resolve_blueprint(&spec.blueprint)?;
279 let overlay = resolve_overlay(&spec.overlay)?;
280 let rng = ChaCha8Rng::seed_from_u64(spec.seed);
281 let mut engine = AuditFsmEngine::new(bwp.clone(), overlay.clone(), rng);
282 let ctx = EngagementContext::demo();
283
284 let result = engine.run_engagement(&ctx)?;
285
286 let mut eng_hours = 0.0;
288 let mut eng_cost = 0.0;
289 for phase in &bwp.blueprint.phases {
290 for proc in &phase.procedures {
291 if result.procedure_states.contains_key(&proc.id) {
292 let h = overlay.resource_costs.effective_hours(proc);
293 eng_hours += h;
294 eng_cost += overlay.resource_costs.procedure_cost(proc);
295 let role = proc
297 .required_roles
298 .first()
299 .map(|r| r.as_str())
300 .unwrap_or("audit_staff");
301 *total_role_hours.entry(role.to_string()).or_default() += h;
302 }
303 }
304 }
305
306 let findings_count = result.artifacts.findings.len();
308 if findings_count > 0 {
309 let mut seen_types = std::collections::HashSet::new();
312 for finding in &result.artifacts.findings {
313 let finding_type = format!("{:?}", finding.finding_type)
314 .to_lowercase()
315 .replace(' ', "_");
316 if seen_types.insert(finding_type.clone()) {
317 findings_by_industry
318 .entry(spec.industry.clone())
319 .or_default()
320 .push((spec.entity_id.clone(), finding_type));
321 }
322 }
323 }
324
325 let completed = result
326 .procedure_states
327 .values()
328 .filter(|s| s.as_str() == "completed" || s.as_str() == "closed")
329 .count();
330 let total_procs = result.procedure_states.len();
331
332 summaries.push(EngagementSummary {
333 entity_id: spec.entity_id.clone(),
334 blueprint: spec.blueprint.clone(),
335 events: result.event_log.len(),
336 artifacts: result.artifacts.total_artifacts(),
337 hours: eng_hours,
338 cost: eng_cost,
339 findings_count,
340 completion_rate: if total_procs > 0 {
341 completed as f64 / total_procs as f64
342 } else {
343 0.0
344 },
345 });
346 }
347
348 let mut conflicts = Vec::new();
350 for (role, required) in &total_role_hours {
351 let available = config.shared_resources.total_hours(role);
352 if available > 0.0 && *required > available {
353 conflicts.push(SchedulingConflict {
354 role: role.clone(),
355 required_hours: *required,
356 available_hours: available,
357 engagements_affected: summaries.iter().map(|s| s.entity_id.clone()).collect(),
358 });
359 }
360 }
361
362 let mut systemic = Vec::new();
364 let mut rng = ChaCha8Rng::seed_from_u64(12345);
365 for (industry, findings) in &findings_by_industry {
366 if findings.len() >= 2 {
367 let roll: f64 = rand::RngExt::random(&mut rng);
368 if roll < config.correlation.systemic_finding_probability {
369 systemic.push(SystemicFinding {
370 finding_type: "systemic_control_deficiency".to_string(),
371 industry: industry.clone(),
372 affected_entities: findings.iter().map(|(e, _)| e.clone()).collect(),
373 });
374 }
375 }
376 }
377
378 let mut heatmap = Vec::new();
380 for spec in &config.engagements {
381 let risk_score = match spec.risk_profile {
382 RiskProfile::High => 0.9,
383 RiskProfile::Medium => 0.5,
384 RiskProfile::Low => 0.2,
385 };
386 heatmap.push(RiskHeatmapEntry {
387 entity_id: spec.entity_id.clone(),
388 category: spec.industry.clone(),
389 score: risk_score,
390 });
391 }
392
393 let mut utilization = HashMap::new();
395 for (role, required) in &total_role_hours {
396 let available = config.shared_resources.total_hours(role);
397 if available > 0.0 {
398 utilization.insert(role.clone(), *required / available);
399 }
400 }
401
402 let total_hours = summaries.iter().map(|s| s.hours).sum();
403 let total_cost = summaries.iter().map(|s| s.cost).sum();
404
405 Ok(PortfolioReport {
406 engagement_summaries: summaries,
407 total_hours,
408 total_cost,
409 resource_utilization: utilization,
410 scheduling_conflicts: conflicts,
411 systemic_findings: systemic,
412 risk_heatmap: heatmap,
413 })
414}
415
416#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn default_pool() -> ResourcePool {
425 let mut roles = HashMap::new();
426 roles.insert(
427 "engagement_partner".into(),
428 ResourceSlot {
429 count: 2,
430 hours_per_person: 2000.0,
431 unavailable_periods: vec![],
432 },
433 );
434 roles.insert(
435 "audit_manager".into(),
436 ResourceSlot {
437 count: 3,
438 hours_per_person: 1800.0,
439 unavailable_periods: vec![],
440 },
441 );
442 roles.insert(
443 "audit_senior".into(),
444 ResourceSlot {
445 count: 5,
446 hours_per_person: 1600.0,
447 unavailable_periods: vec![],
448 },
449 );
450 roles.insert(
451 "audit_staff".into(),
452 ResourceSlot {
453 count: 8,
454 hours_per_person: 1600.0,
455 unavailable_periods: vec![],
456 },
457 );
458 ResourcePool { roles }
459 }
460
461 fn fsa_spec(entity: &str, seed: u64) -> EngagementSpec {
462 EngagementSpec {
463 entity_id: entity.into(),
464 blueprint: "fsa".into(),
465 overlay: "default".into(),
466 industry: "financial_services".into(),
467 risk_profile: RiskProfile::Medium,
468 seed,
469 }
470 }
471
472 #[test]
473 fn test_single_engagement_portfolio() {
474 let config = PortfolioConfig {
475 engagements: vec![fsa_spec("ENTITY_A", 42)],
476 shared_resources: default_pool(),
477 correlation: CorrelationConfig::default(),
478 };
479 let report = simulate_portfolio(&config).unwrap();
480 assert_eq!(report.engagement_summaries.len(), 1);
481 let summary = &report.engagement_summaries[0];
482 assert_eq!(summary.entity_id, "ENTITY_A");
483 assert_eq!(summary.blueprint, "fsa");
484 assert!(summary.events > 0);
485 assert!(summary.hours > 0.0);
486 assert!(summary.cost > 0.0);
487 assert!(report.total_hours > 0.0);
488 assert!(report.total_cost > 0.0);
489 }
490
491 #[test]
492 fn test_two_fsa_engagements() {
493 let config = PortfolioConfig {
494 engagements: vec![fsa_spec("ENTITY_A", 42), fsa_spec("ENTITY_B", 99)],
495 shared_resources: default_pool(),
496 correlation: CorrelationConfig::default(),
497 };
498 let report = simulate_portfolio(&config).unwrap();
499 assert_eq!(report.engagement_summaries.len(), 2);
500 let ids: Vec<&str> = report
501 .engagement_summaries
502 .iter()
503 .map(|s| s.entity_id.as_str())
504 .collect();
505 assert!(ids.contains(&"ENTITY_A"));
506 assert!(ids.contains(&"ENTITY_B"));
507 let sum_hours: f64 = report.engagement_summaries.iter().map(|s| s.hours).sum();
509 assert!((report.total_hours - sum_hours).abs() < 0.01);
510 }
511
512 #[test]
513 fn test_mixed_fsa_ia_portfolio() {
514 let config = PortfolioConfig {
515 engagements: vec![
516 fsa_spec("FSA_ENTITY", 42),
517 EngagementSpec {
518 entity_id: "IA_ENTITY".into(),
519 blueprint: "ia".into(),
520 overlay: "default".into(),
521 industry: "manufacturing".into(),
522 risk_profile: RiskProfile::High,
523 seed: 77,
524 },
525 ],
526 shared_resources: default_pool(),
527 correlation: CorrelationConfig::default(),
528 };
529 let report = simulate_portfolio(&config).unwrap();
530 assert_eq!(report.engagement_summaries.len(), 2);
531 let blueprints: Vec<&str> = report
532 .engagement_summaries
533 .iter()
534 .map(|s| s.blueprint.as_str())
535 .collect();
536 assert!(blueprints.contains(&"fsa"));
537 assert!(blueprints.contains(&"ia"));
538 }
539
540 #[test]
541 fn test_resource_utilization_computed() {
542 let config = PortfolioConfig {
543 engagements: vec![fsa_spec("ENTITY_A", 42)],
544 shared_resources: default_pool(),
545 correlation: CorrelationConfig::default(),
546 };
547 let report = simulate_portfolio(&config).unwrap();
548 assert!(
550 !report.resource_utilization.is_empty(),
551 "expected non-empty resource utilization"
552 );
553 for util in report.resource_utilization.values() {
554 assert!(*util > 0.0, "utilization should be positive");
555 }
556 }
557
558 #[test]
559 fn test_portfolio_deterministic() {
560 let config = PortfolioConfig {
561 engagements: vec![fsa_spec("ENTITY_A", 42), fsa_spec("ENTITY_B", 99)],
562 shared_resources: default_pool(),
563 correlation: CorrelationConfig::default(),
564 };
565 let report1 = simulate_portfolio(&config).unwrap();
566 let report2 = simulate_portfolio(&config).unwrap();
567
568 assert_eq!(
569 report1.engagement_summaries.len(),
570 report2.engagement_summaries.len()
571 );
572 for (s1, s2) in report1
573 .engagement_summaries
574 .iter()
575 .zip(report2.engagement_summaries.iter())
576 {
577 assert_eq!(s1.entity_id, s2.entity_id);
578 assert_eq!(s1.events, s2.events);
579 assert!((s1.hours - s2.hours).abs() < 0.01);
580 assert!((s1.cost - s2.cost).abs() < 0.01);
581 assert_eq!(s1.findings_count, s2.findings_count);
582 }
583 assert!((report1.total_hours - report2.total_hours).abs() < 0.01);
584 assert!((report1.total_cost - report2.total_cost).abs() < 0.01);
585 }
586
587 #[test]
588 fn test_risk_heatmap_populated() {
589 let config = PortfolioConfig {
590 engagements: vec![fsa_spec("ENTITY_A", 42), fsa_spec("ENTITY_B", 99)],
591 shared_resources: default_pool(),
592 correlation: CorrelationConfig::default(),
593 };
594 let report = simulate_portfolio(&config).unwrap();
595 assert_eq!(
596 report.risk_heatmap.len(),
597 config.engagements.len(),
598 "heatmap entries should match engagement count"
599 );
600 for entry in &report.risk_heatmap {
601 assert!(
602 entry.score > 0.0 && entry.score <= 1.0,
603 "risk score should be in (0, 1]"
604 );
605 }
606 }
607
608 #[test]
609 fn test_portfolio_report_serializes() {
610 let config = PortfolioConfig {
611 engagements: vec![fsa_spec("ENTITY_A", 42)],
612 shared_resources: default_pool(),
613 correlation: CorrelationConfig::default(),
614 };
615 let report = simulate_portfolio(&config).unwrap();
616 let json = serde_json::to_string_pretty(&report).unwrap();
617 assert!(json.contains("ENTITY_A"));
618 assert!(json.contains("total_hours"));
619 assert!(json.contains("risk_heatmap"));
620 let _parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
622 }
623
624 #[test]
625 fn test_unavailable_periods_reduce_hours() {
626 let slot = ResourceSlot {
627 count: 1,
628 hours_per_person: 2000.0,
629 unavailable_periods: vec![("2025-01-06".to_string(), "2025-01-10".to_string())],
631 };
632 let effective = slot.effective_hours_per_person();
633 assert!(
634 (effective - 1960.0).abs() < 0.01,
635 "Expected 1960.0 effective hours (2000 - 5*8), got {}",
636 effective
637 );
638 }
639
640 #[test]
641 fn test_unavailable_periods_weekend_excluded() {
642 let slot = ResourceSlot {
643 count: 1,
644 hours_per_person: 2000.0,
645 unavailable_periods: vec![("2025-01-10".to_string(), "2025-01-12".to_string())],
647 };
648 let effective = slot.effective_hours_per_person();
649 assert!(
650 (effective - 1992.0).abs() < 0.01,
651 "Expected 1992.0 effective hours (2000 - 1*8), got {}",
652 effective
653 );
654 }
655
656 #[test]
657 fn test_pool_total_hours_with_unavailability() {
658 let mut roles = HashMap::new();
659 roles.insert(
660 "audit_staff".into(),
661 ResourceSlot {
662 count: 2,
663 hours_per_person: 1600.0,
664 unavailable_periods: vec![("2025-01-06".to_string(), "2025-01-17".to_string())],
666 },
667 );
668 let pool = ResourcePool { roles };
669 let total = pool.total_hours("audit_staff");
671 let expected = 2.0 * (1600.0 - 80.0);
672 assert!(
673 (total - expected).abs() < 0.01,
674 "Expected {expected}, got {total}"
675 );
676 }
677}