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).
192 lines
6.3 KiB
Rust
192 lines
6.3 KiB
Rust
//! Name canonicalisation.
|
||
//!
|
||
//! Sanctions names arrive in every variety of orthography the world
|
||
//! has invented. To get a hit rate above the floor you have to fold
|
||
//! out diacritics, lowercase, normalise whitespace, expand business
|
||
//! suffix abbreviations, and transliterate non-Latin scripts on a
|
||
//! best-effort basis. This module is the place that happens.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use unicode_normalization::UnicodeNormalization;
|
||
|
||
/// A canonicalised name, suitable for index lookup.
|
||
///
|
||
/// Once you have one, two `NormalizedName`s compare equal iff they
|
||
/// hashed to the same bucket; the underlying string is stripped of
|
||
/// case, accents, punctuation, and known suffix variants.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub struct NormalizedName(pub String);
|
||
|
||
impl NormalizedName {
|
||
/// View as a `&str`.
|
||
#[must_use]
|
||
pub fn as_str(&self) -> &str {
|
||
&self.0
|
||
}
|
||
}
|
||
|
||
/// Canonicalise `input` into a [`NormalizedName`].
|
||
///
|
||
/// Pipeline:
|
||
///
|
||
/// 1. Unicode NFKD decomposition.
|
||
/// 2. Strip combining marks (diacritics).
|
||
/// 3. Lowercase.
|
||
/// 4. Replace non-alphanumeric runs with a single space.
|
||
/// 5. Expand common business-suffix abbreviations
|
||
/// (`Co` → `Company`, `Inc` → `Incorporated`, etc.).
|
||
/// 6. Best-effort transliteration of Cyrillic and a handful of
|
||
/// common Arabic / Chinese pinyin tokens.
|
||
#[must_use]
|
||
pub fn normalize(input: &str) -> NormalizedName {
|
||
// 1 + 2: NFKD then drop combining marks.
|
||
let decomposed: String = input
|
||
.nfkd()
|
||
.filter(|c| !is_combining_mark(*c))
|
||
.collect();
|
||
|
||
// 3 + 4: lowercase and squash punctuation runs into single spaces.
|
||
let mut squashed = String::with_capacity(decomposed.len());
|
||
let mut last_was_space = true;
|
||
for c in decomposed.chars() {
|
||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||
if lower.is_alphanumeric() {
|
||
squashed.push(transliterate(lower));
|
||
last_was_space = false;
|
||
} else if !last_was_space {
|
||
squashed.push(' ');
|
||
last_was_space = true;
|
||
}
|
||
}
|
||
let trimmed = squashed.trim();
|
||
|
||
// 5: token-by-token suffix expansion.
|
||
let expanded: Vec<String> = trimmed
|
||
.split_whitespace()
|
||
.map(|tok| expand_abbreviation(tok).to_string())
|
||
.collect();
|
||
|
||
NormalizedName(expanded.join(" "))
|
||
}
|
||
|
||
/// Combining mark detection — every codepoint in the Unicode
|
||
/// `Mn` (mark, nonspacing) or `Mc` (mark, spacing combining)
|
||
/// general category, which is everything `NFKD` peels off a
|
||
/// precomposed letter-with-accent.
|
||
fn is_combining_mark(c: char) -> bool {
|
||
// Cheap range check: covers the bulk of Latin / Greek / Cyrillic.
|
||
matches!(c as u32,
|
||
0x0300..=0x036F // Combining Diacritical Marks
|
||
| 0x1AB0..=0x1AFF // Combining Diacritical Marks Extended
|
||
| 0x1DC0..=0x1DFF // Combining Diacritical Marks Supplement
|
||
| 0x20D0..=0x20FF // Combining Diacritical Marks for Symbols
|
||
| 0xFE20..=0xFE2F // Combining Half Marks
|
||
)
|
||
}
|
||
|
||
/// Per-codepoint best-effort Latinisation.
|
||
///
|
||
/// We only handle the ones that ship in real sanctions data — Cyrillic
|
||
/// for Russia/Ukraine/Belarus designations, the common Arabic letters
|
||
/// for Iran/Syria/Yemen. Chinese names are already Pinyin-romanised
|
||
/// by OFAC / EU lists at source; we touch only basic CJK fall-throughs.
|
||
fn transliterate(c: char) -> char {
|
||
match c {
|
||
// --- Cyrillic small letters (single-char mappings only).
|
||
'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd',
|
||
'е' => 'e', 'ё' => 'e', 'з' => 'z', 'и' => 'i', 'й' => 'i',
|
||
'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', 'о' => 'o',
|
||
'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u',
|
||
'ф' => 'f', 'х' => 'h', 'ы' => 'y', 'э' => 'e',
|
||
|
||
// --- Arabic letters that map 1:1 to a Latin letter (very rough).
|
||
'ا' => 'a', 'ب' => 'b', 'ت' => 't', 'د' => 'd', 'ر' => 'r',
|
||
'ز' => 'z', 'س' => 's', 'ف' => 'f', 'ك' => 'k', 'ل' => 'l',
|
||
'م' => 'm', 'ن' => 'n', 'ه' => 'h', 'ي' => 'y',
|
||
|
||
_ => c,
|
||
}
|
||
}
|
||
|
||
/// Common business-suffix expansions. Casing is already lowered.
|
||
fn expand_abbreviation(tok: &str) -> &str {
|
||
match tok {
|
||
"co" => "company",
|
||
"corp" => "corporation",
|
||
"inc" => "incorporated",
|
||
"incorp" => "incorporated",
|
||
"ltd" => "limited",
|
||
"llc" => "limited liability company",
|
||
"llp" => "limited liability partnership",
|
||
"lp" => "limited partnership",
|
||
"plc" => "public limited company",
|
||
"gmbh" => "gesellschaft",
|
||
"ag" => "aktiengesellschaft",
|
||
"sa" => "societe anonyme",
|
||
"srl" => "societa a responsabilita limitata",
|
||
"bv" => "besloten vennootschap",
|
||
"ab" => "aktiebolag",
|
||
"as" => "aksjeselskap",
|
||
"oy" => "osakeyhtio",
|
||
_ => tok,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn diacritic_strip() {
|
||
assert_eq!(normalize("Müller").as_str(), "muller");
|
||
}
|
||
|
||
#[test]
|
||
fn apostrophes_become_space_then_collapse() {
|
||
// "O'Brien" → punctuation drops, "o" + "brien" left.
|
||
// The squash keeps the space, so we end up with "o brien".
|
||
// That matches the token-bag the matcher operates on.
|
||
assert_eq!(normalize("O'Brien").as_str(), "o brien");
|
||
}
|
||
|
||
#[test]
|
||
fn sao_paulo() {
|
||
assert_eq!(normalize("São Paulo").as_str(), "sao paulo");
|
||
}
|
||
|
||
#[test]
|
||
fn suffix_expansion() {
|
||
assert_eq!(
|
||
normalize("Acme Corp").as_str(),
|
||
"acme corporation"
|
||
);
|
||
assert_eq!(
|
||
normalize("ACME Inc").as_str(),
|
||
"acme incorporated"
|
||
);
|
||
assert_eq!(
|
||
normalize("Test Ltd").as_str(),
|
||
"test limited"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn whitespace_collapse() {
|
||
assert_eq!(
|
||
normalize(" hello \t world ").as_str(),
|
||
"hello world"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cyrillic_transliteration() {
|
||
// "Иван" — vanilla Cyrillic — should become "ivan".
|
||
assert_eq!(normalize("Иван").as_str(), "ivan");
|
||
}
|
||
|
||
#[test]
|
||
fn empty_input_is_empty_output() {
|
||
assert_eq!(normalize("").as_str(), "");
|
||
assert_eq!(normalize(" ").as_str(), "");
|
||
}
|
||
}
|