1use crate::types::{
10 AccountProfile, BankTransaction, FraudDetectionResult, FraudPattern, FraudPatternType,
11 PatternMatch, PatternParams, RecommendedAction, RiskLevel,
12};
13use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
14use std::collections::{HashMap, HashSet};
15
16#[derive(Debug, Clone)]
28pub struct FraudPatternMatch {
29 metadata: KernelMetadata,
30}
31
32impl Default for FraudPatternMatch {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl FraudPatternMatch {
39 #[must_use]
41 pub fn new() -> Self {
42 Self {
43 metadata: KernelMetadata::ring("banking/fraud-pattern-match", Domain::Banking)
44 .with_description("Fraud pattern detection (Aho-Corasick, rapid split, cycles)")
45 .with_throughput(50_000)
46 .with_latency_us(100.0)
47 .with_gpu_native(true),
48 }
49 }
50
51 pub fn compute(
59 transaction: &BankTransaction,
60 history: &[BankTransaction],
61 patterns: &[FraudPattern],
62 profile: Option<&AccountProfile>,
63 ) -> FraudDetectionResult {
64 let mut matched_patterns = Vec::new();
65 let mut total_score = 0.0;
66 let mut related_transactions = HashSet::new();
67
68 let default_profile = AccountProfile::default();
69 let acct_profile = profile.unwrap_or(&default_profile);
70
71 for pattern in patterns {
72 if let Some(match_result) =
73 Self::check_pattern(transaction, history, pattern, acct_profile)
74 {
75 total_score += match_result.score * pattern.risk_weight / 100.0;
76 for &tx_id in &match_result.evidence {
77 related_transactions.insert(tx_id);
78 }
79 matched_patterns.push(match_result);
80 }
81 }
82
83 let fraud_score = (total_score / patterns.len().max(1) as f64 * 100.0).min(100.0);
85 let risk_level = RiskLevel::from(fraud_score);
86 let recommended_action = RecommendedAction::from(risk_level);
87
88 FraudDetectionResult {
89 transaction_id: transaction.id,
90 fraud_score,
91 matched_patterns,
92 risk_level,
93 recommended_action,
94 related_transactions: related_transactions.into_iter().collect(),
95 }
96 }
97
98 pub fn compute_batch(
100 transactions: &[BankTransaction],
101 history_map: &HashMap<u64, Vec<BankTransaction>>,
102 patterns: &[FraudPattern],
103 profiles: &HashMap<u64, AccountProfile>,
104 ) -> Vec<FraudDetectionResult> {
105 transactions
106 .iter()
107 .map(|tx| {
108 let history = history_map
109 .get(&tx.source_account)
110 .map(|h| h.as_slice())
111 .unwrap_or(&[]);
112 let profile = profiles.get(&tx.source_account);
113 Self::compute(tx, history, patterns, profile)
114 })
115 .collect()
116 }
117
118 fn check_pattern(
120 transaction: &BankTransaction,
121 history: &[BankTransaction],
122 pattern: &FraudPattern,
123 profile: &AccountProfile,
124 ) -> Option<PatternMatch> {
125 match pattern.pattern_type {
126 FraudPatternType::RapidSplit => {
127 Self::check_rapid_split(transaction, history, &pattern.params)
128 }
129 FraudPatternType::CircularFlow => {
130 Self::check_circular_flow(transaction, history, &pattern.params)
131 }
132 FraudPatternType::VelocityAnomaly => {
133 Self::check_velocity(transaction, history, profile, &pattern.params)
134 }
135 FraudPatternType::AmountAnomaly => {
136 Self::check_amount_anomaly(transaction, profile, &pattern.params)
137 }
138 FraudPatternType::GeoAnomaly => {
139 Self::check_geo_anomaly(transaction, history, profile, &pattern.params)
140 }
141 FraudPatternType::TimeAnomaly => Self::check_time_anomaly(transaction, profile),
142 FraudPatternType::AccountTakeover => {
143 Self::check_account_takeover(transaction, history, profile)
144 }
145 FraudPatternType::MuleAccount => {
146 Self::check_mule_account(transaction, history, &pattern.params)
147 }
148 FraudPatternType::Layering => {
149 Self::check_layering(transaction, history, &pattern.params)
150 }
151 }
152 .map(|(score, details, evidence)| PatternMatch {
153 pattern_id: pattern.id,
154 pattern_name: pattern.name.clone(),
155 score,
156 details,
157 evidence,
158 })
159 }
160
161 fn check_rapid_split(
164 transaction: &BankTransaction,
165 history: &[BankTransaction],
166 params: &PatternParams,
167 ) -> Option<(f64, String, Vec<u64>)> {
168 let threshold = params.amount_threshold;
169 let time_window = params.time_window;
170 let min_count = params.min_count;
171
172 let mut split_txs: Vec<&BankTransaction> = history
174 .iter()
175 .filter(|tx| {
176 tx.source_account == transaction.source_account
177 && tx.amount >= threshold * 0.8
178 && tx.amount < threshold
179 && transaction.timestamp.saturating_sub(time_window) <= tx.timestamp
180 })
181 .collect();
182
183 if transaction.amount >= threshold * 0.8 && transaction.amount < threshold {
185 split_txs.push(transaction);
186 }
187
188 if split_txs.len() >= min_count as usize {
189 let total: f64 = split_txs.iter().map(|tx| tx.amount).sum();
190 let score = if total > threshold { 80.0 } else { 60.0 };
191 let evidence: Vec<u64> = split_txs.iter().map(|tx| tx.id).collect();
192
193 Some((
194 score,
195 format!(
196 "Detected {} transactions totaling ${:.2} (threshold: ${:.2})",
197 split_txs.len(),
198 total,
199 threshold
200 ),
201 evidence,
202 ))
203 } else {
204 None
205 }
206 }
207
208 fn check_circular_flow(
211 transaction: &BankTransaction,
212 history: &[BankTransaction],
213 params: &PatternParams,
214 ) -> Option<(f64, String, Vec<u64>)> {
215 let time_window = params.time_window;
216 let min_chain_length = params
217 .custom
218 .get("min_chain_length")
219 .copied()
220 .unwrap_or(3.0) as usize;
221
222 let mut graph: HashMap<u64, Vec<(u64, u64, f64)>> = HashMap::new(); for tx in history.iter().chain(std::iter::once(transaction)) {
226 if transaction.timestamp.saturating_sub(time_window) <= tx.timestamp {
227 graph.entry(tx.source_account).or_default().push((
228 tx.dest_account,
229 tx.id,
230 tx.amount,
231 ));
232 }
233 }
234
235 let start = transaction.source_account;
237 let mut visited = HashSet::new();
238 let mut path = vec![(start, transaction.id)];
239 let mut cycle_evidence = Vec::new();
240
241 if Self::find_cycle(
242 &graph,
243 start,
244 start,
245 &mut visited,
246 &mut path,
247 &mut cycle_evidence,
248 min_chain_length,
249 ) {
250 let score = 90.0;
251 Some((
252 score,
253 format!("Circular flow detected with {} hops", cycle_evidence.len()),
254 cycle_evidence,
255 ))
256 } else {
257 None
258 }
259 }
260
261 fn find_cycle(
263 graph: &HashMap<u64, Vec<(u64, u64, f64)>>,
264 current: u64,
265 target: u64,
266 visited: &mut HashSet<u64>,
267 path: &mut Vec<(u64, u64)>,
268 evidence: &mut Vec<u64>,
269 min_length: usize,
270 ) -> bool {
271 if path.len() > 1 && current == target && path.len() >= min_length {
272 *evidence = path.iter().map(|(_, tx_id)| *tx_id).collect();
273 return true;
274 }
275
276 if path.len() > 10 || visited.contains(¤t) {
277 return false;
278 }
279
280 visited.insert(current);
281
282 if let Some(edges) = graph.get(¤t) {
283 for &(dest, tx_id, _) in edges {
284 path.push((dest, tx_id));
285 if Self::find_cycle(graph, dest, target, visited, path, evidence, min_length) {
286 return true;
287 }
288 path.pop();
289 }
290 }
291
292 visited.remove(¤t);
293 false
294 }
295
296 fn check_velocity(
298 transaction: &BankTransaction,
299 history: &[BankTransaction],
300 profile: &AccountProfile,
301 params: &PatternParams,
302 ) -> Option<(f64, String, Vec<u64>)> {
303 let time_window = params.time_window;
304
305 let recent_count = history
307 .iter()
308 .filter(|tx| {
309 tx.source_account == transaction.source_account
310 && transaction.timestamp.saturating_sub(time_window) <= tx.timestamp
311 })
312 .count()
313 + 1; let expected = profile.avg_daily_count * (time_window as f64 / 86400.0);
317 let std_dev = expected.sqrt().max(1.0);
318
319 let z_score = (recent_count as f64 - expected) / std_dev;
320
321 if z_score > 3.0 {
322 let score = (z_score * 20.0).min(100.0);
323 Some((
324 score,
325 format!(
326 "Velocity anomaly: {} transactions vs expected {:.1} (z={:.2})",
327 recent_count, expected, z_score
328 ),
329 vec![transaction.id],
330 ))
331 } else {
332 None
333 }
334 }
335
336 fn check_amount_anomaly(
338 transaction: &BankTransaction,
339 profile: &AccountProfile,
340 _params: &PatternParams,
341 ) -> Option<(f64, String, Vec<u64>)> {
342 let z_score = (transaction.amount - profile.avg_amount) / profile.std_amount.max(1.0);
343
344 if z_score.abs() > 3.0 {
345 let score = (z_score.abs() * 20.0).min(100.0);
346 Some((
347 score,
348 format!(
349 "Amount anomaly: ${:.2} vs avg ${:.2} (z={:.2})",
350 transaction.amount, profile.avg_amount, z_score
351 ),
352 vec![transaction.id],
353 ))
354 } else {
355 None
356 }
357 }
358
359 fn check_geo_anomaly(
361 transaction: &BankTransaction,
362 history: &[BankTransaction],
363 profile: &AccountProfile,
364 params: &PatternParams,
365 ) -> Option<(f64, String, Vec<u64>)> {
366 let tx_location = transaction.location.as_ref()?;
367
368 if profile.typical_locations.contains(tx_location) {
370 return None;
371 }
372
373 let time_window = params
375 .custom
376 .get("travel_window")
377 .copied()
378 .unwrap_or(3600.0) as u64;
379
380 let recent_diff_location = history.iter().find(|tx| {
381 tx.source_account == transaction.source_account
382 && transaction.timestamp.saturating_sub(time_window) <= tx.timestamp
383 && tx.location.as_ref() != Some(tx_location)
384 && tx.location.is_some()
385 });
386
387 if let Some(prev_tx) = recent_diff_location {
388 let score = 85.0;
389 Some((
390 score,
391 format!(
392 "Impossible travel: {} to {} in {}s",
393 prev_tx.location.as_ref().unwrap_or(&"Unknown".to_string()),
394 tx_location,
395 transaction.timestamp - prev_tx.timestamp
396 ),
397 vec![prev_tx.id, transaction.id],
398 ))
399 } else {
400 Some((
402 40.0,
403 format!("Unusual location: {}", tx_location),
404 vec![transaction.id],
405 ))
406 }
407 }
408
409 fn check_time_anomaly(
411 transaction: &BankTransaction,
412 profile: &AccountProfile,
413 ) -> Option<(f64, String, Vec<u64>)> {
414 let hour = ((transaction.timestamp % 86400) / 3600) as u8;
416
417 if !profile.typical_hours.contains(&hour) {
418 let score = 30.0; Some((
420 score,
421 format!("Transaction at unusual hour: {}:00", hour),
422 vec![transaction.id],
423 ))
424 } else {
425 None
426 }
427 }
428
429 fn check_account_takeover(
431 transaction: &BankTransaction,
432 history: &[BankTransaction],
433 profile: &AccountProfile,
434 ) -> Option<(f64, String, Vec<u64>)> {
435 let mut indicators = Vec::new();
436 let mut score: f64 = 0.0;
437
438 if profile.account_age_days < 30 && transaction.amount > profile.avg_amount * 5.0 {
440 indicators.push("New account with large transaction");
441 score += 40.0;
442 }
443
444 let recent_total: f64 = history
446 .iter()
447 .filter(|tx| tx.source_account == transaction.source_account)
448 .take(10)
449 .map(|tx| tx.amount)
450 .sum();
451
452 if transaction.amount > recent_total {
453 indicators.push("Transaction exceeds recent total");
454 score += 30.0;
455 }
456
457 if score > 0.0 {
460 Some((
461 score.min(100.0),
462 format!("Account takeover indicators: {}", indicators.join(", ")),
463 vec![transaction.id],
464 ))
465 } else {
466 None
467 }
468 }
469
470 fn check_mule_account(
472 transaction: &BankTransaction,
473 history: &[BankTransaction],
474 params: &PatternParams,
475 ) -> Option<(f64, String, Vec<u64>)> {
476 let time_window = params.time_window;
477
478 let recent: Vec<&BankTransaction> = history
480 .iter()
481 .filter(|tx| transaction.timestamp.saturating_sub(time_window) <= tx.timestamp)
482 .collect();
483
484 let incoming: f64 = recent
485 .iter()
486 .filter(|tx| tx.dest_account == transaction.source_account)
487 .map(|tx| tx.amount)
488 .sum();
489
490 let outgoing: f64 = recent
491 .iter()
492 .filter(|tx| tx.source_account == transaction.source_account)
493 .map(|tx| tx.amount)
494 .sum::<f64>()
495 + transaction.amount;
496
497 if incoming > 1000.0 && outgoing > incoming * 0.8 {
499 let pass_through_ratio = outgoing / incoming;
500 let score = (pass_through_ratio * 50.0).min(80.0);
501
502 Some((
503 score,
504 format!(
505 "Mule account behavior: ${:.2} in, ${:.2} out ({:.1}% pass-through)",
506 incoming,
507 outgoing,
508 pass_through_ratio * 100.0
509 ),
510 recent.iter().map(|tx| tx.id).collect(),
511 ))
512 } else {
513 None
514 }
515 }
516
517 fn check_layering(
519 transaction: &BankTransaction,
520 history: &[BankTransaction],
521 params: &PatternParams,
522 ) -> Option<(f64, String, Vec<u64>)> {
523 let time_window = params.time_window;
524 let min_layers = params.custom.get("min_layers").copied().unwrap_or(3.0) as usize;
525
526 let mut accounts = HashSet::new();
528 let mut evidence = Vec::new();
529
530 for tx in history
531 .iter()
532 .filter(|tx| transaction.timestamp.saturating_sub(time_window) <= tx.timestamp)
533 {
534 accounts.insert(tx.source_account);
535 accounts.insert(tx.dest_account);
536 evidence.push(tx.id);
537 }
538 accounts.insert(transaction.source_account);
539 accounts.insert(transaction.dest_account);
540 evidence.push(transaction.id);
541
542 if accounts.len() >= min_layers * 2 {
544 let score = (accounts.len() as f64 * 10.0).min(90.0);
545 Some((
546 score,
547 format!("Complex layering: {} accounts involved", accounts.len()),
548 evidence,
549 ))
550 } else {
551 None
552 }
553 }
554
555 pub fn standard_patterns() -> Vec<FraudPattern> {
557 vec![
558 FraudPattern {
559 id: 1,
560 name: "Rapid Split (Structuring)".to_string(),
561 pattern_type: FraudPatternType::RapidSplit,
562 risk_weight: 80.0,
563 params: PatternParams {
564 time_window: 86400, min_count: 3,
566 amount_threshold: 10000.0,
567 ..Default::default()
568 },
569 },
570 FraudPattern {
571 id: 2,
572 name: "Circular Flow".to_string(),
573 pattern_type: FraudPatternType::CircularFlow,
574 risk_weight: 90.0,
575 params: PatternParams {
576 time_window: 604800, custom: [("min_chain_length".to_string(), 3.0)]
578 .into_iter()
579 .collect(),
580 ..Default::default()
581 },
582 },
583 FraudPattern {
584 id: 3,
585 name: "Velocity Anomaly".to_string(),
586 pattern_type: FraudPatternType::VelocityAnomaly,
587 risk_weight: 60.0,
588 params: PatternParams {
589 time_window: 3600, ..Default::default()
591 },
592 },
593 FraudPattern {
594 id: 4,
595 name: "Amount Anomaly".to_string(),
596 pattern_type: FraudPatternType::AmountAnomaly,
597 risk_weight: 50.0,
598 params: PatternParams::default(),
599 },
600 FraudPattern {
601 id: 5,
602 name: "Geographic Anomaly".to_string(),
603 pattern_type: FraudPatternType::GeoAnomaly,
604 risk_weight: 70.0,
605 params: PatternParams {
606 custom: [("travel_window".to_string(), 7200.0)]
607 .into_iter()
608 .collect(), ..Default::default()
610 },
611 },
612 FraudPattern {
613 id: 6,
614 name: "Mule Account".to_string(),
615 pattern_type: FraudPatternType::MuleAccount,
616 risk_weight: 85.0,
617 params: PatternParams {
618 time_window: 86400, ..Default::default()
620 },
621 },
622 ]
623 }
624}
625
626impl GpuKernel for FraudPatternMatch {
627 fn metadata(&self) -> &KernelMetadata {
628 &self.metadata
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::types::{Channel, TransactionType};
636
637 fn create_transaction(
638 id: u64,
639 source: u64,
640 dest: u64,
641 amount: f64,
642 timestamp: u64,
643 ) -> BankTransaction {
644 BankTransaction {
645 id,
646 source_account: source,
647 dest_account: dest,
648 amount,
649 timestamp,
650 tx_type: TransactionType::Wire,
651 channel: Channel::Online,
652 mcc: None,
653 location: Some("US".to_string()),
654 }
655 }
656
657 #[test]
658 fn test_fraud_pattern_match_metadata() {
659 let kernel = FraudPatternMatch::new();
660 assert_eq!(kernel.metadata().id, "banking/fraud-pattern-match");
661 assert_eq!(kernel.metadata().domain, Domain::Banking);
662 }
663
664 #[test]
665 fn test_normal_transaction() {
666 let tx = create_transaction(1, 100, 200, 500.0, 1000000);
667 let patterns = FraudPatternMatch::standard_patterns();
668
669 let result = FraudPatternMatch::compute(&tx, &[], &patterns, None);
670
671 assert!(result.fraud_score < 50.0, "Score: {}", result.fraud_score);
673 assert_eq!(result.risk_level, RiskLevel::Low);
674 }
675
676 #[test]
677 fn test_rapid_split_detection() {
678 let base_time = 1000000u64;
679 let threshold = 10000.0;
680
681 let history = vec![
683 create_transaction(1, 100, 200, 9500.0, base_time),
684 create_transaction(2, 100, 201, 9800.0, base_time + 1000),
685 create_transaction(3, 100, 202, 9600.0, base_time + 2000),
686 ];
687
688 let current = create_transaction(4, 100, 203, 9700.0, base_time + 3000);
689
690 let patterns = vec![FraudPattern {
691 id: 1,
692 name: "Rapid Split".to_string(),
693 pattern_type: FraudPatternType::RapidSplit,
694 risk_weight: 80.0,
695 params: PatternParams {
696 time_window: 86400,
697 min_count: 3,
698 amount_threshold: threshold,
699 ..Default::default()
700 },
701 }];
702
703 let result = FraudPatternMatch::compute(¤t, &history, &patterns, None);
704
705 assert!(
706 !result.matched_patterns.is_empty(),
707 "Should detect rapid split"
708 );
709 assert!(result.fraud_score > 30.0);
710 }
711
712 #[test]
713 fn test_circular_flow_detection() {
714 let base_time = 1000000u64;
715
716 let history = vec![
718 create_transaction(1, 100, 200, 5000.0, base_time), create_transaction(2, 200, 300, 4800.0, base_time + 100), create_transaction(3, 300, 100, 4600.0, base_time + 200), ];
722
723 let current = create_transaction(4, 100, 200, 4500.0, base_time + 300);
724
725 let patterns = vec![FraudPattern {
726 id: 1,
727 name: "Circular Flow".to_string(),
728 pattern_type: FraudPatternType::CircularFlow,
729 risk_weight: 90.0,
730 params: PatternParams {
731 time_window: 86400,
732 custom: [("min_chain_length".to_string(), 3.0)]
733 .into_iter()
734 .collect(),
735 ..Default::default()
736 },
737 }];
738
739 let result = FraudPatternMatch::compute(¤t, &history, &patterns, None);
740
741 let has_circular = result
743 .matched_patterns
744 .iter()
745 .any(|p| p.pattern_name.contains("Circular"));
746 assert!(has_circular, "Should detect circular flow");
747 }
748
749 #[test]
750 fn test_velocity_anomaly() {
751 let base_time = 1000000u64;
752
753 let history: Vec<BankTransaction> = (0..20)
755 .map(|i| create_transaction(i, 100, 200 + i, 100.0, base_time + i * 60))
756 .collect();
757
758 let current = create_transaction(21, 100, 300, 100.0, base_time + 1260);
759
760 let profile = AccountProfile {
761 account_id: 100,
762 avg_daily_count: 2.0, ..Default::default()
764 };
765
766 let patterns = vec![FraudPattern {
767 id: 1,
768 name: "Velocity".to_string(),
769 pattern_type: FraudPatternType::VelocityAnomaly,
770 risk_weight: 60.0,
771 params: PatternParams {
772 time_window: 3600, ..Default::default()
774 },
775 }];
776
777 let result = FraudPatternMatch::compute(¤t, &history, &patterns, Some(&profile));
778
779 let has_velocity = result
780 .matched_patterns
781 .iter()
782 .any(|p| p.pattern_name.contains("Velocity"));
783 assert!(has_velocity, "Should detect velocity anomaly");
784 }
785
786 #[test]
787 fn test_amount_anomaly() {
788 let profile = AccountProfile {
789 account_id: 100,
790 avg_amount: 500.0,
791 std_amount: 100.0,
792 ..Default::default()
793 };
794
795 let tx = create_transaction(1, 100, 200, 5000.0, 1000000);
797
798 let patterns = vec![FraudPattern {
799 id: 1,
800 name: "Amount Anomaly".to_string(),
801 pattern_type: FraudPatternType::AmountAnomaly,
802 risk_weight: 50.0,
803 params: PatternParams::default(),
804 }];
805
806 let result = FraudPatternMatch::compute(&tx, &[], &patterns, Some(&profile));
807
808 let has_amount = result
809 .matched_patterns
810 .iter()
811 .any(|p| p.pattern_name.contains("Amount"));
812 assert!(has_amount, "Should detect amount anomaly");
813 }
814
815 #[test]
816 fn test_geo_anomaly() {
817 let base_time = 1000000u64;
818
819 let mut tx1 = create_transaction(1, 100, 200, 500.0, base_time);
820 tx1.location = Some("US".to_string());
821
822 let mut tx2 = create_transaction(2, 100, 201, 500.0, base_time + 1800); tx2.location = Some("UK".to_string()); let profile = AccountProfile {
826 account_id: 100,
827 typical_locations: vec!["US".to_string()],
828 ..Default::default()
829 };
830
831 let patterns = vec![FraudPattern {
832 id: 1,
833 name: "Geographic Anomaly".to_string(),
834 pattern_type: FraudPatternType::GeoAnomaly,
835 risk_weight: 70.0,
836 params: PatternParams {
837 custom: [("travel_window".to_string(), 7200.0)]
838 .into_iter()
839 .collect(),
840 ..Default::default()
841 },
842 }];
843
844 let result = FraudPatternMatch::compute(&tx2, &[tx1], &patterns, Some(&profile));
845
846 let has_geo = result
847 .matched_patterns
848 .iter()
849 .any(|p| p.pattern_name.contains("Geographic"));
850 assert!(has_geo, "Should detect geographic anomaly");
851 }
852
853 #[test]
854 fn test_mule_account() {
855 let base_time = 1000000u64;
856
857 let history = vec![
859 create_transaction(1, 200, 100, 10000.0, base_time), create_transaction(2, 100, 300, 3000.0, base_time + 100), create_transaction(3, 100, 301, 3000.0, base_time + 200), create_transaction(4, 100, 302, 3000.0, base_time + 300), ];
864
865 let current = create_transaction(5, 100, 303, 800.0, base_time + 400);
866
867 let patterns = vec![FraudPattern {
868 id: 1,
869 name: "Mule Account".to_string(),
870 pattern_type: FraudPatternType::MuleAccount,
871 risk_weight: 85.0,
872 params: PatternParams {
873 time_window: 86400,
874 ..Default::default()
875 },
876 }];
877
878 let result = FraudPatternMatch::compute(¤t, &history, &patterns, None);
879
880 let has_mule = result
881 .matched_patterns
882 .iter()
883 .any(|p| p.pattern_name.contains("Mule"));
884 assert!(has_mule, "Should detect mule account behavior");
885 }
886
887 #[test]
888 fn test_standard_patterns() {
889 let patterns = FraudPatternMatch::standard_patterns();
890
891 assert!(!patterns.is_empty());
892 assert!(
893 patterns
894 .iter()
895 .any(|p| p.pattern_type == FraudPatternType::RapidSplit)
896 );
897 assert!(
898 patterns
899 .iter()
900 .any(|p| p.pattern_type == FraudPatternType::CircularFlow)
901 );
902 }
903
904 #[test]
905 fn test_risk_level_conversion() {
906 assert_eq!(RiskLevel::from(10.0), RiskLevel::Low);
907 assert_eq!(RiskLevel::from(30.0), RiskLevel::Medium);
908 assert_eq!(RiskLevel::from(60.0), RiskLevel::High);
909 assert_eq!(RiskLevel::from(90.0), RiskLevel::Critical);
910 }
911
912 #[test]
913 fn test_batch_processing() {
914 let txs = vec![
915 create_transaction(1, 100, 200, 500.0, 1000000),
916 create_transaction(2, 101, 201, 600.0, 1000001),
917 ];
918
919 let history_map: HashMap<u64, Vec<BankTransaction>> = HashMap::new();
920 let profiles: HashMap<u64, AccountProfile> = HashMap::new();
921 let patterns = FraudPatternMatch::standard_patterns();
922
923 let results = FraudPatternMatch::compute_batch(&txs, &history_map, &patterns, &profiles);
924
925 assert_eq!(results.len(), 2);
926 }
927}