Skip to main content

datasynth_generators/audit/
evidence_generator.rs

1//! Evidence generator for audit engagements.
2//!
3//! Generates audit evidence with appropriate reliability assessments,
4//! source classifications, and cross-references per ISA 500.
5
6use chrono::{Datelike, Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use uuid::Uuid;
11
12use datasynth_core::models::audit::{
13    Assertion, AuditEngagement, AuditEvidence, EvidenceSource, EvidenceType, ReliabilityAssessment,
14    ReliabilityLevel, Workpaper,
15};
16
17/// Context for generating coherent audit evidence.
18///
19/// When provided, evidence type selection, reliability weighting, and amount
20/// anchoring are driven by the workpaper's risk assessment context rather
21/// than random generation.
22#[derive(Debug, Clone, Default)]
23pub struct EvidenceContext {
24    /// CRA risk level for the workpaper's account area (High -> more external evidence).
25    pub risk_level: Option<String>, // "High", "Moderate", "Low"
26    /// Real account balance for the workpaper's GL area (anchors evidence amounts).
27    pub account_balance: Option<f64>,
28    /// Primary assertion being tested (Existence -> confirmation, Completeness -> analytical).
29    pub assertion: Option<String>,
30}
31
32/// Configuration for evidence generation.
33#[derive(Debug, Clone)]
34pub struct EvidenceGeneratorConfig {
35    /// Evidence pieces per workpaper (min, max)
36    pub evidence_per_workpaper: (u32, u32),
37    /// Probability of external third-party evidence
38    pub external_third_party_probability: f64,
39    /// Probability of high reliability evidence
40    pub high_reliability_probability: f64,
41    /// Probability of AI extraction
42    pub ai_extraction_probability: f64,
43    /// File size range in bytes (min, max)
44    pub file_size_range: (u64, u64),
45    /// Period end date used for evidence document dates (e.g., statement_date, document_date).
46    /// Defaults to 2025-12-31 if not set.
47    pub period_end_date: Option<NaiveDate>,
48}
49
50impl Default for EvidenceGeneratorConfig {
51    fn default() -> Self {
52        Self {
53            evidence_per_workpaper: (1, 5),
54            external_third_party_probability: 0.20,
55            high_reliability_probability: 0.40,
56            ai_extraction_probability: 0.15,
57            file_size_range: (10_000, 5_000_000),
58            period_end_date: None,
59        }
60    }
61}
62
63/// Generator for audit evidence.
64pub struct EvidenceGenerator {
65    rng: ChaCha8Rng,
66    config: EvidenceGeneratorConfig,
67    evidence_counter: u32,
68}
69
70impl EvidenceGenerator {
71    /// Create a new generator with the given seed.
72    pub fn new(seed: u64) -> Self {
73        Self {
74            rng: seeded_rng(seed, 0),
75            config: EvidenceGeneratorConfig::default(),
76            evidence_counter: 0,
77        }
78    }
79
80    /// Create a new generator with custom configuration.
81    pub fn with_config(seed: u64, config: EvidenceGeneratorConfig) -> Self {
82        Self {
83            rng: seeded_rng(seed, 0),
84            config,
85            evidence_counter: 0,
86        }
87    }
88
89    /// Generate evidence for a workpaper.
90    pub fn generate_evidence_for_workpaper(
91        &mut self,
92        workpaper: &Workpaper,
93        team_members: &[String],
94        base_date: NaiveDate,
95    ) -> Vec<AuditEvidence> {
96        let count = self.rng.random_range(
97            self.config.evidence_per_workpaper.0..=self.config.evidence_per_workpaper.1,
98        );
99
100        (0..count)
101            .map(|i| {
102                self.generate_evidence(
103                    workpaper.engagement_id,
104                    Some(workpaper.workpaper_id),
105                    &workpaper.assertions_tested,
106                    team_members,
107                    base_date + Duration::days(i as i64),
108                )
109            })
110            .collect()
111    }
112
113    /// Generate evidence for a workpaper with risk/assertion/balance context.
114    ///
115    /// When the [`EvidenceContext`] provides an assertion, evidence types are
116    /// weighted to match audit methodology (e.g. Existence favours
117    /// confirmations; Completeness favours analytical procedures).  When a
118    /// risk level is provided, high-risk areas receive a higher proportion of
119    /// external third-party evidence.  When an account balance is provided,
120    /// AI-extracted amounts are anchored to the real GL balance.
121    pub fn generate_evidence_for_workpaper_with_context(
122        &mut self,
123        workpaper: &Workpaper,
124        team_members: &[String],
125        base_date: NaiveDate,
126        context: &EvidenceContext,
127    ) -> Vec<AuditEvidence> {
128        let count = self.rng.random_range(
129            self.config.evidence_per_workpaper.0..=self.config.evidence_per_workpaper.1,
130        );
131
132        (0..count)
133            .map(|i| {
134                self.generate_evidence_with_context(
135                    workpaper.engagement_id,
136                    Some(workpaper.workpaper_id),
137                    &workpaper.assertions_tested,
138                    team_members,
139                    base_date + Duration::days(i as i64),
140                    context,
141                )
142            })
143            .collect()
144    }
145
146    /// Generate a single piece of evidence with assertion/risk/balance context.
147    fn generate_evidence_with_context(
148        &mut self,
149        engagement_id: Uuid,
150        workpaper_id: Option<Uuid>,
151        assertions: &[Assertion],
152        team_members: &[String],
153        obtained_date: NaiveDate,
154        context: &EvidenceContext,
155    ) -> AuditEvidence {
156        self.evidence_counter += 1;
157
158        // Use context-aware type selection when assertion is provided.
159        let (evidence_type, source_type) =
160            if context.assertion.is_some() || context.risk_level.is_some() {
161                self.select_evidence_type_and_source_with_context(context)
162            } else {
163                self.select_evidence_type_and_source()
164            };
165
166        let title = self.generate_evidence_title(evidence_type);
167        let mut evidence = AuditEvidence::new(engagement_id, evidence_type, source_type, &title);
168        evidence.evidence_ref = format!("EV-{:06}", self.evidence_counter);
169
170        let description = self.generate_evidence_description(evidence_type, source_type);
171        evidence = evidence.with_description(&description);
172
173        let obtainer = self.select_team_member(team_members);
174        evidence = evidence.with_obtained_by(&obtainer, obtained_date);
175
176        let file_size = self
177            .rng
178            .random_range(self.config.file_size_range.0..=self.config.file_size_range.1);
179        let file_path = self.generate_file_path(evidence_type, self.evidence_counter);
180        let file_hash = format!("sha256:{:064x}", self.rng.random::<u128>());
181        evidence = evidence.with_file_info(&file_path, &file_hash, file_size);
182
183        let reliability = self.generate_reliability_assessment(source_type);
184        evidence = evidence.with_reliability(reliability);
185
186        if assertions.is_empty() {
187            evidence = evidence.with_assertions(vec![self.random_assertion()]);
188        } else {
189            evidence = evidence.with_assertions(assertions.to_vec());
190        }
191
192        if let Some(wp_id) = workpaper_id {
193            evidence.link_workpaper(wp_id);
194        }
195
196        // AI extraction with balance-anchored amounts when available.
197        if self.rng.random::<f64>() < self.config.ai_extraction_probability {
198            let terms = if let Some(balance) = context.account_balance {
199                self.generate_ai_terms_anchored(evidence_type, balance)
200            } else {
201                self.generate_ai_terms(evidence_type)
202            };
203            let confidence = self.rng.random_range(0.75..0.98);
204            let summary = self.generate_ai_summary(evidence_type);
205            evidence = evidence.with_ai_extraction(terms, confidence, &summary);
206        }
207
208        evidence
209    }
210
211    /// Generate a single piece of evidence.
212    pub fn generate_evidence(
213        &mut self,
214        engagement_id: Uuid,
215        workpaper_id: Option<Uuid>,
216        assertions: &[Assertion],
217        team_members: &[String],
218        obtained_date: NaiveDate,
219    ) -> AuditEvidence {
220        self.evidence_counter += 1;
221
222        // Determine evidence type and source
223        let (evidence_type, source_type) = self.select_evidence_type_and_source();
224        let title = self.generate_evidence_title(evidence_type);
225
226        let mut evidence = AuditEvidence::new(engagement_id, evidence_type, source_type, &title);
227
228        evidence.evidence_ref = format!("EV-{:06}", self.evidence_counter);
229
230        // Set description
231        let description = self.generate_evidence_description(evidence_type, source_type);
232        evidence = evidence.with_description(&description);
233
234        // Set obtained by
235        let obtainer = self.select_team_member(team_members);
236        evidence = evidence.with_obtained_by(&obtainer, obtained_date);
237
238        // Set file info
239        let file_size = self
240            .rng
241            .random_range(self.config.file_size_range.0..=self.config.file_size_range.1);
242        let file_path = self.generate_file_path(evidence_type, self.evidence_counter);
243        let file_hash = format!("sha256:{:064x}", self.rng.random::<u128>());
244        evidence = evidence.with_file_info(&file_path, &file_hash, file_size);
245
246        // Set reliability assessment
247        let reliability = self.generate_reliability_assessment(source_type);
248        evidence = evidence.with_reliability(reliability);
249
250        // Set assertions
251        if assertions.is_empty() {
252            evidence = evidence.with_assertions(vec![self.random_assertion()]);
253        } else {
254            evidence = evidence.with_assertions(assertions.to_vec());
255        }
256
257        // Link to workpaper if provided
258        if let Some(wp_id) = workpaper_id {
259            evidence.link_workpaper(wp_id);
260        }
261
262        // Maybe add AI extraction
263        if self.rng.random::<f64>() < self.config.ai_extraction_probability {
264            let terms = self.generate_ai_terms(evidence_type);
265            let confidence = self.rng.random_range(0.75..0.98);
266            let summary = self.generate_ai_summary(evidence_type);
267            evidence = evidence.with_ai_extraction(terms, confidence, &summary);
268        }
269
270        evidence
271    }
272
273    /// Generate evidence for an entire engagement.
274    pub fn generate_evidence_for_engagement(
275        &mut self,
276        engagement: &AuditEngagement,
277        workpapers: &[Workpaper],
278        team_members: &[String],
279    ) -> Vec<AuditEvidence> {
280        let mut all_evidence = Vec::new();
281
282        for workpaper in workpapers {
283            let evidence = self.generate_evidence_for_workpaper(
284                workpaper,
285                team_members,
286                workpaper.preparer_date,
287            );
288            all_evidence.extend(evidence);
289        }
290
291        // Add some standalone evidence not linked to specific workpapers
292        let standalone_count = self.rng.random_range(5..15);
293        for i in 0..standalone_count {
294            let date = engagement.fieldwork_start + Duration::days(i as i64 * 3);
295            let evidence =
296                self.generate_evidence(engagement.engagement_id, None, &[], team_members, date);
297            all_evidence.push(evidence);
298        }
299
300        all_evidence
301    }
302
303    /// Select evidence type and source.
304    fn select_evidence_type_and_source(&mut self) -> (EvidenceType, EvidenceSource) {
305        let is_external = self.rng.random::<f64>() < self.config.external_third_party_probability;
306
307        if is_external {
308            let external_types = [
309                (
310                    EvidenceType::Confirmation,
311                    EvidenceSource::ExternalThirdParty,
312                ),
313                (
314                    EvidenceType::BankStatement,
315                    EvidenceSource::ExternalThirdParty,
316                ),
317                (
318                    EvidenceType::LegalLetter,
319                    EvidenceSource::ExternalThirdParty,
320                ),
321                (
322                    EvidenceType::Contract,
323                    EvidenceSource::ExternalClientProvided,
324                ),
325            ];
326            let idx = self.rng.random_range(0..external_types.len());
327            external_types[idx]
328        } else {
329            let internal_types = [
330                (
331                    EvidenceType::Document,
332                    EvidenceSource::InternalClientPrepared,
333                ),
334                (
335                    EvidenceType::Invoice,
336                    EvidenceSource::InternalClientPrepared,
337                ),
338                (
339                    EvidenceType::SystemExtract,
340                    EvidenceSource::InternalClientPrepared,
341                ),
342                (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
343                (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
344                (
345                    EvidenceType::MeetingMinutes,
346                    EvidenceSource::InternalClientPrepared,
347                ),
348                (EvidenceType::Email, EvidenceSource::InternalClientPrepared),
349            ];
350            let idx = self.rng.random_range(0..internal_types.len());
351            internal_types[idx]
352        }
353    }
354
355    /// Select evidence type and source with assertion/risk context.
356    ///
357    /// Assertion drives the preferred evidence types:
358    /// - Existence/Occurrence -> Confirmation, PhysicalObservation, BankStatement
359    /// - Completeness -> Analysis, SystemExtract, Recalculation
360    /// - Valuation -> SpecialistReport, Recalculation, Analysis
361    ///
362    /// High risk increases the proportion of ExternalThirdParty sources.
363    fn select_evidence_type_and_source_with_context(
364        &mut self,
365        context: &EvidenceContext,
366    ) -> (EvidenceType, EvidenceSource) {
367        // Determine external probability: high risk = 40%, moderate = 25%, default = 20%.
368        let external_prob = match context.risk_level.as_deref() {
369            Some("High") => 0.40,
370            Some("Moderate") => 0.25,
371            _ => self.config.external_third_party_probability,
372        };
373
374        // Build assertion-matched type pools.
375        let assertion_str = context.assertion.as_deref().unwrap_or("");
376
377        let preferred_external: Vec<(EvidenceType, EvidenceSource)> = match assertion_str {
378            s if s.contains("Existence") || s.contains("Occurrence") => vec![
379                (
380                    EvidenceType::Confirmation,
381                    EvidenceSource::ExternalThirdParty,
382                ),
383                (
384                    EvidenceType::BankStatement,
385                    EvidenceSource::ExternalThirdParty,
386                ),
387                (
388                    EvidenceType::PhysicalObservation,
389                    EvidenceSource::AuditorPrepared,
390                ),
391            ],
392            s if s.contains("Valuation") => vec![
393                (
394                    EvidenceType::SpecialistReport,
395                    EvidenceSource::ExternalThirdParty,
396                ),
397                (
398                    EvidenceType::Confirmation,
399                    EvidenceSource::ExternalThirdParty,
400                ),
401            ],
402            _ => vec![
403                (
404                    EvidenceType::Confirmation,
405                    EvidenceSource::ExternalThirdParty,
406                ),
407                (
408                    EvidenceType::BankStatement,
409                    EvidenceSource::ExternalThirdParty,
410                ),
411                (
412                    EvidenceType::LegalLetter,
413                    EvidenceSource::ExternalThirdParty,
414                ),
415                (
416                    EvidenceType::Contract,
417                    EvidenceSource::ExternalClientProvided,
418                ),
419            ],
420        };
421
422        let preferred_internal: Vec<(EvidenceType, EvidenceSource)> = match assertion_str {
423            s if s.contains("Completeness") => vec![
424                (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
425                (
426                    EvidenceType::SystemExtract,
427                    EvidenceSource::InternalClientPrepared,
428                ),
429                (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
430            ],
431            s if s.contains("Valuation") => vec![
432                (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
433                (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
434                (
435                    EvidenceType::SystemExtract,
436                    EvidenceSource::InternalClientPrepared,
437                ),
438            ],
439            s if s.contains("Existence") || s.contains("Occurrence") => vec![
440                (
441                    EvidenceType::Document,
442                    EvidenceSource::InternalClientPrepared,
443                ),
444                (
445                    EvidenceType::Invoice,
446                    EvidenceSource::InternalClientPrepared,
447                ),
448                (
449                    EvidenceType::SystemExtract,
450                    EvidenceSource::InternalClientPrepared,
451                ),
452            ],
453            _ => vec![
454                (
455                    EvidenceType::Document,
456                    EvidenceSource::InternalClientPrepared,
457                ),
458                (
459                    EvidenceType::Invoice,
460                    EvidenceSource::InternalClientPrepared,
461                ),
462                (
463                    EvidenceType::SystemExtract,
464                    EvidenceSource::InternalClientPrepared,
465                ),
466                (EvidenceType::Analysis, EvidenceSource::AuditorPrepared),
467                (EvidenceType::Recalculation, EvidenceSource::AuditorPrepared),
468                (
469                    EvidenceType::MeetingMinutes,
470                    EvidenceSource::InternalClientPrepared,
471                ),
472                (EvidenceType::Email, EvidenceSource::InternalClientPrepared),
473            ],
474        };
475
476        let is_external = self.rng.random::<f64>() < external_prob;
477
478        if is_external && !preferred_external.is_empty() {
479            let idx = self.rng.random_range(0..preferred_external.len());
480            preferred_external[idx]
481        } else if !preferred_internal.is_empty() {
482            let idx = self.rng.random_range(0..preferred_internal.len());
483            preferred_internal[idx]
484        } else {
485            self.select_evidence_type_and_source()
486        }
487    }
488
489    /// Generate AI-extracted terms anchored to a real account balance.
490    fn generate_ai_terms_anchored(
491        &mut self,
492        evidence_type: EvidenceType,
493        account_balance: f64,
494    ) -> std::collections::HashMap<String, String> {
495        let mut terms = std::collections::HashMap::new();
496
497        let default_end = NaiveDate::from_ymd_opt(2025, 12, 31).expect("valid date");
498        let period_end = self.config.period_end_date.unwrap_or(default_end);
499        let period_end_str = period_end.format("%Y-%m-%d").to_string();
500        let period_start_str = NaiveDate::from_ymd_opt(period_end.year(), 1, 1)
501            .expect("valid date")
502            .format("%Y-%m-%d")
503            .to_string();
504
505        // Small variance around the real balance (+-2%).
506        let variance_pct = self.rng.random_range(-0.02..0.02);
507        let anchored_amount = account_balance * (1.0 + variance_pct);
508
509        match evidence_type {
510            EvidenceType::Invoice => {
511                terms.insert(
512                    "invoice_number".into(),
513                    format!("INV-{:06}", self.rng.random_range(100000..999999)),
514                );
515                // Anchor to a fraction of the balance (single invoice).
516                let fraction = self.rng.random_range(0.005..0.05);
517                terms.insert(
518                    "amount".into(),
519                    format!("{:.2}", account_balance * fraction),
520                );
521                terms.insert("vendor".into(), "Extracted Vendor Name".into());
522            }
523            EvidenceType::Contract => {
524                terms.insert("effective_date".into(), period_start_str);
525                terms.insert(
526                    "term_years".into(),
527                    format!("{}", self.rng.random_range(1..5)),
528                );
529                terms.insert("total_value".into(), format!("{:.2}", anchored_amount));
530            }
531            EvidenceType::BankStatement => {
532                terms.insert("ending_balance".into(), format!("{:.2}", anchored_amount));
533                terms.insert("statement_date".into(), period_end_str);
534            }
535            EvidenceType::Confirmation => {
536                terms.insert(
537                    "confirmed_balance".into(),
538                    format!("{:.2}", anchored_amount),
539                );
540                terms.insert("confirmation_date".into(), period_end_str);
541            }
542            _ => {
543                terms.insert("document_date".into(), period_end_str);
544                terms.insert(
545                    "reference".into(),
546                    format!("REF-{:06}", self.rng.random_range(100000..999999)),
547                );
548                terms.insert("reported_amount".into(), format!("{:.2}", anchored_amount));
549            }
550        }
551
552        terms
553    }
554
555    /// Generate evidence title.
556    fn generate_evidence_title(&mut self, evidence_type: EvidenceType) -> String {
557        let titles = match evidence_type {
558            EvidenceType::Confirmation => vec![
559                "Bank Confirmation - Primary Account",
560                "AR Confirmation - Major Customer",
561                "AP Confirmation - Key Vendor",
562                "Legal Confirmation",
563                "Investment Confirmation",
564            ],
565            EvidenceType::BankStatement => vec![
566                "Bank Statement - Operating Account",
567                "Bank Statement - Payroll Account",
568                "Bank Statement - Investment Account",
569                "Bank Statement - Foreign Currency",
570            ],
571            EvidenceType::Invoice => vec![
572                "Vendor Invoice Sample",
573                "Customer Invoice Sample",
574                "Intercompany Invoice",
575                "Service Invoice",
576            ],
577            EvidenceType::Contract => vec![
578                "Customer Contract",
579                "Vendor Agreement",
580                "Lease Agreement",
581                "Employment Contract Sample",
582                "Loan Agreement",
583            ],
584            EvidenceType::Document => vec![
585                "Supporting Documentation",
586                "Source Document",
587                "Transaction Support",
588                "Authorization Document",
589            ],
590            EvidenceType::Analysis => vec![
591                "Analytical Review",
592                "Variance Analysis",
593                "Trend Analysis",
594                "Ratio Analysis",
595                "Account Reconciliation Review",
596            ],
597            EvidenceType::SystemExtract => vec![
598                "ERP System Extract",
599                "GL Detail Extract",
600                "Transaction Log Extract",
601                "User Access Report",
602            ],
603            EvidenceType::MeetingMinutes => vec![
604                "Board Meeting Minutes",
605                "Audit Committee Minutes",
606                "Management Meeting Notes",
607            ],
608            EvidenceType::Email => vec![
609                "Management Inquiry Response",
610                "Confirmation Follow-up",
611                "Exception Explanation",
612            ],
613            EvidenceType::Recalculation => vec![
614                "Depreciation Recalculation",
615                "Interest Recalculation",
616                "Tax Provision Recalculation",
617                "Allowance Recalculation",
618            ],
619            EvidenceType::LegalLetter => vec!["Attorney Response Letter", "Litigation Summary"],
620            EvidenceType::ManagementRepresentation => vec![
621                "Management Representation Letter",
622                "Specific Representation",
623            ],
624            EvidenceType::SpecialistReport => vec![
625                "Valuation Specialist Report",
626                "Actuary Report",
627                "IT Specialist Assessment",
628            ],
629            EvidenceType::PhysicalObservation => vec![
630                "Inventory Count Observation",
631                "Fixed Asset Inspection",
632                "Physical Verification",
633            ],
634        };
635
636        let idx = self.rng.random_range(0..titles.len());
637        titles[idx].to_string()
638    }
639
640    /// Generate evidence description.
641    fn generate_evidence_description(
642        &mut self,
643        evidence_type: EvidenceType,
644        source: EvidenceSource,
645    ) -> String {
646        let source_desc = source.description();
647        match evidence_type {
648            EvidenceType::Confirmation => {
649                format!("External confirmation {source_desc}. Response received and agreed to client records.")
650            }
651            EvidenceType::BankStatement => {
652                format!("Bank statement {source_desc}. Statement obtained for period-end reconciliation.")
653            }
654            EvidenceType::Invoice => {
655                "Invoice selected as part of sample testing. Examined for appropriate approval, accuracy, and proper period recording.".into()
656            }
657            EvidenceType::Analysis => {
658                "Auditor-prepared analytical procedure. Expectations developed based on prior year, industry data, and management budgets.".into()
659            }
660            EvidenceType::SystemExtract => {
661                format!("System report {source_desc}. Extract validated for completeness and accuracy.")
662            }
663            _ => format!("Supporting documentation {source_desc}."),
664        }
665    }
666
667    /// Generate reliability assessment.
668    fn generate_reliability_assessment(&mut self, source: EvidenceSource) -> ReliabilityAssessment {
669        let base_reliability = source.inherent_reliability();
670
671        let independence = base_reliability;
672        let controls = if self.rng.random::<f64>() < self.config.high_reliability_probability {
673            ReliabilityLevel::High
674        } else {
675            ReliabilityLevel::Medium
676        };
677        let qualifications = if self.rng.random::<f64>() < 0.7 {
678            ReliabilityLevel::High
679        } else {
680            ReliabilityLevel::Medium
681        };
682        let objectivity = match source {
683            EvidenceSource::ExternalThirdParty | EvidenceSource::AuditorPrepared => {
684                ReliabilityLevel::High
685            }
686            _ => {
687                if self.rng.random::<f64>() < 0.5 {
688                    ReliabilityLevel::Medium
689                } else {
690                    ReliabilityLevel::Low
691                }
692            }
693        };
694
695        let notes = match base_reliability {
696            ReliabilityLevel::High => {
697                "Evidence obtained from independent source with high reliability"
698            }
699            ReliabilityLevel::Medium => "Evidence obtained from client with adequate controls",
700            ReliabilityLevel::Low => "Internal evidence requires corroboration",
701        };
702
703        ReliabilityAssessment::new(independence, controls, qualifications, objectivity, notes)
704    }
705
706    /// Generate file path for evidence.
707    fn generate_file_path(&mut self, evidence_type: EvidenceType, counter: u32) -> String {
708        let extension = match evidence_type {
709            EvidenceType::SystemExtract => "xlsx",
710            EvidenceType::Analysis | EvidenceType::Recalculation => "xlsx",
711            EvidenceType::MeetingMinutes | EvidenceType::ManagementRepresentation => "pdf",
712            EvidenceType::Email => "msg",
713            _ => {
714                if self.rng.random::<f64>() < 0.6 {
715                    "pdf"
716                } else {
717                    "xlsx"
718                }
719            }
720        };
721
722        format!("/evidence/EV-{counter:06}.{extension}")
723    }
724
725    /// Select a random team member.
726    fn select_team_member(&mut self, team_members: &[String]) -> String {
727        if team_members.is_empty() {
728            format!("STAFF{:03}", self.rng.random_range(1..100))
729        } else {
730            let idx = self.rng.random_range(0..team_members.len());
731            team_members[idx].clone()
732        }
733    }
734
735    /// Generate a random assertion.
736    fn random_assertion(&mut self) -> Assertion {
737        let assertions = [
738            Assertion::Occurrence,
739            Assertion::Completeness,
740            Assertion::Accuracy,
741            Assertion::Cutoff,
742            Assertion::Classification,
743            Assertion::Existence,
744            Assertion::RightsAndObligations,
745            Assertion::ValuationAndAllocation,
746            Assertion::PresentationAndDisclosure,
747        ];
748        let idx = self.rng.random_range(0..assertions.len());
749        assertions[idx]
750    }
751
752    /// Generate AI-extracted terms.
753    fn generate_ai_terms(
754        &mut self,
755        evidence_type: EvidenceType,
756    ) -> std::collections::HashMap<String, String> {
757        let mut terms = std::collections::HashMap::new();
758
759        let default_end = NaiveDate::from_ymd_opt(2025, 12, 31).expect("valid date");
760        let period_end = self.config.period_end_date.unwrap_or(default_end);
761        let period_end_str = period_end.format("%Y-%m-%d").to_string();
762        // Derive a period-start from period_end (beginning of that year)
763        let period_start_str = NaiveDate::from_ymd_opt(period_end.year(), 1, 1)
764            .expect("valid date")
765            .format("%Y-%m-%d")
766            .to_string();
767
768        match evidence_type {
769            EvidenceType::Invoice => {
770                terms.insert(
771                    "invoice_number".into(),
772                    format!("INV-{:06}", self.rng.random_range(100000..999999)),
773                );
774                terms.insert(
775                    "amount".into(),
776                    format!("{:.2}", self.rng.random_range(1000.0..100000.0)),
777                );
778                terms.insert("vendor".into(), "Extracted Vendor Name".into());
779            }
780            EvidenceType::Contract => {
781                terms.insert("effective_date".into(), period_start_str);
782                terms.insert(
783                    "term_years".into(),
784                    format!("{}", self.rng.random_range(1..5)),
785                );
786                terms.insert(
787                    "total_value".into(),
788                    format!("{:.2}", self.rng.random_range(50000.0..500000.0)),
789                );
790            }
791            EvidenceType::BankStatement => {
792                terms.insert(
793                    "ending_balance".into(),
794                    format!("{:.2}", self.rng.random_range(100000.0..10000000.0)),
795                );
796                terms.insert("statement_date".into(), period_end_str);
797            }
798            _ => {
799                terms.insert("document_date".into(), period_end_str);
800                terms.insert(
801                    "reference".into(),
802                    format!("REF-{:06}", self.rng.random_range(100000..999999)),
803                );
804            }
805        }
806
807        terms
808    }
809
810    /// Generate AI summary.
811    fn generate_ai_summary(&mut self, evidence_type: EvidenceType) -> String {
812        match evidence_type {
813            EvidenceType::Invoice => {
814                "Invoice for goods/services with standard payment terms. Amount within expected range.".into()
815            }
816            EvidenceType::Contract => {
817                "Multi-year agreement with standard commercial terms. Key provisions identified.".into()
818            }
819            EvidenceType::BankStatement => {
820                "Month-end bank statement showing reconciled balance. No unusual items noted.".into()
821            }
822            _ => "Document reviewed and key data points extracted.".into(),
823        }
824    }
825}
826
827#[cfg(test)]
828#[allow(clippy::unwrap_used)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn test_evidence_generation() {
834        let mut generator = EvidenceGenerator::new(42);
835        let evidence = generator.generate_evidence(
836            Uuid::new_v4(),
837            None,
838            &[Assertion::Occurrence],
839            &["STAFF001".into()],
840            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
841        );
842
843        assert!(!evidence.evidence_ref.is_empty());
844        assert!(!evidence.title.is_empty());
845        assert!(evidence.file_size.is_some());
846    }
847
848    #[test]
849    fn test_evidence_reliability() {
850        let mut generator = EvidenceGenerator::new(42);
851
852        // Generate multiple evidence pieces and check reliability
853        for _ in 0..10 {
854            let evidence = generator.generate_evidence(
855                Uuid::new_v4(),
856                None,
857                &[],
858                &["STAFF001".into()],
859                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
860            );
861
862            // Verify reliability assessment is set
863            assert!(!evidence.reliability_assessment.notes.is_empty());
864        }
865    }
866
867    #[test]
868    fn test_evidence_with_ai_extraction() {
869        let config = EvidenceGeneratorConfig {
870            ai_extraction_probability: 1.0, // Always extract
871            ..Default::default()
872        };
873        let mut generator = EvidenceGenerator::with_config(42, config);
874
875        let evidence = generator.generate_evidence(
876            Uuid::new_v4(),
877            None,
878            &[],
879            &["STAFF001".into()],
880            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
881        );
882
883        assert!(evidence.ai_extracted_terms.is_some());
884        assert!(evidence.ai_confidence.is_some());
885        assert!(evidence.ai_summary.is_some());
886    }
887
888    #[test]
889    fn test_evidence_with_context_existence_favors_confirmation() {
890        // With Existence assertion + High risk, evidence should favor
891        // external types (Confirmation, BankStatement, PhysicalObservation).
892        let mut generator = EvidenceGenerator::new(42);
893        let context = EvidenceContext {
894            risk_level: Some("High".into()),
895            account_balance: Some(1_250_000.0),
896            assertion: Some("Existence".into()),
897        };
898
899        let mut external_count = 0;
900        let total = 50;
901        for _ in 0..total {
902            let evidence = generator.generate_evidence_with_context(
903                Uuid::new_v4(),
904                None,
905                &[Assertion::Existence],
906                &["STAFF001".into()],
907                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
908                &context,
909            );
910            if matches!(
911                evidence.source_type,
912                EvidenceSource::ExternalThirdParty | EvidenceSource::AuditorPrepared
913            ) && matches!(
914                evidence.evidence_type,
915                EvidenceType::Confirmation
916                    | EvidenceType::BankStatement
917                    | EvidenceType::PhysicalObservation
918            ) {
919                external_count += 1;
920            }
921        }
922        // With High risk + Existence, we expect a meaningful proportion of
923        // confirmation/observation evidence (more than baseline ~10%).
924        assert!(
925            external_count > 5,
926            "Expected >5 confirmation/observation evidence, got {external_count}/{total}"
927        );
928    }
929
930    #[test]
931    fn test_evidence_with_context_completeness_favors_analysis() {
932        let mut generator = EvidenceGenerator::new(42);
933        let context = EvidenceContext {
934            risk_level: Some("Moderate".into()),
935            account_balance: None,
936            assertion: Some("Completeness".into()),
937        };
938
939        let mut analytical_count = 0;
940        let total = 50;
941        for _ in 0..total {
942            let evidence = generator.generate_evidence_with_context(
943                Uuid::new_v4(),
944                None,
945                &[Assertion::Completeness],
946                &["STAFF001".into()],
947                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
948                &context,
949            );
950            if matches!(
951                evidence.evidence_type,
952                EvidenceType::Analysis | EvidenceType::SystemExtract | EvidenceType::Recalculation
953            ) {
954                analytical_count += 1;
955            }
956        }
957        // Completeness should heavily favor analytical/system evidence.
958        assert!(
959            analytical_count > 20,
960            "Expected >20 analytical evidence, got {analytical_count}/{total}"
961        );
962    }
963
964    #[test]
965    fn test_evidence_anchored_amounts() {
966        let config = EvidenceGeneratorConfig {
967            ai_extraction_probability: 1.0,
968            ..Default::default()
969        };
970        let mut generator = EvidenceGenerator::with_config(42, config);
971        let balance = 1_000_000.0;
972        let context = EvidenceContext {
973            risk_level: None,
974            account_balance: Some(balance),
975            assertion: None,
976        };
977
978        let evidence = generator.generate_evidence_with_context(
979            Uuid::new_v4(),
980            None,
981            &[],
982            &["STAFF001".into()],
983            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
984            &context,
985        );
986
987        // AI terms should be present and amounts should be near the balance.
988        assert!(evidence.ai_extracted_terms.is_some());
989    }
990
991    #[test]
992    fn test_evidence_workpaper_link() {
993        let mut generator = EvidenceGenerator::new(42);
994        let workpaper_id = Uuid::new_v4();
995
996        let evidence = generator.generate_evidence(
997            Uuid::new_v4(),
998            Some(workpaper_id),
999            &[Assertion::Completeness],
1000            &["STAFF001".into()],
1001            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1002        );
1003
1004        assert!(evidence.linked_workpapers.contains(&workpaper_id));
1005    }
1006}