Skip to main content

datasynth_audit_optimizer/
portfolio.rs

1//! Multi-engagement portfolio simulation.
2//!
3//! Runs multiple audit engagements with shared resources, correlated findings,
4//! and consolidated reporting.  The [`simulate_portfolio`] function accepts a
5//! [`PortfolioConfig`] describing the engagements, a shared [`ResourcePool`],
6//! and correlation parameters, then returns a [`PortfolioReport`] with per-
7//! engagement summaries, scheduling conflicts, systemic findings, and a risk
8//! heatmap.
9
10use 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// ---------------------------------------------------------------------------
24// Config types
25// ---------------------------------------------------------------------------
26
27/// Top-level configuration for a portfolio simulation run.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PortfolioConfig {
30    /// Specifications for each engagement to simulate.
31    pub engagements: Vec<EngagementSpec>,
32    /// Shared resource pool across all engagements.
33    pub shared_resources: ResourcePool,
34    /// Correlation settings for cross-engagement finding propagation.
35    pub correlation: CorrelationConfig,
36}
37
38/// Specification of a single engagement within the portfolio.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct EngagementSpec {
41    /// Identifier for the entity being audited.
42    pub entity_id: String,
43    /// Blueprint selector: `"fsa"`, `"ia"`, `"builtin:fsa"`, `"builtin:ia"`,
44    /// or a file path.
45    pub blueprint: String,
46    /// Overlay selector: `"default"`, `"builtin:default"`, `"thorough"`,
47    /// `"rushed"`, or a file path.
48    pub overlay: String,
49    /// Industry classification for cross-engagement correlation.
50    pub industry: String,
51    /// Risk profile of the entity.
52    pub risk_profile: RiskProfile,
53    /// Deterministic RNG seed for this engagement.
54    pub seed: u64,
55}
56
57/// Risk profile for an entity.
58#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum RiskProfile {
61    High,
62    Medium,
63    Low,
64}
65
66/// Pool of shared resources across all engagements.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ResourcePool {
69    /// Slots keyed by role name.
70    pub roles: HashMap<String, ResourceSlot>,
71}
72
73/// A single resource slot (headcount + hours per person).
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ResourceSlot {
76    /// Number of people available in this role.
77    pub count: usize,
78    /// Annual hours available per person.
79    pub hours_per_person: f64,
80    /// Unavailable date ranges as `(start_date, end_date)` pairs (ISO 8601 strings).
81    /// Each pair reduces available hours by the number of business days in the range
82    /// multiplied by 8 hours per day per person.
83    pub unavailable_periods: Vec<(String, String)>,
84}
85
86impl ResourceSlot {
87    /// Compute effective hours per person after subtracting unavailable periods.
88    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                        // Count business days in range.
98                        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 // 8 hours per business day
108                    }
109                    _ => 0.0,
110                }
111            })
112            .sum();
113        (self.hours_per_person - unavailable_hours).max(0.0)
114    }
115}
116
117impl ResourcePool {
118    /// Total hours available for a given role (accounting for unavailable periods).
119    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/// Correlation parameters governing how findings propagate across engagements.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CorrelationConfig {
130    /// Probability that a finding shared by 2+ entities in the same industry
131    /// is flagged as systemic.
132    pub systemic_finding_probability: f64,
133    /// Strength of industry correlation (reserved for future use).
134    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// ---------------------------------------------------------------------------
147// Report types
148// ---------------------------------------------------------------------------
149
150/// Consolidated output of a portfolio simulation.
151#[derive(Debug, Clone, Serialize)]
152pub struct PortfolioReport {
153    /// Per-engagement summaries.
154    pub engagement_summaries: Vec<EngagementSummary>,
155    /// Total hours consumed across all engagements.
156    pub total_hours: f64,
157    /// Total monetary cost across all engagements.
158    pub total_cost: f64,
159    /// Resource utilization ratio per role (required / available).
160    pub resource_utilization: HashMap<String, f64>,
161    /// Roles where demand exceeds supply.
162    pub scheduling_conflicts: Vec<SchedulingConflict>,
163    /// Findings that appear across multiple entities in the same industry.
164    pub systemic_findings: Vec<SystemicFinding>,
165    /// Per-entity risk heat-map entries.
166    pub risk_heatmap: Vec<RiskHeatmapEntry>,
167}
168
169/// Summary of a single engagement within the portfolio.
170#[derive(Debug, Clone, Serialize)]
171pub struct EngagementSummary {
172    /// Entity identifier.
173    pub entity_id: String,
174    /// Blueprint used.
175    pub blueprint: String,
176    /// Number of FSM events emitted.
177    pub events: usize,
178    /// Number of typed artifacts generated.
179    pub artifacts: usize,
180    /// Estimated hours consumed.
181    pub hours: f64,
182    /// Estimated monetary cost.
183    pub cost: f64,
184    /// Number of audit findings generated.
185    pub findings_count: usize,
186    /// Fraction of procedures that reached a terminal state.
187    pub completion_rate: f64,
188}
189
190/// A scheduling conflict where demand for a role exceeds supply.
191#[derive(Debug, Clone, Serialize)]
192pub struct SchedulingConflict {
193    /// Role that is over-subscribed.
194    pub role: String,
195    /// Total hours required across all engagements.
196    pub required_hours: f64,
197    /// Total hours available in the pool.
198    pub available_hours: f64,
199    /// Entity IDs affected by this conflict.
200    pub engagements_affected: Vec<String>,
201}
202
203/// A finding that appears systemically across an industry.
204#[derive(Debug, Clone, Serialize)]
205pub struct SystemicFinding {
206    /// Classification of the finding.
207    pub finding_type: String,
208    /// Industry in which the finding was observed.
209    pub industry: String,
210    /// Entities affected.
211    pub affected_entities: Vec<String>,
212}
213
214/// A single entry in the risk heat-map.
215#[derive(Debug, Clone, Serialize)]
216pub struct RiskHeatmapEntry {
217    /// Entity identifier.
218    pub entity_id: String,
219    /// Risk category (industry).
220    pub category: String,
221    /// Numeric risk score in [0, 1].
222    pub score: f64,
223}
224
225// ---------------------------------------------------------------------------
226// Blueprint / overlay resolution helpers
227// ---------------------------------------------------------------------------
228
229fn 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
261// ---------------------------------------------------------------------------
262// Main simulation function
263// ---------------------------------------------------------------------------
264
265/// Run a portfolio simulation over all configured engagements.
266///
267/// Each engagement is executed sequentially with its own deterministic RNG seed.
268/// After all engagements complete, scheduling conflicts are detected, systemic
269/// findings are propagated, and a consolidated report is returned.
270pub 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    // industry -> [(entity_id, finding_type)]
274    let mut findings_by_industry: HashMap<String, Vec<(String, String)>> = HashMap::new();
275
276    // 1. Run each engagement.
277    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        // Compute hours and cost from blueprint procedures.
287        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                    // Track per-role hours.
296                    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        // Track findings for cross-engagement correlation.
307        let findings_count = result.artifacts.findings.len();
308        if findings_count > 0 {
309            // Extract actual finding types from the generated findings,
310            // deduplicating per-entity to avoid inflating the count.
311            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    // 2. Detect scheduling conflicts.
349    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    // 3. Propagate systemic findings.
363    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    // 4. Build risk heat-map.
379    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    // 5. Resource utilization.
394    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// ---------------------------------------------------------------------------
417// Tests
418// ---------------------------------------------------------------------------
419
420#[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        // Total hours should be the sum of individual hours.
508        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        // At least one role should have non-zero utilization.
549        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        // Roundtrip: deserialize back.
621        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            // 5 business days (Mon-Fri) of unavailability = 40 hours.
630            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            // Range includes weekend: 2025-01-10 (Fri) through 2025-01-12 (Sun) = 1 business day
646            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                // 10 business days per person
665                unavailable_periods: vec![("2025-01-06".to_string(), "2025-01-17".to_string())],
666            },
667        );
668        let pool = ResourcePool { roles };
669        // 10 business days * 8h = 80h subtracted per person; 2 people.
670        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}