open-standards/openpay/crates/op-screening/src/storage.rs
Transaction Science 6575ff1eb7 Ingest batch 3: 9 more crates across openpay / smart-byte / eoc
openpay/ (+3 crates)
  op-bnpl/        Affirm + Klarna + Afterpay/Clearpay rails. Full lifecycle
                  typestate (Initiated→Approved→Captured→Settled→Refunded).
                  Per-provider sandbox+production base URLs, regions (NA/EU/OC
                  for Klarna; US/AuNz/Gb/Ca/Eu for Afterpay). HMAC-SHA256
                  webhook verification with constant-time compare. 61 tests.
  op-batch/       NACHA ACH (94-char records, PPD/CCD/WEB/TEL/CTX, R01-R99
                  return codes, Same-Day ACH cutoffs); SEPA pain.001.001.09
                  (CT) + pain.008.001.08 (DD) with SCT-Inst flagging; Fedwire
                  MT103/MT202/CHIPS + pacs.008.001.12 / pacs.009.001.10 +
                  UETR; Bacs Standard 18. Per-rail cutoff scheduler. Exception
                  taxonomy unified across rails. 51 tests.
  op-screening/   OFAC SDN + EU consolidated + UN consolidated + HMT UK
                  consolidated + Australia DFAT + Canada SEMA + Japan MOF.
                  Levenshtein + Jaro-Winkler + Soundex + Metaphone + token
                  Jaccard scoring. Unicode NFKD normalisation + Cyrillic /
                  Arabic transliteration. Ed25519-signed BLAKE3-chained
                  audit log. BSA helpers: CTR + SAR pattern detection
                  (structuring, round-dollar, rapid-in-out). 43 tests.

smart-byte/smart-byte-rs/ (+3 crates)
  smart-byte-bbs/      BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures-08).
                       BLS12-381 keys, signatures, disclosure proofs with
                       cryptographic unlinkability. bbs-2023 Data Integrity
                       cryptosuite. AnonCreds-shaped link secrets +
                       predicate proofs. SecretKey zeroize-on-drop. 49 tests.
  smart-byte-didcomm/  DIDComm v2 messaging per DIF spec. Pack-encrypted
                       (ECDH-1PU + ECDH-ES via X25519), pack-signed (JWS),
                       pack-plaintext. Aries protocols: issue-credential v3,
                       present-proof v3, basic-message, trust-ping, discover-
                       features, out-of-band, coordinate-mediation, message-
                       pickup. Mediator role with pluggable storage. 23 tests.
  smart-byte-mdl/      ISO/IEC 18013-5 mobile driver's licence + 18013-7
                       online presentment. mDoc + MSO with salted-hash
                       selective disclosure (per item; SHA-256 digest tree).
                       org.iso.18013.5.1 namespace + AAMVA add-on. ES256
                       device-auth + issuer-auth. QR-code device-engagement
                       scaffolding. 28 tests.

eoc/eoc-rs/ (+3 crates)
  eoc-local/      Local-inference backends behind one NeuralBackend:
                  llama.cpp (GGUF, Q4/Q5/Q6/Q8/F16/F32 quantisation),
                  MLX (Apple Silicon, feature-gated), MLC-LLM (TVM, feature-
                  gated marker), ONNX Runtime. GGUF file-format reader.
                  HuggingFace tokenizers. Greedy / top-k / top-p / temperature /
                  Mirostat samplers. Model registry scanning ~/.eoc/models/.
                  Joule attribution via eoc-meter. 39 tests.
  eoc-rerank/     BM25 sparse + HNSW dense + RRF + min-max hybrid fusion.
                  Cohere v3.5 + Voyage v2 + bge-reranker v2-m3 + ms-marco
                  MiniLM + ColBERT v2 rerankers. Two-stage pipeline:
                  retrieve top-K hybrid → cross-encoder rerank → top-N.
                  KV-stage recovery shim into the cascade. 31 tests.
  eoc-eval/       15 canonical eval harnesses: MMLU, MMLU-Pro, GPQA,
                  HumanEval (pass@1 with python3 sandbox), BBH, IFEval (25
                  verifiable constraints), AlpacaEval, AGIEval, HellaSwag,
                  ARC (Easy + Challenge), TruthfulQA (MC1+MC2), BoolQ,
                  GSM8K, MATH (boxed-answer equivalence), Winogrande. Real
                  questions in BuiltinSamples. EvalRunner emits joules-per-
                  correct alongside accuracy. 49 tests.

Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 374 new tests, all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]

Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
2026-05-24 01:09:35 -04:00

254 lines
8.2 KiB
Rust

//! In-memory index storage with on-disk CBOR snapshot.
//!
//! The sanctions corpus is small (~30k entries across all lists in
//! 2026) but every screen call has to be fast — payment auth has a
//! ~100ms budget end-to-end. We sit a tiny bloom filter in front of
//! an inverted index of normalised name tokens; the bloom rules out
//! 99% of misses without touching the hash map.
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::lists::SanctionedEntity;
use crate::normalize::{NormalizedName, normalize};
/// Stable reference into [`SanctionsIndex::by_id`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EntityRef(pub String);
/// Tiny in-memory bloom filter.
///
/// We don't need a serious bloom filter — 30k entries, dozens of
/// tokens each. A 64-bit bitmap with two hash functions gives us a
/// false-positive rate around 1% at this fill level, which is the
/// design point: the bloom is only a coarse pre-filter, false
/// positives just cost a hash-map miss.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BloomFilter {
/// 4096-bit bitmap, 64 `u64`s.
bits: Vec<u64>,
}
impl Default for BloomFilter {
fn default() -> Self {
Self::new()
}
}
impl BloomFilter {
const BITS: usize = 4096;
const WORDS: usize = Self::BITS / 64;
/// Empty filter.
#[must_use]
pub fn new() -> Self {
Self { bits: vec![0u64; Self::WORDS] }
}
/// Insert a token's two hash positions.
pub fn insert(&mut self, token: &str) {
let (h1, h2) = Self::hashes(token);
let (w1, b1) = (h1 / 64, h1 % 64);
let (w2, b2) = (h2 / 64, h2 % 64);
self.bits[w1] |= 1u64 << b1;
self.bits[w2] |= 1u64 << b2;
}
/// Membership test (with false-positive rate).
#[must_use]
pub fn might_contain(&self, token: &str) -> bool {
let (h1, h2) = Self::hashes(token);
let (w1, b1) = (h1 / 64, h1 % 64);
let (w2, b2) = (h2 / 64, h2 % 64);
(self.bits[w1] & (1u64 << b1)) != 0 && (self.bits[w2] & (1u64 << b2)) != 0
}
fn hashes(token: &str) -> (usize, usize) {
// BLAKE3 keyed twice. Plenty fast for our scale and gives us
// two independent uniform positions without rolling our own
// hash family.
let h1 = blake3::keyed_hash(&[1u8; 32], token.as_bytes());
let h2 = blake3::keyed_hash(&[2u8; 32], token.as_bytes());
let h1_bytes = h1.as_bytes();
let h2_bytes = h2.as_bytes();
let p1 = u64::from_le_bytes([
h1_bytes[0], h1_bytes[1], h1_bytes[2], h1_bytes[3],
h1_bytes[4], h1_bytes[5], h1_bytes[6], h1_bytes[7],
]) as usize;
let p2 = u64::from_le_bytes([
h2_bytes[0], h2_bytes[1], h2_bytes[2], h2_bytes[3],
h2_bytes[4], h2_bytes[5], h2_bytes[6], h2_bytes[7],
]) as usize;
(p1 % Self::BITS, p2 % Self::BITS)
}
}
/// Combined bloom + inverted-index over a population of
/// [`SanctionedEntity`]s.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SanctionsIndex {
/// Bloom over every normalised token in the corpus.
pub bloom: BloomFilter,
/// Inverted index: normalised token -> entity refs that contain it.
pub by_name: HashMap<NormalizedName, Vec<EntityRef>>,
/// Primary store: entity id -> full record.
pub by_id: HashMap<String, SanctionedEntity>,
}
impl SanctionsIndex {
/// Build a fresh index from a list of entities.
#[must_use]
pub fn build(entities: Vec<SanctionedEntity>) -> Self {
let mut idx = Self::default();
for ent in entities {
idx.insert(ent);
}
idx
}
/// Insert a single entity, updating bloom and inverted index.
pub fn insert(&mut self, entity: SanctionedEntity) {
let id = entity.id.clone();
// Index every normalised token of every name + alias.
let mut names: Vec<String> = vec![entity.name.clone()];
names.extend(entity.name_aliases.iter().cloned());
for name in &names {
let norm = normalize(name);
for token in norm.as_str().split_whitespace() {
self.bloom.insert(token);
self.by_name
.entry(NormalizedName(token.to_string()))
.or_default()
.push(EntityRef(id.clone()));
}
}
self.by_id.insert(id, entity);
}
/// Number of indexed entities.
#[must_use]
pub fn len(&self) -> usize {
self.by_id.len()
}
/// Whether the index is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
/// All entity IDs that share at least one normalised token with
/// `normalized_query`. The bloom filter narrows the per-token
/// lookups; the inverted index resolves to entity IDs.
#[must_use]
pub fn candidate_ids(&self, normalized_query: &str) -> HashSet<String> {
let mut out: HashSet<String> = HashSet::new();
for token in normalized_query.split_whitespace() {
if !self.bloom.might_contain(token) {
continue;
}
if let Some(refs) = self.by_name.get(&NormalizedName(token.to_string())) {
for r in refs {
out.insert(r.0.clone());
}
}
}
out
}
/// Serialise the index to a CBOR file.
pub fn save_snapshot(&self, path: &Path) -> Result<()> {
let file = File::create(path)?;
let writer = BufWriter::new(file);
ciborium::into_writer(self, writer).map_err(|e| Error::Cbor(e.to_string()))?;
Ok(())
}
/// Read an index back from a CBOR file written by [`save_snapshot`].
pub fn load_snapshot(path: &Path) -> Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let idx: Self =
ciborium::from_reader(reader).map_err(|e| Error::Cbor(e.to_string()))?;
Ok(idx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lists::{EntityType, SanctionsList};
use chrono::Utc;
fn fake(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: SanctionsList::OfacSdn,
}
}
#[test]
fn build_and_lookup() {
let idx = SanctionsIndex::build(vec![
fake("1", "John Smith"),
fake("2", "Maria Garcia"),
]);
assert_eq!(idx.len(), 2);
let cands = idx.candidate_ids("john smith");
assert!(cands.contains("1"));
assert!(!cands.contains("2"));
}
#[test]
fn bloom_membership_works() {
let mut bf = BloomFilter::new();
bf.insert("alpha");
bf.insert("beta");
assert!(bf.might_contain("alpha"));
assert!(bf.might_contain("beta"));
// Highly likely not-contained — over 4096 bits, single token,
// this isn't a false-positive in practice.
assert!(!bf.might_contain("gamma_unlikely_token_xyz"));
}
#[test]
fn snapshot_roundtrip() {
let idx = SanctionsIndex::build(vec![fake("1", "Test Name")]);
let dir = tempdir();
let path = dir.join("snap.cbor");
idx.save_snapshot(&path).expect("save");
let loaded = SanctionsIndex::load_snapshot(&path).expect("load");
assert_eq!(loaded.len(), 1);
assert!(loaded.by_id.contains_key("1"));
}
fn tempdir() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let uniq = format!(
"op-screening-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
p.push(uniq);
std::fs::create_dir_all(&p).expect("mkdir");
p
}
}