Skip to main content

datasynth_banking/models/
case_narrative.rs

1//! Case narrative models for SAR generation and ML training.
2
3use chrono::NaiveDate;
4use datasynth_core::models::banking::{AmlTypology, LaunderingStage, Sophistication};
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9// EvasionTactic is re-exported at the bottom of the file
10
11/// An AML scenario (ground truth case).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AmlScenario {
14    /// Unique scenario identifier
15    pub scenario_id: String,
16    /// Primary AML typology
17    pub typology: AmlTypology,
18    /// Secondary typologies (if multiple)
19    pub secondary_typologies: Vec<AmlTypology>,
20    /// Money laundering stages involved
21    pub stages: Vec<LaunderingStage>,
22    /// Scenario start date
23    pub start_date: NaiveDate,
24    /// Scenario end date
25    pub end_date: NaiveDate,
26    /// Customer IDs involved
27    pub involved_customers: Vec<Uuid>,
28    /// Account IDs involved
29    pub involved_accounts: Vec<Uuid>,
30    /// Transaction IDs involved
31    pub involved_transactions: Vec<Uuid>,
32    /// Total amount laundered/defrauded
33    #[serde(with = "rust_decimal::serde::str")]
34    pub total_amount: Decimal,
35    /// Evasion tactics employed
36    pub evasion_tactics: Vec<EvasionTactic>,
37    /// Sophistication level
38    pub sophistication: Sophistication,
39    /// Detectability score (0.0-1.0, higher = easier to detect)
40    pub detectability: f64,
41    /// Case narrative
42    pub narrative: CaseNarrative,
43    /// Alert triggers that should fire
44    pub expected_alerts: Vec<ExpectedAlert>,
45    /// Whether scenario was successfully completed
46    pub was_successful: bool,
47}
48
49impl AmlScenario {
50    /// Create a new AML scenario.
51    pub fn new(
52        scenario_id: &str,
53        typology: AmlTypology,
54        start_date: NaiveDate,
55        end_date: NaiveDate,
56    ) -> Self {
57        Self {
58            scenario_id: scenario_id.to_string(),
59            typology,
60            secondary_typologies: Vec::new(),
61            stages: Vec::new(),
62            start_date,
63            end_date,
64            involved_customers: Vec::new(),
65            involved_accounts: Vec::new(),
66            involved_transactions: Vec::new(),
67            total_amount: Decimal::ZERO,
68            evasion_tactics: Vec::new(),
69            sophistication: Sophistication::default(),
70            detectability: typology.severity() as f64 / 10.0,
71            narrative: CaseNarrative::default(),
72            expected_alerts: Vec::new(),
73            was_successful: true,
74        }
75    }
76
77    /// Add a stage.
78    pub fn add_stage(&mut self, stage: LaunderingStage) {
79        if !self.stages.contains(&stage) {
80            self.stages.push(stage);
81        }
82    }
83
84    /// Add a customer.
85    pub fn add_customer(&mut self, customer_id: Uuid) {
86        if !self.involved_customers.contains(&customer_id) {
87            self.involved_customers.push(customer_id);
88        }
89    }
90
91    /// Add an account.
92    pub fn add_account(&mut self, account_id: Uuid) {
93        if !self.involved_accounts.contains(&account_id) {
94            self.involved_accounts.push(account_id);
95        }
96    }
97
98    /// Add a transaction.
99    pub fn add_transaction(&mut self, transaction_id: Uuid, amount: Decimal) {
100        self.involved_transactions.push(transaction_id);
101        self.total_amount += amount;
102    }
103
104    /// Add an evasion tactic.
105    pub fn add_evasion_tactic(&mut self, tactic: EvasionTactic) {
106        if !self.evasion_tactics.contains(&tactic) {
107            self.evasion_tactics.push(tactic);
108            // Adjust detectability
109            self.detectability *= 1.0 / tactic.difficulty_modifier();
110        }
111    }
112
113    /// Set sophistication level.
114    pub fn with_sophistication(mut self, sophistication: Sophistication) -> Self {
115        self.sophistication = sophistication;
116        self.detectability *= sophistication.detectability_modifier();
117        self
118    }
119
120    /// Calculate case complexity score.
121    pub fn complexity_score(&self) -> u8 {
122        let mut score = 0.0;
123
124        // Number of entities (use max(1) to avoid ln(0))
125        score += (self.involved_customers.len().max(1) as f64).ln() * 10.0;
126
127        // Number of accounts
128        score += (self.involved_accounts.len().max(1) as f64).ln() * 5.0;
129
130        // Number of transactions
131        score += (self.involved_transactions.len().max(1) as f64).ln() * 3.0;
132
133        // Duration
134        let duration = (self.end_date - self.start_date).num_days();
135        score += (duration as f64 / 30.0).min(10.0) * 3.0;
136
137        // Evasion tactics
138        score += self.evasion_tactics.len() as f64 * 5.0;
139
140        // Sophistication
141        score += match self.sophistication {
142            Sophistication::Basic => 0.0,
143            Sophistication::Standard => 10.0,
144            Sophistication::Professional => 20.0,
145            Sophistication::Advanced => 30.0,
146            Sophistication::StateLevel => 40.0,
147        };
148
149        // Number of stages
150        score += self.stages.len() as f64 * 5.0;
151
152        score.min(100.0) as u8
153    }
154}
155
156/// Case narrative for SAR-style reporting.
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct CaseNarrative {
159    /// Summary storyline
160    pub storyline: String,
161    /// Key evidence points
162    pub evidence_points: Vec<String>,
163    /// Violated expectations
164    pub violated_expectations: Vec<ViolatedExpectation>,
165    /// Red flags identified
166    pub red_flags: Vec<RedFlag>,
167    /// Recommended action
168    pub recommendation: CaseRecommendation,
169    /// Investigation notes
170    pub investigation_notes: Vec<String>,
171}
172
173impl CaseNarrative {
174    /// Create a new case narrative.
175    pub fn new(storyline: &str) -> Self {
176        Self {
177            storyline: storyline.to_string(),
178            ..Default::default()
179        }
180    }
181
182    /// Add an evidence point.
183    pub fn add_evidence(&mut self, evidence: &str) {
184        self.evidence_points.push(evidence.to_string());
185    }
186
187    /// Add a violated expectation.
188    pub fn add_violated_expectation(&mut self, expectation: ViolatedExpectation) {
189        self.violated_expectations.push(expectation);
190    }
191
192    /// Add a red flag.
193    pub fn add_red_flag(&mut self, flag: RedFlag) {
194        self.red_flags.push(flag);
195    }
196
197    /// Set recommendation.
198    pub fn with_recommendation(mut self, recommendation: CaseRecommendation) -> Self {
199        self.recommendation = recommendation;
200        self
201    }
202
203    /// Generate narrative text.
204    pub fn generate_text(&self) -> String {
205        let mut text = format!("## Case Summary\n\n{}\n\n", self.storyline);
206
207        if !self.evidence_points.is_empty() {
208            text.push_str("## Evidence Points\n\n");
209            for (i, point) in self.evidence_points.iter().enumerate() {
210                text.push_str(&format!("{}. {}\n", i + 1, point));
211            }
212            text.push('\n');
213        }
214
215        if !self.violated_expectations.is_empty() {
216            text.push_str("## Violated Expectations\n\n");
217            for ve in &self.violated_expectations {
218                text.push_str(&format!(
219                    "- **{}**: Expected {}, Actual {}\n",
220                    ve.expectation_type, ve.expected_value, ve.actual_value
221                ));
222            }
223            text.push('\n');
224        }
225
226        if !self.red_flags.is_empty() {
227            text.push_str("## Red Flags\n\n");
228            for flag in &self.red_flags {
229                text.push_str(&format!(
230                    "- {} (Severity: {})\n",
231                    flag.description, flag.severity
232                ));
233            }
234            text.push('\n');
235        }
236
237        text.push_str(&format!(
238            "## Recommendation\n\n{}\n",
239            self.recommendation.description()
240        ));
241
242        text
243    }
244}
245
246/// A violated KYC expectation.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ViolatedExpectation {
249    /// Type of expectation
250    pub expectation_type: String,
251    /// Expected value
252    pub expected_value: String,
253    /// Actual value
254    pub actual_value: String,
255    /// Deviation percentage
256    pub deviation_percentage: f64,
257}
258
259impl ViolatedExpectation {
260    /// Create a new violated expectation.
261    pub fn new(expectation_type: &str, expected: &str, actual: &str, deviation: f64) -> Self {
262        Self {
263            expectation_type: expectation_type.to_string(),
264            expected_value: expected.to_string(),
265            actual_value: actual.to_string(),
266            deviation_percentage: deviation,
267        }
268    }
269}
270
271/// A red flag indicator.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct RedFlag {
274    /// Red flag category
275    pub category: RedFlagCategory,
276    /// Description
277    pub description: String,
278    /// Severity (1-10)
279    pub severity: u8,
280    /// Date identified
281    pub date_identified: NaiveDate,
282}
283
284impl RedFlag {
285    /// Create a new red flag.
286    pub fn new(
287        category: RedFlagCategory,
288        description: &str,
289        severity: u8,
290        date: NaiveDate,
291    ) -> Self {
292        Self {
293            category,
294            description: description.to_string(),
295            severity,
296            date_identified: date,
297        }
298    }
299}
300
301/// Category of red flag.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum RedFlagCategory {
305    /// Activity pattern red flag
306    ActivityPattern,
307    /// Geographic red flag
308    Geographic,
309    /// Customer behavior red flag
310    CustomerBehavior,
311    /// Transaction characteristic red flag
312    TransactionCharacteristic,
313    /// Account characteristic red flag
314    AccountCharacteristic,
315    /// Third party red flag
316    ThirdParty,
317    /// Timing red flag
318    Timing,
319    /// Documentation red flag
320    Documentation,
321}
322
323/// Case recommendation.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
325#[serde(rename_all = "snake_case")]
326pub enum CaseRecommendation {
327    /// Close case - no further action
328    #[default]
329    CloseNoAction,
330    /// Continue monitoring
331    ContinueMonitoring,
332    /// Enhanced monitoring
333    EnhancedMonitoring,
334    /// Escalate to compliance
335    EscalateCompliance,
336    /// File SAR
337    FileSar,
338    /// Close account
339    CloseAccount,
340    /// Report to law enforcement
341    ReportLawEnforcement,
342}
343
344impl CaseRecommendation {
345    /// Description of the recommendation.
346    pub fn description(&self) -> &'static str {
347        match self {
348            Self::CloseNoAction => "Close case - no suspicious activity identified",
349            Self::ContinueMonitoring => "Continue standard monitoring",
350            Self::EnhancedMonitoring => "Place customer under enhanced monitoring",
351            Self::EscalateCompliance => "Escalate to compliance officer for review",
352            Self::FileSar => "Escalate to SAR filing",
353            Self::CloseAccount => "Close account and file SAR",
354            Self::ReportLawEnforcement => "Report to law enforcement immediately",
355        }
356    }
357
358    /// Severity level (1-5).
359    pub fn severity(&self) -> u8 {
360        match self {
361            Self::CloseNoAction => 1,
362            Self::ContinueMonitoring => 1,
363            Self::EnhancedMonitoring => 2,
364            Self::EscalateCompliance => 3,
365            Self::FileSar => 4,
366            Self::CloseAccount => 4,
367            Self::ReportLawEnforcement => 5,
368        }
369    }
370}
371
372/// Expected alert that should be triggered.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct ExpectedAlert {
375    /// Alert type/rule name
376    pub alert_type: String,
377    /// Expected trigger date
378    pub expected_date: NaiveDate,
379    /// Transactions that should trigger
380    pub triggering_transactions: Vec<Uuid>,
381    /// Severity
382    pub severity: AlertSeverity,
383}
384
385/// Alert severity level.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
387#[serde(rename_all = "snake_case")]
388pub enum AlertSeverity {
389    /// Low severity
390    #[default]
391    Low,
392    /// Medium severity
393    Medium,
394    /// High severity
395    High,
396    /// Critical severity
397    Critical,
398}
399
400// Re-export EvasionTactic from synth-core
401pub use datasynth_core::models::banking::EvasionTactic;
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_aml_scenario() {
409        let scenario = AmlScenario::new(
410            "SC-2024-001",
411            AmlTypology::Structuring,
412            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
413            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
414        );
415
416        assert_eq!(scenario.typology, AmlTypology::Structuring);
417        assert!(scenario.was_successful);
418    }
419
420    #[test]
421    fn test_case_narrative() {
422        let mut narrative = CaseNarrative::new(
423            "Subject conducted 12 cash deposits just below $10,000 threshold over 3 days.",
424        );
425        narrative.add_evidence("12 deposits ranging from $9,500 to $9,900");
426        narrative.add_evidence("All deposits at different branch locations");
427        narrative.add_violated_expectation(ViolatedExpectation::new(
428            "Monthly deposits",
429            "2",
430            "12",
431            500.0,
432        ));
433
434        let text = narrative.generate_text();
435        assert!(text.contains("12 cash deposits"));
436        assert!(text.contains("Evidence Points"));
437    }
438
439    #[test]
440    fn test_complexity_score() {
441        let mut simple = AmlScenario::new(
442            "SC-001",
443            AmlTypology::Structuring,
444            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
445            NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
446        );
447        simple.add_customer(Uuid::new_v4());
448        simple.add_account(Uuid::new_v4());
449
450        let mut complex = AmlScenario::new(
451            "SC-002",
452            AmlTypology::Layering,
453            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
454            NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
455        )
456        .with_sophistication(Sophistication::Advanced);
457
458        for _ in 0..10 {
459            complex.add_customer(Uuid::new_v4());
460            complex.add_account(Uuid::new_v4());
461        }
462        complex.add_stage(LaunderingStage::Placement);
463        complex.add_stage(LaunderingStage::Layering);
464        complex.add_evasion_tactic(EvasionTactic::TimeJitter);
465        complex.add_evasion_tactic(EvasionTactic::AccountSplitting);
466
467        assert!(complex.complexity_score() > simple.complexity_score());
468    }
469}