1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
17pub struct AmlTransactionData {
18 pub transaction_id: String,
20 pub typology: String,
22 pub case_id: String,
24 pub amount: f64,
26 pub is_flagged: bool,
28}
29
30#[derive(Debug, Clone)]
32pub struct TypologyData {
33 pub name: String,
35 pub scenario_count: usize,
37 pub case_ids_consistent: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AmlDetectabilityThresholds {
44 pub min_typology_coverage: f64,
46 pub min_scenario_coherence: f64,
48 pub structuring_threshold: f64,
50 pub min_sample_for_coverage: usize,
60}
61
62impl Default for AmlDetectabilityThresholds {
63 fn default() -> Self {
64 Self {
65 min_typology_coverage: 0.80,
66 min_scenario_coherence: 0.90,
67 structuring_threshold: 10_000.0,
68 min_sample_for_coverage: 5_000,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct TypologyDetectability {
76 pub name: String,
78 pub transaction_count: usize,
80 pub case_count: usize,
82 pub flag_rate: f64,
84 pub pattern_detected: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AmlDetectabilityAnalysis {
91 pub typology_coverage: f64,
93 pub scenario_coherence: f64,
95 pub per_typology: Vec<TypologyDetectability>,
97 pub total_transactions: usize,
99 pub passes: bool,
101 pub issues: Vec<String>,
103}
104
105const EXPECTED_TYPOLOGIES: &[(&str, &[&str])] = &[
121 (
122 "structuring",
123 &["structuring", "smurfing", "cuckoo_smurfing"],
124 ),
125 (
126 "funnel",
127 &[
128 "funnel",
129 "funnel_account",
130 "concentration_account",
131 "pouch_activity",
132 ],
133 ),
134 ("layering", &["layering", "rapid_movement", "shell_company"]),
135 (
136 "mule",
137 &[
138 "mule",
139 "money_mule",
140 "authorized_push_payment",
141 "synthetic_identity",
142 ],
143 ),
144 (
145 "round_tripping",
146 &[
147 "round_tripping",
148 "trade_based_ml",
149 "real_estate_integration",
150 ],
151 ),
152 (
153 "fraud",
154 &[
155 "fraud",
156 "first_party_fraud",
157 "account_takeover",
158 "romance_scam",
159 "sanctions_evasion",
160 ],
161 ),
162 (
163 "spoofing",
164 &["spoofing", "casino_integration", "crypto_integration"],
165 ),
166];
167
168pub struct AmlDetectabilityAnalyzer {
170 thresholds: AmlDetectabilityThresholds,
171}
172
173impl AmlDetectabilityAnalyzer {
174 pub fn new() -> Self {
176 Self {
177 thresholds: AmlDetectabilityThresholds::default(),
178 }
179 }
180
181 pub fn with_thresholds(thresholds: AmlDetectabilityThresholds) -> Self {
183 Self { thresholds }
184 }
185
186 pub fn analyze(
188 &self,
189 transactions: &[AmlTransactionData],
190 typologies: &[TypologyData],
191 ) -> EvalResult<AmlDetectabilityAnalysis> {
192 let mut issues = Vec::new();
193
194 let present_typologies: std::collections::HashSet<&str> =
200 typologies.iter().map(|t| t.name.as_str()).collect();
201 let covered = EXPECTED_TYPOLOGIES
202 .iter()
203 .filter(|(_, aliases)| aliases.iter().any(|a| present_typologies.contains(a)))
204 .count();
205 let typology_coverage = covered as f64 / EXPECTED_TYPOLOGIES.len() as f64;
206
207 let coherent = typologies.iter().filter(|t| t.case_ids_consistent).count();
209 let scenario_coherence = if typologies.is_empty() {
210 1.0
211 } else {
212 coherent as f64 / typologies.len() as f64
213 };
214
215 let mut by_typology: HashMap<String, Vec<&AmlTransactionData>> = HashMap::new();
217 for txn in transactions {
218 by_typology
219 .entry(txn.typology.clone())
220 .or_default()
221 .push(txn);
222 }
223
224 let mut per_typology = Vec::new();
225 for (name, txns) in &by_typology {
226 let case_ids: std::collections::HashSet<&str> =
227 txns.iter().map(|t| t.case_id.as_str()).collect();
228 let flagged = txns.iter().filter(|t| t.is_flagged).count();
229 let flag_rate = if txns.is_empty() {
230 0.0
231 } else {
232 flagged as f64 / txns.len() as f64
233 };
234
235 let pattern_detected = match name.as_str() {
237 "structuring" => {
238 let below = txns
240 .iter()
241 .filter(|t| t.amount < self.thresholds.structuring_threshold)
242 .count();
243 below as f64 / txns.len().max(1) as f64 > 0.5
244 }
245 "layering" => {
246 !case_ids.is_empty() && txns.len() > case_ids.len()
248 }
249 _ => {
250 let suspicious_count = txns.iter().filter(|t| t.is_flagged).count();
254 let suspicious_ratio = suspicious_count as f64 / txns.len().max(1) as f64;
255 !txns.is_empty() && suspicious_ratio > 0.0
256 }
257 };
258
259 per_typology.push(TypologyDetectability {
260 name: name.clone(),
261 transaction_count: txns.len(),
262 case_count: case_ids.len(),
263 flag_rate,
264 pattern_detected,
265 });
266 }
267
268 let mut failed = false;
276 if transactions.len() < self.thresholds.min_sample_for_coverage {
277 issues.push(format!(
278 "Advisory: typology coverage {:.3} computed on {} txns \
279 (< {} sample floor) — metric is statistically unstable; \
280 increase sample size for a reliable reading.",
281 typology_coverage,
282 transactions.len(),
283 self.thresholds.min_sample_for_coverage
284 ));
285 } else if typology_coverage < self.thresholds.min_typology_coverage {
286 issues.push(format!(
287 "Typology coverage {:.3} < {:.3}",
288 typology_coverage, self.thresholds.min_typology_coverage
289 ));
290 failed = true;
291 }
292 if scenario_coherence < self.thresholds.min_scenario_coherence {
293 issues.push(format!(
294 "Scenario coherence {:.3} < {:.3}",
295 scenario_coherence, self.thresholds.min_scenario_coherence
296 ));
297 failed = true;
298 }
299
300 let passes = !failed;
301
302 Ok(AmlDetectabilityAnalysis {
303 typology_coverage,
304 scenario_coherence,
305 per_typology,
306 total_transactions: transactions.len(),
307 passes,
308 issues,
309 })
310 }
311}
312
313impl Default for AmlDetectabilityAnalyzer {
314 fn default() -> Self {
315 Self::new()
316 }
317}
318
319#[cfg(test)]
320#[allow(clippy::unwrap_used)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_good_aml_data() {
326 let analyzer = AmlDetectabilityAnalyzer::new();
327 let typologies: Vec<TypologyData> = EXPECTED_TYPOLOGIES
330 .iter()
331 .map(|(canonical, _aliases)| TypologyData {
332 name: canonical.to_string(),
333 scenario_count: 5,
334 case_ids_consistent: true,
335 })
336 .collect();
337 let transactions = vec![
338 AmlTransactionData {
339 transaction_id: "T001".to_string(),
340 typology: "structuring".to_string(),
341 case_id: "C001".to_string(),
342 amount: 9_500.0,
343 is_flagged: true,
344 },
345 AmlTransactionData {
346 transaction_id: "T002".to_string(),
347 typology: "structuring".to_string(),
348 case_id: "C001".to_string(),
349 amount: 9_800.0,
350 is_flagged: true,
351 },
352 ];
353
354 let result = analyzer.analyze(&transactions, &typologies).unwrap();
355 assert!(result.passes);
356 assert_eq!(result.typology_coverage, 1.0);
357 }
358
359 #[test]
360 fn test_missing_typologies() {
361 let mut thresholds = AmlDetectabilityThresholds::default();
367 thresholds.min_sample_for_coverage = 0;
368 let analyzer = AmlDetectabilityAnalyzer::with_thresholds(thresholds);
369 let typologies = vec![TypologyData {
370 name: "structuring".to_string(),
371 scenario_count: 5,
372 case_ids_consistent: true,
373 }];
374
375 let result = analyzer.analyze(&[], &typologies).unwrap();
376 assert!(!result.passes); }
378
379 #[test]
380 fn test_empty() {
381 let mut thresholds = AmlDetectabilityThresholds::default();
382 thresholds.min_sample_for_coverage = 0;
383 let analyzer = AmlDetectabilityAnalyzer::with_thresholds(thresholds);
384 let result = analyzer.analyze(&[], &[]).unwrap();
385 assert!(!result.passes); }
387
388 #[test]
389 fn test_small_sample_advisory_does_not_fail() {
390 let analyzer = AmlDetectabilityAnalyzer::new();
396 let typologies = vec![TypologyData {
397 name: "structuring".to_string(),
398 scenario_count: 5,
399 case_ids_consistent: true,
400 }];
401 let transactions = vec![AmlTransactionData {
402 transaction_id: "T001".to_string(),
403 typology: "structuring".to_string(),
404 case_id: "C001".to_string(),
405 amount: 9_500.0,
406 is_flagged: true,
407 }];
408
409 let result = analyzer.analyze(&transactions, &typologies).unwrap();
410 assert!(result.passes, "small sample should not fail on coverage");
411 assert!(
412 result.issues.iter().any(|i| i.starts_with("Advisory:")),
413 "small sample should surface an advisory issue, got: {:?}",
414 result.issues
415 );
416 }
417}