rustkernel_compliance/
sanctions.rs

1//! Sanctions screening kernels.
2//!
3//! This module provides sanctions and PEP screening:
4//! - OFAC/UN/EU sanctions list screening
5//! - Politically Exposed Persons (PEP) screening
6
7use crate::messages::{
8    PEPScreeningInput, PEPScreeningOutput, SanctionsScreeningInput, SanctionsScreeningOutput,
9};
10use crate::types::{
11    PEPEntry, PEPMatch, PEPResult, SanctionsEntry, SanctionsMatch, SanctionsResult,
12};
13use async_trait::async_trait;
14use rustkernel_core::error::Result;
15use rustkernel_core::traits::BatchKernel;
16use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
17use std::time::Instant;
18
19// ============================================================================
20// Sanctions Screening Kernel
21// ============================================================================
22
23/// Sanctions list screening kernel.
24///
25/// Screens names against OFAC, UN, EU and other sanctions lists
26/// using fuzzy matching.
27#[derive(Debug, Clone)]
28pub struct SanctionsScreening {
29    metadata: KernelMetadata,
30}
31
32impl Default for SanctionsScreening {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl SanctionsScreening {
39    /// Create a new sanctions screening kernel.
40    #[must_use]
41    pub fn new() -> Self {
42        Self {
43            metadata: KernelMetadata::ring("compliance/sanctions-screening", Domain::Compliance)
44                .with_description("OFAC/UN/EU sanctions list screening")
45                .with_throughput(100_000)
46                .with_latency_us(10.0),
47        }
48    }
49
50    /// Screen a name against sanctions lists.
51    ///
52    /// # Arguments
53    /// * `name` - Name to screen
54    /// * `sanctions_list` - List of sanctions entries
55    /// * `min_score` - Minimum match score threshold (0-1)
56    /// * `max_matches` - Maximum number of matches to return
57    pub fn compute(
58        name: &str,
59        sanctions_list: &[SanctionsEntry],
60        min_score: f64,
61        max_matches: usize,
62    ) -> SanctionsResult {
63        if name.is_empty() || sanctions_list.is_empty() {
64            return SanctionsResult {
65                query_name: name.to_string(),
66                matches: Vec::new(),
67                is_hit: false,
68            };
69        }
70
71        let mut matches: Vec<SanctionsMatch> = sanctions_list
72            .iter()
73            .filter_map(|entry| {
74                let score = Self::match_score(name, entry);
75                if score >= min_score {
76                    let matched_name = Self::best_matching_name(name, entry);
77                    Some(SanctionsMatch {
78                        entry_id: entry.id,
79                        score,
80                        matched_name,
81                        source: entry.source.clone(),
82                        reason: format!("Name match score: {:.2}%", score * 100.0),
83                    })
84                } else {
85                    None
86                }
87            })
88            .collect();
89
90        // Sort by score descending
91        matches.sort_by(|a, b| {
92            b.score
93                .partial_cmp(&a.score)
94                .unwrap_or(std::cmp::Ordering::Equal)
95        });
96        matches.truncate(max_matches);
97
98        let is_hit = matches.iter().any(|m| m.score >= 0.85);
99
100        SanctionsResult {
101            query_name: name.to_string(),
102            matches,
103            is_hit,
104        }
105    }
106
107    /// Batch screen multiple names.
108    pub fn compute_batch(
109        names: &[String],
110        sanctions_list: &[SanctionsEntry],
111        min_score: f64,
112        max_matches: usize,
113    ) -> Vec<SanctionsResult> {
114        names
115            .iter()
116            .map(|name| Self::compute(name, sanctions_list, min_score, max_matches))
117            .collect()
118    }
119
120    /// Calculate match score between a name and a sanctions entry.
121    fn match_score(query: &str, entry: &SanctionsEntry) -> f64 {
122        // Check primary name
123        let mut best_score = Self::name_similarity(query, &entry.name);
124
125        // Check aliases
126        for alias in &entry.aliases {
127            let alias_score = Self::name_similarity(query, alias);
128            best_score = best_score.max(alias_score);
129        }
130
131        best_score
132    }
133
134    /// Get the best matching name from an entry.
135    fn best_matching_name(query: &str, entry: &SanctionsEntry) -> String {
136        let mut best_name = entry.name.clone();
137        let mut best_score = Self::name_similarity(query, &entry.name);
138
139        for alias in &entry.aliases {
140            let score = Self::name_similarity(query, alias);
141            if score > best_score {
142                best_score = score;
143                best_name = alias.clone();
144            }
145        }
146
147        best_name
148    }
149
150    /// Name similarity using Jaro-Winkler.
151    fn name_similarity(s1: &str, s2: &str) -> f64 {
152        let s1 = s1.to_lowercase();
153        let s2 = s2.to_lowercase();
154
155        if s1 == s2 {
156            return 1.0;
157        }
158
159        if s1.is_empty() || s2.is_empty() {
160            return 0.0;
161        }
162
163        // Use the same Jaro-Winkler as EntityResolution
164        jaro_winkler(&s1, &s2)
165    }
166}
167
168impl GpuKernel for SanctionsScreening {
169    fn metadata(&self) -> &KernelMetadata {
170        &self.metadata
171    }
172}
173
174#[async_trait]
175impl BatchKernel<SanctionsScreeningInput, SanctionsScreeningOutput> for SanctionsScreening {
176    async fn execute(&self, input: SanctionsScreeningInput) -> Result<SanctionsScreeningOutput> {
177        let start = Instant::now();
178        let result = Self::compute(
179            &input.name,
180            &input.sanctions_list,
181            input.min_score,
182            input.max_matches,
183        );
184        Ok(SanctionsScreeningOutput {
185            result,
186            compute_time_us: start.elapsed().as_micros() as u64,
187        })
188    }
189}
190
191// ============================================================================
192// PEP Screening Kernel
193// ============================================================================
194
195/// PEP (Politically Exposed Persons) screening kernel.
196///
197/// Screens names against PEP lists to identify individuals
198/// with political connections.
199#[derive(Debug, Clone)]
200pub struct PEPScreening {
201    metadata: KernelMetadata,
202}
203
204impl Default for PEPScreening {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl PEPScreening {
211    /// Create a new PEP screening kernel.
212    #[must_use]
213    pub fn new() -> Self {
214        Self {
215            metadata: KernelMetadata::ring("compliance/pep-screening", Domain::Compliance)
216                .with_description("Politically Exposed Persons screening")
217                .with_throughput(100_000)
218                .with_latency_us(10.0),
219        }
220    }
221
222    /// Screen a name against PEP lists.
223    ///
224    /// # Arguments
225    /// * `name` - Name to screen
226    /// * `pep_list` - List of PEP entries
227    /// * `min_score` - Minimum match score threshold
228    /// * `max_matches` - Maximum number of matches
229    pub fn compute(
230        name: &str,
231        pep_list: &[PEPEntry],
232        min_score: f64,
233        max_matches: usize,
234    ) -> PEPResult {
235        if name.is_empty() || pep_list.is_empty() {
236            return PEPResult {
237                query_name: name.to_string(),
238                matches: Vec::new(),
239                is_pep: false,
240            };
241        }
242
243        let mut matches: Vec<PEPMatch> = pep_list
244            .iter()
245            .filter_map(|entry| {
246                let score = jaro_winkler(&name.to_lowercase(), &entry.name.to_lowercase());
247                if score >= min_score {
248                    Some(PEPMatch {
249                        entry_id: entry.id,
250                        score,
251                        name: entry.name.clone(),
252                        position: entry.position.clone(),
253                        country: entry.country.clone(),
254                        level: entry.level,
255                    })
256                } else {
257                    None
258                }
259            })
260            .collect();
261
262        // Sort by score descending, then by level (higher risk first)
263        matches.sort_by(|a, b| match b.score.partial_cmp(&a.score) {
264            Some(std::cmp::Ordering::Equal) => a.level.cmp(&b.level),
265            other => other.unwrap_or(std::cmp::Ordering::Equal),
266        });
267        matches.truncate(max_matches);
268
269        let is_pep = matches.iter().any(|m| m.score >= 0.85);
270
271        PEPResult {
272            query_name: name.to_string(),
273            matches,
274            is_pep,
275        }
276    }
277
278    /// Batch screen multiple names.
279    pub fn compute_batch(
280        names: &[String],
281        pep_list: &[PEPEntry],
282        min_score: f64,
283        max_matches: usize,
284    ) -> Vec<PEPResult> {
285        names
286            .iter()
287            .map(|name| Self::compute(name, pep_list, min_score, max_matches))
288            .collect()
289    }
290}
291
292impl GpuKernel for PEPScreening {
293    fn metadata(&self) -> &KernelMetadata {
294        &self.metadata
295    }
296}
297
298#[async_trait]
299impl BatchKernel<PEPScreeningInput, PEPScreeningOutput> for PEPScreening {
300    async fn execute(&self, input: PEPScreeningInput) -> Result<PEPScreeningOutput> {
301        let start = Instant::now();
302        let result = Self::compute(
303            &input.name,
304            &input.pep_list,
305            input.min_score,
306            input.max_matches,
307        );
308        Ok(PEPScreeningOutput {
309            result,
310            compute_time_us: start.elapsed().as_micros() as u64,
311        })
312    }
313}
314
315// ============================================================================
316// String Similarity Helper
317// ============================================================================
318
319/// Jaro-Winkler similarity function.
320fn jaro_winkler(s1: &str, s2: &str) -> f64 {
321    let jaro = jaro(s1, s2);
322    let prefix_len = s1
323        .chars()
324        .zip(s2.chars())
325        .take(4)
326        .take_while(|(a, b)| a == b)
327        .count();
328    jaro + (prefix_len as f64 * 0.1 * (1.0 - jaro))
329}
330
331/// Jaro similarity function.
332fn jaro(s1: &str, s2: &str) -> f64 {
333    let s1_chars: Vec<char> = s1.chars().collect();
334    let s2_chars: Vec<char> = s2.chars().collect();
335
336    let len1 = s1_chars.len();
337    let len2 = s2_chars.len();
338
339    if len1 == 0 || len2 == 0 {
340        return 0.0;
341    }
342
343    if s1 == s2 {
344        return 1.0;
345    }
346
347    let match_distance = (len1.max(len2) / 2).saturating_sub(1);
348
349    let mut s1_matches = vec![false; len1];
350    let mut s2_matches = vec![false; len2];
351
352    let mut matches = 0usize;
353    let mut transpositions = 0usize;
354
355    for i in 0..len1 {
356        let start = i.saturating_sub(match_distance);
357        let end = (i + match_distance + 1).min(len2);
358
359        for j in start..end {
360            if s2_matches[j] || s1_chars[i] != s2_chars[j] {
361                continue;
362            }
363            s1_matches[i] = true;
364            s2_matches[j] = true;
365            matches += 1;
366            break;
367        }
368    }
369
370    if matches == 0 {
371        return 0.0;
372    }
373
374    let mut k = 0usize;
375    for i in 0..len1 {
376        if !s1_matches[i] {
377            continue;
378        }
379        while !s2_matches[k] {
380            k += 1;
381        }
382        if s1_chars[i] != s2_chars[k] {
383            transpositions += 1;
384        }
385        k += 1;
386    }
387
388    let m = matches as f64;
389    let t = transpositions as f64 / 2.0;
390
391    (m / len1 as f64 + m / len2 as f64 + (m - t) / m) / 3.0
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn create_sanctions_list() -> Vec<SanctionsEntry> {
399        vec![
400            SanctionsEntry {
401                id: 1,
402                name: "John Doe".to_string(),
403                aliases: vec!["Johnny Doe".to_string(), "J. Doe".to_string()],
404                source: "OFAC".to_string(),
405                program: "SDN".to_string(),
406                country: Some("IR".to_string()),
407                dob: Some(19700115),
408            },
409            SanctionsEntry {
410                id: 2,
411                name: "Evil Corp LLC".to_string(),
412                aliases: vec!["Evil Corporation".to_string()],
413                source: "OFAC".to_string(),
414                program: "SDN".to_string(),
415                country: Some("RU".to_string()),
416                dob: None,
417            },
418        ]
419    }
420
421    fn create_pep_list() -> Vec<PEPEntry> {
422        vec![
423            PEPEntry {
424                id: 1,
425                name: "Vladimir Putin".to_string(),
426                position: "President".to_string(),
427                country: "RU".to_string(),
428                level: 1,
429                active: true,
430            },
431            PEPEntry {
432                id: 2,
433                name: "Joe Biden".to_string(),
434                position: "President".to_string(),
435                country: "US".to_string(),
436                level: 1,
437                active: true,
438            },
439        ]
440    }
441
442    #[test]
443    fn test_sanctions_screening_metadata() {
444        let kernel = SanctionsScreening::new();
445        assert_eq!(kernel.metadata().id, "compliance/sanctions-screening");
446        assert_eq!(kernel.metadata().domain, Domain::Compliance);
447    }
448
449    #[test]
450    fn test_sanctions_exact_match() {
451        let list = create_sanctions_list();
452        let result = SanctionsScreening::compute("John Doe", &list, 0.5, 10);
453
454        assert!(result.is_hit);
455        assert!(!result.matches.is_empty());
456        assert_eq!(result.matches[0].entry_id, 1);
457        assert!(result.matches[0].score > 0.9);
458    }
459
460    #[test]
461    fn test_sanctions_alias_match() {
462        let list = create_sanctions_list();
463        let result = SanctionsScreening::compute("Johnny Doe", &list, 0.5, 10);
464
465        assert!(result.is_hit);
466        assert!(!result.matches.is_empty());
467        assert!(result.matches[0].score > 0.9);
468    }
469
470    #[test]
471    fn test_sanctions_fuzzy_match() {
472        let list = create_sanctions_list();
473        let result = SanctionsScreening::compute("Jon Doe", &list, 0.5, 10);
474
475        assert!(!result.matches.is_empty());
476        assert!(result.matches[0].score > 0.7);
477    }
478
479    #[test]
480    fn test_sanctions_no_match() {
481        let list = create_sanctions_list();
482        let result = SanctionsScreening::compute("Alice Wonderland", &list, 0.8, 10);
483
484        assert!(!result.is_hit);
485        // May have weak matches below threshold
486    }
487
488    #[test]
489    fn test_pep_screening_metadata() {
490        let kernel = PEPScreening::new();
491        assert_eq!(kernel.metadata().id, "compliance/pep-screening");
492    }
493
494    #[test]
495    fn test_pep_exact_match() {
496        let list = create_pep_list();
497        let result = PEPScreening::compute("Vladimir Putin", &list, 0.5, 10);
498
499        assert!(result.is_pep);
500        assert!(!result.matches.is_empty());
501        assert_eq!(result.matches[0].level, 1);
502    }
503
504    #[test]
505    fn test_pep_fuzzy_match() {
506        let list = create_pep_list();
507        let result = PEPScreening::compute("Vladmir Putin", &list, 0.7, 10);
508
509        // Should still match with fuzzy matching
510        assert!(!result.matches.is_empty());
511        assert!(result.matches[0].score > 0.8);
512    }
513
514    #[test]
515    fn test_empty_inputs() {
516        let list = create_sanctions_list();
517
518        let result1 = SanctionsScreening::compute("", &list, 0.5, 10);
519        assert!(result1.matches.is_empty());
520
521        let result2 = SanctionsScreening::compute("John Doe", &[], 0.5, 10);
522        assert!(result2.matches.is_empty());
523    }
524}