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)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_good_aml_data() {
325 let analyzer = AmlDetectabilityAnalyzer::new();
326 let typologies: Vec<TypologyData> = EXPECTED_TYPOLOGIES
329 .iter()
330 .map(|(canonical, _aliases)| TypologyData {
331 name: canonical.to_string(),
332 scenario_count: 5,
333 case_ids_consistent: true,
334 })
335 .collect();
336 let transactions = vec![
337 AmlTransactionData {
338 transaction_id: "T001".to_string(),
339 typology: "structuring".to_string(),
340 case_id: "C001".to_string(),
341 amount: 9_500.0,
342 is_flagged: true,
343 },
344 AmlTransactionData {
345 transaction_id: "T002".to_string(),
346 typology: "structuring".to_string(),
347 case_id: "C001".to_string(),
348 amount: 9_800.0,
349 is_flagged: true,
350 },
351 ];
352
353 let result = analyzer.analyze(&transactions, &typologies).unwrap();
354 assert!(result.passes);
355 assert_eq!(result.typology_coverage, 1.0);
356 }
357
358 #[test]
359 fn test_missing_typologies() {
360 let mut thresholds = AmlDetectabilityThresholds::default();
366 thresholds.min_sample_for_coverage = 0;
367 let analyzer = AmlDetectabilityAnalyzer::with_thresholds(thresholds);
368 let typologies = vec![TypologyData {
369 name: "structuring".to_string(),
370 scenario_count: 5,
371 case_ids_consistent: true,
372 }];
373
374 let result = analyzer.analyze(&[], &typologies).unwrap();
375 assert!(!result.passes); }
377
378 #[test]
379 fn test_empty() {
380 let mut thresholds = AmlDetectabilityThresholds::default();
381 thresholds.min_sample_for_coverage = 0;
382 let analyzer = AmlDetectabilityAnalyzer::with_thresholds(thresholds);
383 let result = analyzer.analyze(&[], &[]).unwrap();
384 assert!(!result.passes); }
386
387 #[test]
388 fn test_small_sample_advisory_does_not_fail() {
389 let analyzer = AmlDetectabilityAnalyzer::new();
395 let typologies = vec![TypologyData {
396 name: "structuring".to_string(),
397 scenario_count: 5,
398 case_ids_consistent: true,
399 }];
400 let transactions = vec![AmlTransactionData {
401 transaction_id: "T001".to_string(),
402 typology: "structuring".to_string(),
403 case_id: "C001".to_string(),
404 amount: 9_500.0,
405 is_flagged: true,
406 }];
407
408 let result = analyzer.analyze(&transactions, &typologies).unwrap();
409 assert!(result.passes, "small sample should not fail on coverage");
410 assert!(
411 result.issues.iter().any(|i| i.starts_with("Advisory:")),
412 "small sample should surface an advisory issue, got: {:?}",
413 result.issues
414 );
415 }
416}