use std::path::PathBuf;
use datasynth_audit_fsm::context::EngagementContext;
use datasynth_audit_fsm::engine::AuditFsmEngine;
use datasynth_audit_fsm::error::AuditFsmError;
use datasynth_audit_fsm::loader::*;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YoyChainConfig {
pub entity_id: String,
pub blueprint: String,
pub overlay: String,
pub years: usize,
pub base_seed: u64,
pub finding_carry_rate: f64,
}
impl Default for YoyChainConfig {
fn default() -> Self {
Self {
entity_id: "ENTITY_01".into(),
blueprint: "fsa".into(),
overlay: "default".into(),
years: 3,
base_seed: 42,
finding_carry_rate: 0.3,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct YoyEngagementResult {
pub year: i32,
pub events: usize,
pub artifacts: usize,
pub findings_new: usize,
pub findings_carried: usize,
pub completion_rate: f64,
pub duration_hours: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct YoyChainReport {
pub entity_id: String,
pub years: Vec<YoyEngagementResult>,
pub finding_trend: Vec<(i32, usize)>,
pub duration_trend: Vec<(i32, 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(),
"kpmg" | "builtin:kpmg" => BlueprintWithPreconditions::load_builtin_kpmg(),
"pwc" | "builtin:pwc" => BlueprintWithPreconditions::load_builtin_pwc(),
"deloitte" | "builtin:deloitte" => BlueprintWithPreconditions::load_builtin_deloitte(),
"ey_gam_lite" | "builtin:ey_gam_lite" => {
BlueprintWithPreconditions::load_builtin_ey_gam_lite()
}
path => BlueprintWithPreconditions::load_from_file(PathBuf::from(path)),
}
}
fn resolve_overlay(
name: &str,
) -> Result<datasynth_audit_fsm::schema::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 run_yoy_chain(config: &YoyChainConfig) -> Result<YoyChainReport, AuditFsmError> {
assert!(
config.years >= 2 && config.years <= 10,
"years must be in 2..=10, got {}",
config.years
);
let bwp = resolve_blueprint(&config.blueprint)?;
let overlay = resolve_overlay(&config.overlay)?;
let base_year = 2025_i32.saturating_sub(config.years as i32);
let mut year_results = Vec::with_capacity(config.years);
let mut prior_findings: usize = 0;
for i in 0..config.years {
let year = base_year + i as i32 + 1;
let seed = config.base_seed.wrapping_add(i as u64);
let rng = ChaCha8Rng::seed_from_u64(seed);
let mut engine = AuditFsmEngine::new(bwp.clone(), overlay.clone(), rng);
let mut ctx = EngagementContext::demo();
ctx.fiscal_year = year;
ctx.company_code = config.entity_id.clone();
let carried = ((prior_findings as f64) * config.finding_carry_rate).round() as usize;
if carried > 0 {
ctx.anomaly_refs = (0..carried)
.map(|j| format!("CARRY-{}-{:03}", year - 1, j + 1))
.collect();
}
let result = engine.run_engagement(&ctx)?;
let new_findings = result.artifacts.findings.len();
let total_procs = result.procedure_states.len();
let completed = result
.procedure_states
.values()
.filter(|s| s.as_str() == "completed" || s.as_str() == "closed")
.count();
year_results.push(YoyEngagementResult {
year,
events: result.event_log.len(),
artifacts: result.artifacts.total_artifacts(),
findings_new: new_findings,
findings_carried: carried,
completion_rate: if total_procs > 0 {
completed as f64 / total_procs as f64
} else {
0.0
},
duration_hours: result.total_duration_hours,
});
prior_findings = new_findings + carried;
}
let finding_trend: Vec<(i32, usize)> = year_results
.iter()
.map(|r| (r.year, r.findings_new + r.findings_carried))
.collect();
let duration_trend: Vec<(i32, f64)> = year_results
.iter()
.map(|r| (r.year, r.duration_hours))
.collect();
Ok(YoyChainReport {
entity_id: config.entity_id.clone(),
years: year_results,
finding_trend,
duration_trend,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chain_produces_n_years() {
let config = YoyChainConfig {
entity_id: "TEST_ENTITY".into(),
blueprint: "fsa".into(),
overlay: "default".into(),
years: 3,
base_seed: 42,
finding_carry_rate: 0.3,
};
let report = run_yoy_chain(&config).unwrap();
assert_eq!(report.years.len(), 3, "expected 3 year results");
assert_eq!(report.entity_id, "TEST_ENTITY");
assert_eq!(report.finding_trend.len(), 3);
assert_eq!(report.duration_trend.len(), 3);
for window in report.years.windows(2) {
assert_eq!(
window[1].year,
window[0].year + 1,
"years should be sequential"
);
}
for yr in &report.years {
assert!(yr.events > 0, "year {} should have events", yr.year);
assert!(yr.artifacts > 0, "year {} should have artifacts", yr.year);
}
}
#[test]
fn test_findings_carry_forward() {
let config = YoyChainConfig {
entity_id: "CARRY_TEST".into(),
blueprint: "fsa".into(),
overlay: "default".into(),
years: 4,
base_seed: 99,
finding_carry_rate: 1.0, };
let report = run_yoy_chain(&config).unwrap();
assert_eq!(
report.years[0].findings_carried, 0,
"year 1 should have 0 carried findings"
);
let y1_new = report.years[0].findings_new;
if y1_new > 0 {
assert!(
report.years[1].findings_carried > 0,
"with 100% carry rate and {} new findings in year 1, year 2 should carry some",
y1_new
);
}
}
#[test]
fn test_trends_are_computed() {
let config = YoyChainConfig {
entity_id: "TREND_TEST".into(),
blueprint: "fsa".into(),
overlay: "default".into(),
years: 2,
base_seed: 77,
finding_carry_rate: 0.5,
};
let report = run_yoy_chain(&config).unwrap();
assert_eq!(report.finding_trend.len(), report.years.len());
for (i, (year, total)) in report.finding_trend.iter().enumerate() {
assert_eq!(*year, report.years[i].year);
assert_eq!(
*total,
report.years[i].findings_new + report.years[i].findings_carried
);
}
assert_eq!(report.duration_trend.len(), report.years.len());
for (i, (year, hours)) in report.duration_trend.iter().enumerate() {
assert_eq!(*year, report.years[i].year);
assert!(
(*hours - report.years[i].duration_hours).abs() < 0.001,
"duration mismatch for year {}",
year
);
}
}
}