Aller au contenu

POC Sanctions matcher — re-ranker Kotlin déterministe + RCA + audit signé

POC : poc-sanctions-matcher/ (~900 lignes Kotlin pur, 0 dépendance externe runtime). Référence pour sanctions-svc MVP.

Status : 21/21 tests passants. Démo CLI 8 cas (matches directs, PEP, RCA 1-hop, RCA 2-hop, faux positifs filtrés).

Ce POC démontre les invariants critiques du module Sanctions Screening en code exécutable :

  1. Algorithmes déterministes — Jaro-Winkler, Levenshtein, Soundex, Beider-Morse approx, ICU-style Arabic transliterator
  2. Re-ranker pondéré — score consolidé reproductible bit-à-bit (cœur audit BCT)
  3. Backend abstrait — interface SanctionsIndex (OpenSearch en prod, in-memory en POC/tests)
  4. RCA scoring — flattening BFS pré-calculé ≤ 2 sauts + propagation de score avec décroissance par profondeur
  5. Audit log chaîné — chaînage SHA-256 + verifyChain() détectant tout tampering

poc-sanctions-matcher/
├── build.gradle.kts // Kotlin 2.0, JUnit 5, AssertJ
├── src/main/kotlin/io/vitakyc/sanctions/
│ ├── Model.kt // NormalizedEntity, ScreeningQuery, RcaPath, etc.
│ ├── Algorithms.kt // JaroWinkler, Levenshtein, Soundex, BeiderMorseApprox, ArabicTransliterator
│ ├── Reranker.kt // re-ranker pondéré déterministe
│ ├── Index.kt // SanctionsIndex interface + InMemoryIndex
│ ├── RcaScorer.kt // BFS flattener ≤ 2 sauts + propagation décroissance
│ ├── AuditLog.kt // append-only chaînage SHA-256
│ ├── Screener.kt // orchestrateur end-to-end
│ ├── Fixtures.kt // dataset synthétique (OFAC-like + PEP + RCA chains)
│ └── Main.kt // démo CLI 8 cas
└── src/test/kotlin/io/vitakyc/sanctions/
└── SanctionsTest.kt // 21 tests

TestVérifie
Jaro-Winkler — identical strings score 1invariant trivial mais critique
Jaro-Winkler — known reference pair MARTHA MARHTAaligné sur la référence canonique de la littérature
Levenshtein — known distanceskitten/sitting = 3, ''/abc = 3
Soundex — encodes Robert vs Rupert identically”R163” — encodage standard
Beider-Morse approx — Mohamed and Mohammed matchdoublets mm collapsés
Arabic transliterator — generates plausible variantsmohammed → contient muhammed, mohamad
TestVérifie
Reranker — exact OFAC match scores highfinalScore > 0.92 (au-dessus du threshold direct sanctions)
Reranker — alias Mohamed vs Mohammad still scores welltypo + alias pris en compte
Reranker — completely different name scores lowfinalScore < 0.5
Reranker — DOB mismatch costs 20 percentimpact mesurable de DOB sur le score consolidé
Reranker — deterministic same input same outputreproductibilité bit-à-bit (audit BCT)
RerankerWeights — sum must equal 1invariant DSL enforced à la construction
TestVérifie
Screener — exact direct sanctions match → MATCH_DIRECT_SANCTIONStypologie correcte
Screener — PEP matchtypologie PEP avec threshold 0.85
Screener — RCA 1-hop (brother of OFAC)candidate RCA avec flattenedAssociatedSanctioned
Screener — Arabic name only via transliteration unfoldmatching cross-script (alias arabe + DOB)
Screener — phonetic + alias variant typo still matches with DOBrobustesse aux typos
Screener — clean profile → CLEARpas de faux positif
TestVérifie
RcaScorer — BFS flattens 2-hop sanctioned targetdénormalisation pré-calculée + decay correct
Audit log — every screening creates a chained eventappend-only enforcé
Audit log — chain integrity verified after multiple eventsverifyChain détecte un tampering

=== VitaKYC POC Sanctions Matcher — démo ===
─── Cas : Ali Hassan Al Makki (DOB=1968-03-15, NAT=SY)
attendu : exact name + DOB + nationality → MATCH_DIRECT_SANCTIONS
décision : MATCH_DIRECT_SANCTIONS
[1] Ali Hassan AL-MAKKI score=1,00 (name=1,00, phon=1, dob=1,0, alias=1,00) → MATCH_DIRECT_SANCTIONS
─── Cas : Mehdi Ben Ali (DOB=1965-04-12, NAT=TN)
attendu : PEP exact match → MATCH_PEP
décision : MATCH_PEP
[1] Mehdi BEN ALI score=0,92 (name=1,00, phon=1, dob=1,0, alias=1,00) → MATCH_PEP
─── Cas : Sami Al Makki (DOB=1972-08-20, NAT=SY)
attendu : RCA brother of OFAC entity → MATCH_RCA_DIRECT (1-hop)
décision : MATCH_RCA_DIRECT
[1] Sami AL-MAKKI score=0,85 (name=1,00, phon=1, dob=1,0, alias=1,00) → MATCH_RCA_DIRECT
─── Cas : Ahmed Ben Salem (DOB=1980-06-15, NAT=TN)
attendu : 2-hop RCA via shell company → MATCH_RCA_INDIRECT
décision : MATCH_RCA_INDIRECT
[1] Ahmed BEN SALEM score=1,00 → MATCH_RCA_INDIRECT
─── Cas : Sami Said (DOB=1985-12-01, NAT=TN)
attendu : clean profile → CLEAR
décision : CLEAR
─── Cas : علي حسن المكي (DOB=null, NAT=null)
décision : CLEAR
(sans DOB+nat le score plafonne sous 0,92 — re-ranker conservateur, comportement attendu)
─── Audit log integrity ───
chain integrity : ✅ OK
events archivés : 8

Note importante : les cas avec nom seul (Arabic-only ou typo sans DOB+nationality) retournent CLEAR — c’est le comportement conservateur correct du re-ranker. Sans DOB ni nationalité, le score consolidé plafonne autour de 0,55-0,60 (< threshold OFAC 0,92), ce qui évite les faux positifs massifs en production. La spec engineering explique cet arbitrage section 5.3.


  • Backend OpenSearch réel — le POC utilise InMemoryIndex qui implémente la même interface SanctionsIndex. La prod swap vers OpenSearchIndex qui appelle _search avec mappings ICU + Beider-Morse plugins.
  • Beider-Morse complet — le POC implémente une approximation 18 règles (suffisante pour les tests). La prod utilise le plugin Lucene phonetic avec encoder beider_morse.
  • ICU transliteration complet — le POC mappe ~30 caractères arabes vers latin. La prod utilise Transliterator.getInstance("Arabic-Latin") d’ICU4J (couvre toute la plage Unicode arabe + extensions).
  • Signatures Ed25519 — le POC chaîne les hashes. La prod ajoute la signature par tenant key (KMS Vault).
  • ETL pipeline — le POC charge des fixtures statiques. La prod a un service sanctions-etl-svc qui parse XML/JSON OFAC/UN/EU/OFSI/OpenSanctions, normalise FtM, dédupe, flatten RCA, bulk-index OpenSearch.
  • Snapshots versionnés — la prod snapshot OpenSearch vers MinIO à chaque reindex (10 ans WORM).
  • Adapter Dow Jones — implémenté à activation tenant (format de feed XML/JSON propriétaire, parser dédié).

// Production wiring
class OpenSearchIndex(client: RestHighLevelClient, alias: String) : SanctionsIndex {
override fun broadSearch(query: ScreeningQuery, topN: Int): List<NormalizedEntity> {
val req = SearchRequest(alias).source(SearchSourceBuilder().apply {
size(topN)
query(QueryBuilders.boolQuery()
.should(disMaxQuery()
.add(matchQuery("primaryName.icu_folded", query.name).boost(4.0f))
.add(matchQuery("primaryName.phonetic", query.name).boost(2.5f))
.add(matchQuery("primaryName.ngram", query.name).boost(1.5f))
.add(matchQuery("akas.icu_folded", query.name).boost(3.0f))
.add(matchQuery("akas.phonetic", query.name).boost(2.0f))
.tieBreaker(0.3f))
.filter(termQuery("schema", query.type.name)))
})
return client.search(req).hits.hits.map { it.toNormalizedEntity() }
}
override fun listVersion() = readListVersionAlias(alias)
override fun size() = client.indices().count(alias)
}
// Le Reranker, RcaScorer, Screener et AuditLog sont réutilisés tels quels
val screener = SanctionsScreener(
index = OpenSearchIndex(client, "sanctions_TN-BANQUEX"),
reranker = Reranker(version = "1.0.0", weights = tenantConfig.rerankerWeights),
thresholds = tenantConfig.thresholds,
audit = AuditLog(signer = vaultEd25519Signer)
)

Cible perf prod : screener.screen(...) p95 ≤ 200 ms (broad ≤ 30 ms + re-rank 50 candidats ≤ 50 ms + audit + Kafka emit).


Fenêtre de terminal
cd poc-sanctions-matcher
./gradlew test # 21/21 tests verts
./gradlew run # démo CLI 8 cas

Dépendances : Kotlin 2.0.20, JVM 17, JUnit 5.11, AssertJ 3.26. Aucune dépendance runtime externe.