Skip to main content

datasynth_eval/banking/
aml_detectability.rs

1//! AML typology detectability evaluator.
2//!
3//! Validates that AML typologies (structuring, layering, mule networks, etc.)
4//! produce statistically detectable patterns and maintain coherence.
5
6use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// AML transaction data for a typology instance.
11///
12/// The `typology` string should be the canonical lowercase name
13/// produced by `AmlTypology::canonical_name()` — see
14/// [`EXPECTED_TYPOLOGIES`] for the allowed values. Using PascalCase
15/// (e.g. the Debug format of the enum) will fail the coverage match.
16#[derive(Debug, Clone)]
17pub struct AmlTransactionData {
18    /// Transaction identifier.
19    pub transaction_id: String,
20    /// Canonical typology name, e.g. "structuring", "mule", "fraud".
21    pub typology: String,
22    /// Case identifier (shared across related transactions).
23    pub case_id: String,
24    /// Transaction amount.
25    pub amount: f64,
26    /// Whether this is a flagged/suspicious transaction.
27    pub is_flagged: bool,
28}
29
30/// Overall typology data for coverage validation.
31#[derive(Debug, Clone)]
32pub struct TypologyData {
33    /// Typology name.
34    pub name: String,
35    /// Number of scenarios generated.
36    pub scenario_count: usize,
37    /// Whether all transactions in a scenario share a case_id.
38    pub case_ids_consistent: bool,
39}
40
41/// Thresholds for AML detectability.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AmlDetectabilityThresholds {
44    /// Minimum typology coverage (fraction of expected typologies present).
45    pub min_typology_coverage: f64,
46    /// Minimum scenario coherence rate.
47    pub min_scenario_coherence: f64,
48    /// Structuring threshold (transactions should cluster below this).
49    pub structuring_threshold: f64,
50}
51
52impl Default for AmlDetectabilityThresholds {
53    fn default() -> Self {
54        Self {
55            min_typology_coverage: 0.80,
56            min_scenario_coherence: 0.90,
57            structuring_threshold: 10_000.0,
58        }
59    }
60}
61
62/// Per-typology detectability result.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TypologyDetectability {
65    /// Typology name.
66    pub name: String,
67    /// Number of transactions.
68    pub transaction_count: usize,
69    /// Number of unique cases.
70    pub case_count: usize,
71    /// Flag rate.
72    pub flag_rate: f64,
73    /// Whether the typology shows expected patterns.
74    pub pattern_detected: bool,
75}
76
77/// Results of AML detectability analysis.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct AmlDetectabilityAnalysis {
80    /// Typology coverage: fraction of expected typologies present.
81    pub typology_coverage: f64,
82    /// Scenario coherence: fraction of scenarios with consistent case_ids.
83    pub scenario_coherence: f64,
84    /// Per-typology detectability.
85    pub per_typology: Vec<TypologyDetectability>,
86    /// Total transactions analyzed.
87    pub total_transactions: usize,
88    /// Overall pass/fail.
89    pub passes: bool,
90    /// Issues found.
91    pub issues: Vec<String>,
92}
93
94/// Expected typology categories for coverage calculation.
95///
96/// Matches the banking module catalog in CLAUDE.md:
97///   structuring, funnel, layering, mule, round_tripping, fraud, spoofing
98///
99/// v4.4.2: each category is represented by a canonical name *plus* the
100/// aliases the typology injectors emit into `TypologyData.name` and
101/// `suspicion_reason`. Before v4.4.2 the evaluator did exact-string
102/// matching against short names, so "money_mule" / "funnel_account" /
103/// "first_party_fraud" / "authorized_push_payment" didn't match even
104/// though the underlying typologies were firing — the SDK team saw
105/// coverage 0.71 / 5-of-7 where the real coverage was 1.0 / 7-of-7.
106///
107/// Each entry is `(canonical, aliases)`. A category is "covered" when
108/// ANY of its names appears in the typology set.
109const EXPECTED_TYPOLOGIES: &[(&str, &[&str])] = &[
110    (
111        "structuring",
112        &["structuring", "smurfing", "cuckoo_smurfing"],
113    ),
114    (
115        "funnel",
116        &[
117            "funnel",
118            "funnel_account",
119            "concentration_account",
120            "pouch_activity",
121        ],
122    ),
123    ("layering", &["layering", "rapid_movement", "shell_company"]),
124    (
125        "mule",
126        &[
127            "mule",
128            "money_mule",
129            "authorized_push_payment",
130            "synthetic_identity",
131        ],
132    ),
133    (
134        "round_tripping",
135        &[
136            "round_tripping",
137            "trade_based_ml",
138            "real_estate_integration",
139        ],
140    ),
141    (
142        "fraud",
143        &[
144            "fraud",
145            "first_party_fraud",
146            "account_takeover",
147            "romance_scam",
148            "sanctions_evasion",
149        ],
150    ),
151    (
152        "spoofing",
153        &["spoofing", "casino_integration", "crypto_integration"],
154    ),
155];
156
157/// Analyzer for AML detectability.
158pub struct AmlDetectabilityAnalyzer {
159    thresholds: AmlDetectabilityThresholds,
160}
161
162impl AmlDetectabilityAnalyzer {
163    /// Create a new analyzer with default thresholds.
164    pub fn new() -> Self {
165        Self {
166            thresholds: AmlDetectabilityThresholds::default(),
167        }
168    }
169
170    /// Create with custom thresholds.
171    pub fn with_thresholds(thresholds: AmlDetectabilityThresholds) -> Self {
172        Self { thresholds }
173    }
174
175    /// Analyze AML transactions and typology data.
176    pub fn analyze(
177        &self,
178        transactions: &[AmlTransactionData],
179        typologies: &[TypologyData],
180    ) -> EvalResult<AmlDetectabilityAnalysis> {
181        let mut issues = Vec::new();
182
183        // 1. Typology coverage — a category counts as covered when ANY
184        // of its canonical / alias names appears in the observed
185        // typology set. v4.4.2+ matching against the alias table lets
186        // injector-emitted names like "money_mule" map to the "mule"
187        // category without forcing a rename in every injector.
188        let present_typologies: std::collections::HashSet<&str> =
189            typologies.iter().map(|t| t.name.as_str()).collect();
190        let covered = EXPECTED_TYPOLOGIES
191            .iter()
192            .filter(|(_, aliases)| aliases.iter().any(|a| present_typologies.contains(a)))
193            .count();
194        let typology_coverage = covered as f64 / EXPECTED_TYPOLOGIES.len() as f64;
195
196        // 2. Scenario coherence
197        let coherent = typologies.iter().filter(|t| t.case_ids_consistent).count();
198        let scenario_coherence = if typologies.is_empty() {
199            1.0
200        } else {
201            coherent as f64 / typologies.len() as f64
202        };
203
204        // 3. Per-typology analysis
205        let mut by_typology: HashMap<String, Vec<&AmlTransactionData>> = HashMap::new();
206        for txn in transactions {
207            by_typology
208                .entry(txn.typology.clone())
209                .or_default()
210                .push(txn);
211        }
212
213        let mut per_typology = Vec::new();
214        for (name, txns) in &by_typology {
215            let case_ids: std::collections::HashSet<&str> =
216                txns.iter().map(|t| t.case_id.as_str()).collect();
217            let flagged = txns.iter().filter(|t| t.is_flagged).count();
218            let flag_rate = if txns.is_empty() {
219                0.0
220            } else {
221                flagged as f64 / txns.len() as f64
222            };
223
224            // Check typology-specific patterns
225            let pattern_detected = match name.as_str() {
226                "structuring" => {
227                    // Most amounts should be below threshold
228                    let below = txns
229                        .iter()
230                        .filter(|t| t.amount < self.thresholds.structuring_threshold)
231                        .count();
232                    below as f64 / txns.len().max(1) as f64 > 0.5
233                }
234                "layering" => {
235                    // Should have multiple cases with >2 transactions each
236                    !case_ids.is_empty() && txns.len() > case_ids.len()
237                }
238                _ => {
239                    // Generic: require a meaningful flag rate indicating
240                    // the typology produces detectable suspicious patterns.
241                    // A flag rate of 0 means no suspicious indicators at all.
242                    let suspicious_count = txns.iter().filter(|t| t.is_flagged).count();
243                    let suspicious_ratio = suspicious_count as f64 / txns.len().max(1) as f64;
244                    !txns.is_empty() && suspicious_ratio > 0.0
245                }
246            };
247
248            per_typology.push(TypologyDetectability {
249                name: name.clone(),
250                transaction_count: txns.len(),
251                case_count: case_ids.len(),
252                flag_rate,
253                pattern_detected,
254            });
255        }
256
257        // Check thresholds
258        if typology_coverage < self.thresholds.min_typology_coverage {
259            issues.push(format!(
260                "Typology coverage {:.3} < {:.3}",
261                typology_coverage, self.thresholds.min_typology_coverage
262            ));
263        }
264        if scenario_coherence < self.thresholds.min_scenario_coherence {
265            issues.push(format!(
266                "Scenario coherence {:.3} < {:.3}",
267                scenario_coherence, self.thresholds.min_scenario_coherence
268            ));
269        }
270
271        let passes = issues.is_empty();
272
273        Ok(AmlDetectabilityAnalysis {
274            typology_coverage,
275            scenario_coherence,
276            per_typology,
277            total_transactions: transactions.len(),
278            passes,
279            issues,
280        })
281    }
282}
283
284impl Default for AmlDetectabilityAnalyzer {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290#[cfg(test)]
291#[allow(clippy::unwrap_used)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_good_aml_data() {
297        let analyzer = AmlDetectabilityAnalyzer::new();
298        // Use the canonical names (first of each tuple) so every
299        // category counts as covered.
300        let typologies: Vec<TypologyData> = EXPECTED_TYPOLOGIES
301            .iter()
302            .map(|(canonical, _aliases)| TypologyData {
303                name: canonical.to_string(),
304                scenario_count: 5,
305                case_ids_consistent: true,
306            })
307            .collect();
308        let transactions = vec![
309            AmlTransactionData {
310                transaction_id: "T001".to_string(),
311                typology: "structuring".to_string(),
312                case_id: "C001".to_string(),
313                amount: 9_500.0,
314                is_flagged: true,
315            },
316            AmlTransactionData {
317                transaction_id: "T002".to_string(),
318                typology: "structuring".to_string(),
319                case_id: "C001".to_string(),
320                amount: 9_800.0,
321                is_flagged: true,
322            },
323        ];
324
325        let result = analyzer.analyze(&transactions, &typologies).unwrap();
326        assert!(result.passes);
327        assert_eq!(result.typology_coverage, 1.0);
328    }
329
330    #[test]
331    fn test_missing_typologies() {
332        let analyzer = AmlDetectabilityAnalyzer::new();
333        let typologies = vec![TypologyData {
334            name: "structuring".to_string(),
335            scenario_count: 5,
336            case_ids_consistent: true,
337        }];
338
339        let result = analyzer.analyze(&[], &typologies).unwrap();
340        assert!(!result.passes); // Coverage too low
341    }
342
343    #[test]
344    fn test_empty() {
345        let analyzer = AmlDetectabilityAnalyzer::new();
346        let result = analyzer.analyze(&[], &[]).unwrap();
347        assert!(!result.passes); // Zero coverage
348    }
349}