Aller au contenu

POC AML Rules Engine — DSL streaming + sliding windows + alert dedup

POC : poc-aml-rules-engine/ (~1 000 lignes Kotlin pur, 0 dépendance externe runtime). Référence pour tx-monitoring-svc (Kafka Streams en prod).

Status : 24/24 tests passants. Démo CLI 6 cas (structuring, threshold, pass-through, aggregate, dedup, clean profile).

Ce POC démontre les invariants critiques du moteur de règles streaming en code exécutable, sans dépendance à Kafka Streams : on simule le comportement d’une topology en mémoire (state stores keyed par window, late event handling, alert dedup) afin de tester les règles indépendamment de l’infra.


poc-aml-rules-engine/
├── build.gradle.kts // Kotlin 2.0, JUnit 5, AssertJ
├── src/main/kotlin/io/vitakyc/aml/
│ ├── Model.kt // CanonicalTx, AmlRule, WindowSpec, Predicate, AggregateExpr, ThresholdExpr, AmlAlert
│ ├── PredicateEvaluator.kt // évaluation déterministe Predicate AST
│ ├── Windows.kt // active windows (hopping/tumbling/session), late event detection
│ ├── StateStore.kt // WindowedKeyValueStore en mémoire, dedupe txIds
│ ├── RulesEngine.kt // orchestrateur process(tx) → List<AmlAlert> + alert dedup SHA-256
│ ├── Rules.kt // 5 typologies pré-livrées MVP
│ └── Main.kt // démo CLI 6 cas
└── src/test/kotlin/io/vitakyc/aml/
└── AmlTest.kt // 24 tests

2.1 Predicate evaluation (réutilise grammaire ADR-004/025)

Section intitulée « 2.1 Predicate evaluation (réutilise grammaire ADR-004/025) »
TestVérifie
predicate eq numeric coercesint/double/BigDecimal comparés correctement
predicate gt and ltopérateurs numériques
predicate inSet on enum-like fieldmatching liste de pays high-risk
predicate AND OR composescomposition booléenne
predicate field unknown throwsFieldNotFound typé
TestVérifie
hopping windows — single point spans multiple windowshop step < size → un point couvre plusieurs windows actives
tumbling windows — exactly oneune seule window non-overlapping
late event detectiontx au-delà de windowEnd + grace → late
TestVérifie
structuring rule fires on 11 small DEBITs within window≥ 11 txs (1000 < amount < 10000) DEBIT en 7j → HIGH
structuring rule does NOT fire below threshold (10 txs)invariant strict CountGt(10)
structuring rule ignores CREDITsfilter direction enforced
high-risk country fires on 60K to RUURGENT immédiat (window 60s)
high-risk country does NOT fire on 60K to FRfilter pays
high-risk country does NOT fire below 50K thresholdfilter montant
pass-through fires on CREDIT then 95 percent DEBIT within 48hpattern detection avec ratio temporel
pass-through does NOT fire if DEBIT below 95 percentseuil ratio
aggregate wire 24h fires above 200Ksum sliding window
aggregate wire 24h does NOT fire on domestic wirefilter international
TestVérifie
multiple rules fire independently on same tx2 règles différentes → 2 alertes différentes
rule for tenant TN does not fire on tenant FRisolation cross-tenant strict
shadow mode rule emits alert flagged isShadow=trueshadow vs active différencié
archived rule does not firestatus enforced
TestVérifie
alert dedup by signature — re-processing same tx does not double emitalertId = sha256(rule|key|windowStart|sortedTxIds) déterministe
alert id is deterministic — same inputs produce same idreproductibilité audit BCT

=== VitaKYC POC AML Rules Engine — démo ===
[1] Structuring — 11 transactions DEBIT 8 500 TND sur même compte (1 par heure)
alertes émises : 158 (attendu : ≥ 1)
↳ RULE_STRUCTURING_TND severity=HIGH count=11 txs=11
[2] High-risk country — 1 tx 60 000 TND vers RU
alertes émises : 1 (attendu : 1 URGENT)
↳ RULE_HIGH_AMOUNT_HIGH_RISK_COUNTRY severity=URGENT aggregate=1.0
[3] Pass-through — CREDIT 100 000 puis DEBIT 96 000 dans 1h
alertes émises : 1 (attendu : 1 HIGH)
↳ RULE_PASS_THROUGH_48H severity=HIGH txs=[PT-IN, PT-OUT]
[4] Aggregate WIRE 24h > 200 000 — 3 tx WIRE vers FR totalisant 250 000
alertes émises : 20 (attendu : ≥ 1 HIGH)
↳ RULE_INT_WIRE_24H_200K sum=250000.0 threshold=200000.0
[5] Dedup — re-process la même tx, alertId déjà émis → pas de duplicate
alertes avant=180 après=180 (attendu : identique)
[6] Tx non-suspecte — clean profile
alertes émises : 0 (attendu : 0)
─── Résumé ───
Total alertes : 180
HIGH : 179, URGENT : 1
STRUCTURING : 158, THRESHOLD : 1, PATTERN_PASS_THROUGH : 1, AGGREGATE : 20
State store size : 371 entries

Note sur les alertes multiples : avec hopping windows (size=24h, step=1h), un pattern vrai génère autant d’alertes que de windows actives qui le couvrent (24 hops). C’est la sémantique attendue. La déduplication métier (1 incident = 1 case) est faite par case-mgmt-svc qui groupe les alertes overlapping en un seul case (cf Case Management).


Élément POCProduction Kafka Streams
RulesEngine.process(tx)KStream<String, CanonicalTx>.process(...)
WindowedStateStore (in-memory HashMap)WindowedKeyValueStore<String, Aggregate> (RocksDB local + changelog Kafka topic compacted)
Windows.activeWindowsTimeWindows.ofSizeAndGrace(...).advanceBy(...)
RulesEngine.computeAlertIdidentique (déterministe)
emittedAlerts setoutbox Postgres alert_outbox avec dedup SQL
process(tx) synchronetopology asynchrone, processing.guarantee=exactly_once_v2

Le re-ranker / threshold / agrégation sont identiques bit-à-bit entre POC et prod. Seule la couche persistence + streaming change.


  • Kafka Streams runtime réel — topology, partitionnement, rebalancing
  • RocksDB persistence + changelog compacted topic pour resilience
  • Outbox pattern Postgres + sweeper goroutine vers Kafka
  • Signature Ed25519 des alertes par tenant key (Vault HSM)
  • Backtest runner sur 6 mois historique avec rapport precision/recall
  • Dual-control publish d’une nouvelle règle (2 sig Ed25519)
  • Audit trail append-only des changements de règles (aml_rule_audit)
  • Métriques Prometheus (throughput, latency par phase, dedup rate, late event drop rate)
  • Late event handling avancé (cf Watermarks)
  • Custom typologies au-delà des 5 MVP (NEW_BENEFICIARY, peer-deviation avec join CPS)

Fenêtre de terminal
cd poc-aml-rules-engine
./gradlew test # 24/24 tests verts
./gradlew run # démo CLI 6 cas

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