use std::collections::HashMap;
use std::path::PathBuf;
use chrono::Datelike;
use datasynth_audit_fsm::context::EngagementContext;
use datasynth_audit_fsm::engine::AuditFsmEngine;
use datasynth_audit_fsm::error::AuditFsmError;
use datasynth_audit_fsm::loader::*;
use datasynth_audit_fsm::schema::GenerationOverlay;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioConfig {
pub engagements: Vec<EngagementSpec>,
pub shared_resources: ResourcePool,
pub correlation: CorrelationConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngagementSpec {
pub entity_id: String,
pub blueprint: String,
pub overlay: String,
pub industry: String,
pub risk_profile: RiskProfile,
pub seed: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskProfile {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcePool {
pub roles: HashMap<String, ResourceSlot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceSlot {
pub count: usize,
pub hours_per_person: f64,
pub unavailable_periods: Vec<(String, String)>,
}
impl ResourceSlot {
pub fn effective_hours_per_person(&self) -> f64 {
let unavailable_hours: f64 = self
.unavailable_periods
.iter()
.map(|(start, end)| {
let start_date = chrono::NaiveDate::parse_from_str(start, "%Y-%m-%d");
let end_date = chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d");
match (start_date, end_date) {
(Ok(s), Ok(e)) => {
let mut days = 0;
let mut d = s;
while d <= e {
let wd = d.weekday();
if wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun {
days += 1;
}
d += chrono::Duration::days(1);
}
days as f64 * 8.0 }
_ => 0.0,
}
})
.sum();
(self.hours_per_person - unavailable_hours).max(0.0)
}
}
impl ResourcePool {
pub fn total_hours(&self, role: &str) -> f64 {
self.roles
.get(role)
.map(|s| s.count as f64 * s.effective_hours_per_person())
.unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorrelationConfig {
pub systemic_finding_probability: f64,
pub industry_correlation: f64,
}
impl Default for CorrelationConfig {
fn default() -> Self {
Self {
systemic_finding_probability: 0.3,
industry_correlation: 0.5,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PortfolioReport {
pub engagement_summaries: Vec<EngagementSummary>,
pub total_hours: f64,
pub total_cost: f64,
pub resource_utilization: HashMap<String, f64>,
pub scheduling_conflicts: Vec<SchedulingConflict>,
pub systemic_findings: Vec<SystemicFinding>,
pub risk_heatmap: Vec<RiskHeatmapEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EngagementSummary {
pub entity_id: String,
pub blueprint: String,
pub events: usize,
pub artifacts: usize,
pub hours: f64,
pub cost: f64,
pub findings_count: usize,
pub completion_rate: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SchedulingConflict {
pub role: String,
pub required_hours: f64,
pub available_hours: f64,
pub engagements_affected: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SystemicFinding {
pub finding_type: String,
pub industry: String,
pub affected_entities: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RiskHeatmapEntry {
pub entity_id: String,
pub category: String,
pub score: f64,
}
fn resolve_blueprint(name: &str) -> Result<BlueprintWithPreconditions, AuditFsmError> {
match name {
"fsa" | "builtin:fsa" => BlueprintWithPreconditions::load_builtin_fsa(),
"ia" | "builtin:ia" => BlueprintWithPreconditions::load_builtin_ia(),
path => BlueprintWithPreconditions::load_from_file(PathBuf::from(path)),
}
}
fn resolve_overlay(name: &str) -> Result<GenerationOverlay, AuditFsmError> {
match name {
"default" | "builtin:default" => {
load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Default))
}
"thorough" | "builtin:thorough" => {
load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Thorough))
}
"rushed" | "builtin:rushed" => {
load_overlay(&OverlaySource::Builtin(BuiltinOverlay::Rushed))
}
"retail" | "builtin:retail" => {
load_overlay(&OverlaySource::Builtin(BuiltinOverlay::IndustryRetail))
}
"manufacturing" | "builtin:manufacturing" => load_overlay(&OverlaySource::Builtin(
BuiltinOverlay::IndustryManufacturing,
)),
"financial_services" | "builtin:financial_services" => load_overlay(
&OverlaySource::Builtin(BuiltinOverlay::IndustryFinancialServices),
),
path => load_overlay(&OverlaySource::Custom(PathBuf::from(path))),
}
}
pub fn simulate_portfolio(config: &PortfolioConfig) -> Result<PortfolioReport, AuditFsmError> {
let mut summaries = Vec::new();
let mut total_role_hours: HashMap<String, f64> = HashMap::new();
let mut findings_by_industry: HashMap<String, Vec<(String, String)>> = HashMap::new();
for spec in &config.engagements {
let bwp = resolve_blueprint(&spec.blueprint)?;
let overlay = resolve_overlay(&spec.overlay)?;
let rng = ChaCha8Rng::seed_from_u64(spec.seed);
let mut engine = AuditFsmEngine::new(bwp.clone(), overlay.clone(), rng);
let ctx = EngagementContext::demo();
let result = engine.run_engagement(&ctx)?;
let mut eng_hours = 0.0;
let mut eng_cost = 0.0;
for phase in &bwp.blueprint.phases {
for proc in &phase.procedures {
if result.procedure_states.contains_key(&proc.id) {
let h = overlay.resource_costs.effective_hours(proc);
eng_hours += h;
eng_cost += overlay.resource_costs.procedure_cost(proc);
let role = proc
.required_roles
.first()
.map(|r| r.as_str())
.unwrap_or("audit_staff");
*total_role_hours.entry(role.to_string()).or_default() += h;
}
}
}
let findings_count = result.artifacts.findings.len();
if findings_count > 0 {
let mut seen_types = std::collections::HashSet::new();
for finding in &result.artifacts.findings {
let finding_type = format!("{:?}", finding.finding_type)
.to_lowercase()
.replace(' ', "_");
if seen_types.insert(finding_type.clone()) {
findings_by_industry
.entry(spec.industry.clone())
.or_default()
.push((spec.entity_id.clone(), finding_type));
}
}
}
let completed = result
.procedure_states
.values()
.filter(|s| s.as_str() == "completed" || s.as_str() == "closed")
.count();
let total_procs = result.procedure_states.len();
summaries.push(EngagementSummary {
entity_id: spec.entity_id.clone(),
blueprint: spec.blueprint.clone(),
events: result.event_log.len(),
artifacts: result.artifacts.total_artifacts(),
hours: eng_hours,
cost: eng_cost,
findings_count,
completion_rate: if total_procs > 0 {
completed as f64 / total_procs as f64
} else {
0.0
},
});
}
let mut conflicts = Vec::new();
for (role, required) in &total_role_hours {
let available = config.shared_resources.total_hours(role);
if available > 0.0 && *required > available {
conflicts.push(SchedulingConflict {
role: role.clone(),
required_hours: *required,
available_hours: available,
engagements_affected: summaries.iter().map(|s| s.entity_id.clone()).collect(),
});
}
}
let mut systemic = Vec::new();
let mut rng = ChaCha8Rng::seed_from_u64(12345);
for (industry, findings) in &findings_by_industry {
if findings.len() >= 2 {
let roll: f64 = rand::RngExt::random(&mut rng);
if roll < config.correlation.systemic_finding_probability {
systemic.push(SystemicFinding {
finding_type: "systemic_control_deficiency".to_string(),
industry: industry.clone(),
affected_entities: findings.iter().map(|(e, _)| e.clone()).collect(),
});
}
}
}
let mut heatmap = Vec::new();
for spec in &config.engagements {
let risk_score = match spec.risk_profile {
RiskProfile::High => 0.9,
RiskProfile::Medium => 0.5,
RiskProfile::Low => 0.2,
};
heatmap.push(RiskHeatmapEntry {
entity_id: spec.entity_id.clone(),
category: spec.industry.clone(),
score: risk_score,
});
}
let mut utilization = HashMap::new();
for (role, required) in &total_role_hours {
let available = config.shared_resources.total_hours(role);
if available > 0.0 {
utilization.insert(role.clone(), *required / available);
}
}
let total_hours = summaries.iter().map(|s| s.hours).sum();
let total_cost = summaries.iter().map(|s| s.cost).sum();
Ok(PortfolioReport {
engagement_summaries: summaries,
total_hours,
total_cost,
resource_utilization: utilization,
scheduling_conflicts: conflicts,
systemic_findings: systemic,
risk_heatmap: heatmap,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn default_pool() -> ResourcePool {
let mut roles = HashMap::new();
roles.insert(
"engagement_partner".into(),
ResourceSlot {
count: 2,
hours_per_person: 2000.0,
unavailable_periods: vec![],
},
);
roles.insert(
"audit_manager".into(),
ResourceSlot {
count: 3,
hours_per_person: 1800.0,
unavailable_periods: vec![],
},
);
roles.insert(
"audit_senior".into(),
ResourceSlot {
count: 5,
hours_per_person: 1600.0,
unavailable_periods: vec![],
},
);
roles.insert(
"audit_staff".into(),
ResourceSlot {
count: 8,
hours_per_person: 1600.0,
unavailable_periods: vec![],
},
);
ResourcePool { roles }
}
fn fsa_spec(entity: &str, seed: u64) -> EngagementSpec {
EngagementSpec {
entity_id: entity.into(),
blueprint: "fsa".into(),
overlay: "default".into(),
industry: "financial_services".into(),
risk_profile: RiskProfile::Medium,
seed,
}
}
#[test]
fn test_single_engagement_portfolio() {
let config = PortfolioConfig {
engagements: vec![fsa_spec("ENTITY_A", 42)],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report = simulate_portfolio(&config).unwrap();
assert_eq!(report.engagement_summaries.len(), 1);
let summary = &report.engagement_summaries[0];
assert_eq!(summary.entity_id, "ENTITY_A");
assert_eq!(summary.blueprint, "fsa");
assert!(summary.events > 0);
assert!(summary.hours > 0.0);
assert!(summary.cost > 0.0);
assert!(report.total_hours > 0.0);
assert!(report.total_cost > 0.0);
}
#[test]
fn test_two_fsa_engagements() {
let config = PortfolioConfig {
engagements: vec![fsa_spec("ENTITY_A", 42), fsa_spec("ENTITY_B", 99)],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report = simulate_portfolio(&config).unwrap();
assert_eq!(report.engagement_summaries.len(), 2);
let ids: Vec<&str> = report
.engagement_summaries
.iter()
.map(|s| s.entity_id.as_str())
.collect();
assert!(ids.contains(&"ENTITY_A"));
assert!(ids.contains(&"ENTITY_B"));
let sum_hours: f64 = report.engagement_summaries.iter().map(|s| s.hours).sum();
assert!((report.total_hours - sum_hours).abs() < 0.01);
}
#[test]
fn test_mixed_fsa_ia_portfolio() {
let config = PortfolioConfig {
engagements: vec![
fsa_spec("FSA_ENTITY", 42),
EngagementSpec {
entity_id: "IA_ENTITY".into(),
blueprint: "ia".into(),
overlay: "default".into(),
industry: "manufacturing".into(),
risk_profile: RiskProfile::High,
seed: 77,
},
],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report = simulate_portfolio(&config).unwrap();
assert_eq!(report.engagement_summaries.len(), 2);
let blueprints: Vec<&str> = report
.engagement_summaries
.iter()
.map(|s| s.blueprint.as_str())
.collect();
assert!(blueprints.contains(&"fsa"));
assert!(blueprints.contains(&"ia"));
}
#[test]
fn test_resource_utilization_computed() {
let config = PortfolioConfig {
engagements: vec![fsa_spec("ENTITY_A", 42)],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report = simulate_portfolio(&config).unwrap();
assert!(
!report.resource_utilization.is_empty(),
"expected non-empty resource utilization"
);
for (_role, util) in &report.resource_utilization {
assert!(*util > 0.0, "utilization should be positive");
}
}
#[test]
fn test_portfolio_deterministic() {
let config = PortfolioConfig {
engagements: vec![fsa_spec("ENTITY_A", 42), fsa_spec("ENTITY_B", 99)],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report1 = simulate_portfolio(&config).unwrap();
let report2 = simulate_portfolio(&config).unwrap();
assert_eq!(
report1.engagement_summaries.len(),
report2.engagement_summaries.len()
);
for (s1, s2) in report1
.engagement_summaries
.iter()
.zip(report2.engagement_summaries.iter())
{
assert_eq!(s1.entity_id, s2.entity_id);
assert_eq!(s1.events, s2.events);
assert!((s1.hours - s2.hours).abs() < 0.01);
assert!((s1.cost - s2.cost).abs() < 0.01);
assert_eq!(s1.findings_count, s2.findings_count);
}
assert!((report1.total_hours - report2.total_hours).abs() < 0.01);
assert!((report1.total_cost - report2.total_cost).abs() < 0.01);
}
#[test]
fn test_risk_heatmap_populated() {
let config = PortfolioConfig {
engagements: vec![fsa_spec("ENTITY_A", 42), fsa_spec("ENTITY_B", 99)],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report = simulate_portfolio(&config).unwrap();
assert_eq!(
report.risk_heatmap.len(),
config.engagements.len(),
"heatmap entries should match engagement count"
);
for entry in &report.risk_heatmap {
assert!(
entry.score > 0.0 && entry.score <= 1.0,
"risk score should be in (0, 1]"
);
}
}
#[test]
fn test_portfolio_report_serializes() {
let config = PortfolioConfig {
engagements: vec![fsa_spec("ENTITY_A", 42)],
shared_resources: default_pool(),
correlation: CorrelationConfig::default(),
};
let report = simulate_portfolio(&config).unwrap();
let json = serde_json::to_string_pretty(&report).unwrap();
assert!(json.contains("ENTITY_A"));
assert!(json.contains("total_hours"));
assert!(json.contains("risk_heatmap"));
let _parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_unavailable_periods_reduce_hours() {
let slot = ResourceSlot {
count: 1,
hours_per_person: 2000.0,
unavailable_periods: vec![("2025-01-06".to_string(), "2025-01-10".to_string())],
};
let effective = slot.effective_hours_per_person();
assert!(
(effective - 1960.0).abs() < 0.01,
"Expected 1960.0 effective hours (2000 - 5*8), got {}",
effective
);
}
#[test]
fn test_unavailable_periods_weekend_excluded() {
let slot = ResourceSlot {
count: 1,
hours_per_person: 2000.0,
unavailable_periods: vec![("2025-01-10".to_string(), "2025-01-12".to_string())],
};
let effective = slot.effective_hours_per_person();
assert!(
(effective - 1992.0).abs() < 0.01,
"Expected 1992.0 effective hours (2000 - 1*8), got {}",
effective
);
}
#[test]
fn test_pool_total_hours_with_unavailability() {
let mut roles = HashMap::new();
roles.insert(
"audit_staff".into(),
ResourceSlot {
count: 2,
hours_per_person: 1600.0,
unavailable_periods: vec![("2025-01-06".to_string(), "2025-01-17".to_string())],
},
);
let pool = ResourcePool { roles };
let total = pool.total_hours("audit_staff");
let expected = 2.0 * (1600.0 - 80.0);
assert!(
(total - expected).abs() < 0.01,
"Expected {expected}, got {total}"
);
}
}