1use 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#[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 #[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 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 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 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 fn match_score(query: &str, entry: &SanctionsEntry) -> f64 {
122 let mut best_score = Self::name_similarity(query, &entry.name);
124
125 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 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 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 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#[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 #[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 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 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 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
315fn 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
331fn 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 }
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 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}