open-standards/openpay/crates/op-screening/src/screener.rs
Transaction Science 8438da166e clippy 1.99: fix what Rust 1.99.1's clippy newly reports, before the pin moves
These are the only lints clippy 1.99.1 reports that clippy 1.98.0 does not.
Every cargo root in the tree was compared on both toolchains (the evidence is
in the next commit). 1.98.0's clippy also builds every target of the fixed
code, and reports nothing it did not report before, so this commit stands
under either pin.

What changed, and why

- clippy::assert_is_empty is new in 1.99. 36 sites, all in test code
  (#[cfg(test)] modules; no lib or bin build reports any):
  aac-core and access-core, 23 sites; openpay's op-crypto-extra, op-emv,
  op-iso20022, op-reconciliation, op-routing, op-screening, op-settlement
  and op-tax, 13 sites. `assert!(x.is_empty())` becomes
  `assert_eq!(x, [] as [T; 0])`, `assert_ne!` for the negation, or `""` for
  strings, so a failure prints the value instead of only "false". aac-core and
  access-core are `#![deny(warnings)]` and `#![deny(clippy::all)]`, so without
  this their clippy run fails on 1.99.1.
  Judgment call: at 18 of the 36 sites, clippy's own "MachineApplicable"
  suggestion does not compile. It names local types by crate-relative path
  (`finder::Found`, which is not in scope in a test module), or offers a bare
  `[]` whose element type cannot be inferred. So each site names its element
  type as its own module sees it (`[] as [Found; 0]`, `[] as [f64; 0]`). The
  four lines rustfmt would wrap are wrapped the way rustfmt wraps them.

- clippy::approx_constant (deny-by-default) now knows EULER_GAMMA and
  GOLDEN_RATIO. Both are in std::f{32,64}::consts and stable since 1.94; the
  jouleclaw workspace MSRV is 1.97.
  - jouleclaw-omni, inference/architecture/mochi.rs: `1.618_034` becomes
    `std::f32::consts::GOLDEN_RATIO`. They are the same f32
    (1.61803400516510009765625), so the latents do not change. This file is
    in standards-build.yml's macOS gate (`cargo clippy -p jouleclaw-omni
    --features metal -- -D warnings`), which fails on 1.99.1 without the fix.
  - jouleclaw-outlier, c_factor: `0.5772156649` becomes
    `std::f64::consts::EULER_GAMMA`. Judgment call: the literal was
    truncated, so c(n) moves by 3.1e-12. The normalisation in the
    isolation-forest paper uses the Euler-Mascheroni constant itself, so this
    is the intended value. No other crate and no committed vector reads these
    scores, and the crate's tests pass unchanged.

- smart-byte-rs/Cargo.lock: async-trait 0.1.89 becomes 0.1.92, which adds
  syn 3.0.9 as its dependency. 0.1.89 put `#[must_use]` on every trait method
  it generates. clippy 1.99 now counts the returned `Pin<Box<dyn Future>>` as
  must_use already, so clippy::double_must_use fired at 9 #[async_trait] trait
  methods. The attribute there is the macro's, not ours, so the right fix is
  the macro: 0.1.92 no longer emits it, and every other lockfile in this tree
  already uses 0.1.92. A narrow `cargo update -p async-trait --precise 0.1.92`
  also moved data-encoding-macro-internal (which accepts syn >=1, <3) from syn
  2 to syn 1. That was put back to syn 2.0.117, as on main, so the lock
  changes only what the bump needs. `cargo update --workspace --locked` and
  scripts/check-lockfiles.sh accept it.

Left as they are

- openpay op-dispute-evidence evidence.rs:92 (clippy::double_must_use) is
  reported on 1.98.0 too. Only the message's wording changed in 1.99.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:20 -05:00

271 lines
8.9 KiB
Rust

//! Top-level screening orchestration.
//!
//! [`Screener`] is the type operators actually wire into their payment
//! flow. It owns the index, the config (threshold, max results, list
//! filter, optional DOB / address gating), and an [`crate::audit::AuditLog`]
//! that every screen call appends to. The result classification
//! ([`ScreenDecision::Clear`] / [`Hit`](ScreenDecision::Hit) /
//! [`AmbiguousNeedsReview`](ScreenDecision::AmbiguousNeedsReview))
//! drives the caller's downstream action.
use std::collections::HashSet;
use std::sync::Mutex;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::audit::AuditLog;
use crate::error::Result;
use crate::lists::{Address, Identification, WatchList};
use crate::matching::{MatchScore, screen};
use crate::storage::SanctionsIndex;
/// Tunable knobs for [`Screener`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScreenerConfig {
/// Combined-score threshold below which a candidate is dropped.
/// Default in production deployments: `0.85`.
pub threshold: f32,
/// Hard cap on the number of hits returned to the caller.
pub max_results: usize,
/// If true and the request carries a DOB, hits whose entity DOB
/// disagrees by more than ±1 year are demoted to
/// [`ScreenDecision::AmbiguousNeedsReview`].
pub screen_dob: bool,
/// If true, address country mismatch demotes the result similarly.
pub screen_address: bool,
/// If non-empty, only consider hits whose `source_list` is in the set.
pub watchlist_filter: HashSet<WatchList>,
/// Above-`threshold` but below `hit_threshold` lands in
/// `AmbiguousNeedsReview`. Defaults to `0.95`.
pub hit_threshold: f32,
}
impl Default for ScreenerConfig {
fn default() -> Self {
Self {
threshold: 0.85,
max_results: 25,
screen_dob: false,
screen_address: false,
watchlist_filter: HashSet::new(),
hit_threshold: 0.95,
}
}
}
/// What the operator wants to screen.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScreenRequest {
/// Name to screen. Required.
pub name: String,
/// Date of birth, if known. Used when `screen_dob` is on.
pub dob: Option<NaiveDate>,
/// Address, if known. Used when `screen_address` is on.
pub address: Option<Address>,
/// Other identifications to cross-screen (passport, national ID).
pub additional_ids: Vec<Identification>,
}
/// Three-way result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenDecision {
/// No hits at or above threshold.
Clear,
/// At least one hit at or above `hit_threshold`.
Hit,
/// Hits between `threshold` and `hit_threshold`. Human review required.
AmbiguousNeedsReview,
}
/// The output of [`Screener::screen`].
#[derive(Debug, Clone)]
pub struct ScreenResult {
/// All hits that survived filtering, sorted desc by score.
pub hits: Vec<MatchScore>,
/// Top-line decision.
pub decision: ScreenDecision,
/// Audit-log row id that recorded this call.
pub audit_id: String,
}
/// Stateful screening engine.
///
/// Wrap the audit log in a `Mutex` because every screen call is a
/// write into a chained, signed log; we need exclusive access for
/// the duration of the append. The index itself is read-only.
pub struct Screener {
index: SanctionsIndex,
config: ScreenerConfig,
audit_log: Mutex<AuditLog>,
}
impl Screener {
/// Build a new screener.
#[must_use]
pub fn new(index: SanctionsIndex, config: ScreenerConfig, audit_log: AuditLog) -> Self {
Self {
index,
config,
audit_log: Mutex::new(audit_log),
}
}
/// Borrow the audit log (e.g. for verification).
pub fn with_audit_log<R>(&self, f: impl FnOnce(&AuditLog) -> R) -> R {
let log = self.audit_log.lock().expect("audit-log mutex poisoned");
f(&log)
}
/// Active configuration.
#[must_use]
pub fn config(&self) -> &ScreenerConfig {
&self.config
}
/// How many entities the index covers.
#[must_use]
pub fn index_len(&self) -> usize {
self.index.len()
}
/// Screen a single request.
///
/// Always appends to the audit log, even on `Clear` — operators
/// rely on the negative-evidence trail when regulators inspect.
pub async fn screen(&self, request: &ScreenRequest) -> Result<ScreenResult> {
let raw_hits = screen(&request.name, &self.index, self.config.threshold);
// Optional list filter.
let filtered: Vec<MatchScore> = if self.config.watchlist_filter.is_empty() {
raw_hits
} else {
raw_hits
.into_iter()
.filter(|h| {
self.config
.watchlist_filter
.contains(&h.entity.source_list)
})
.collect()
};
// Optional DOB / address discrimination. We don't drop hits —
// a mismatched DOB on a perfect-name match is still ambiguous;
// we just demote the per-hit confidence so the overall decision
// gates into AmbiguousNeedsReview.
let mut hits = filtered;
if self.config.screen_dob {
if let Some(req_dob) = request.dob {
for h in &mut hits {
if let Some(ent_dob) = h.entity.dob {
let delta = (ent_dob - req_dob).num_days().abs();
if delta > 366 {
// ±1 year. Scale the score down by 20%.
h.score *= 0.8;
}
}
}
}
}
if self.config.screen_address {
if let Some(addr) = request.address.as_ref() {
for h in &mut hits {
let any_country_match = h
.entity
.addresses
.iter()
.any(|a| a.country == addr.country);
if !any_country_match && !h.entity.addresses.is_empty() {
h.score *= 0.9;
}
}
}
}
hits.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
hits.truncate(self.config.max_results);
let decision = if hits.is_empty() {
ScreenDecision::Clear
} else if hits[0].score >= self.config.hit_threshold {
ScreenDecision::Hit
} else {
ScreenDecision::AmbiguousNeedsReview
};
let audit_id = {
let mut log = self.audit_log.lock().expect("audit mutex poisoned");
log.record(
&request.name,
&hits,
match decision {
ScreenDecision::Clear => "clear",
ScreenDecision::Hit => "hit",
ScreenDecision::AmbiguousNeedsReview => "review",
},
)?
};
Ok(ScreenResult { hits, decision, audit_id })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::AuditLog;
use crate::lists::{EntityType, SanctionedEntity};
use chrono::Utc;
fn ent(id: &str, name: &str) -> SanctionedEntity {
SanctionedEntity {
id: id.to_string(),
name: name.to_string(),
name_aliases: vec![],
entity_type: EntityType::Individual,
dob: None,
place_of_birth: None,
addresses: vec![],
nationalities: vec![],
identifications: vec![],
programs: vec![],
last_updated: Utc::now(),
source_list: WatchList::OfacSdn,
}
}
#[tokio::test]
async fn clear_when_no_match() {
let idx = SanctionsIndex::build(vec![ent("1", "John Smith")]);
let s = Screener::new(idx, ScreenerConfig::default(), AuditLog::new());
let r = s
.screen(&ScreenRequest {
name: "Totally Unrelated Person".into(),
dob: None,
address: None,
additional_ids: vec![],
})
.await
.expect("ok");
assert_eq!(r.decision, ScreenDecision::Clear);
assert_eq!(r.hits, [] as [MatchScore; 0]);
}
#[tokio::test]
async fn exact_match_is_hit() {
let idx = SanctionsIndex::build(vec![ent("1", "John Smith")]);
let s = Screener::new(idx, ScreenerConfig::default(), AuditLog::new());
let r = s
.screen(&ScreenRequest {
name: "John Smith".into(),
dob: None,
address: None,
additional_ids: vec![],
})
.await
.expect("ok");
assert_eq!(r.decision, ScreenDecision::Hit);
assert_eq!(r.hits.len(), 1);
}
}