///|
/// A deterministic, explainable AML rules engine.
pub fn evaluate(
rules : Array[Rule],
transactions : Array[Transaction],
) -> Array[Alert] {
let alerts = []
for transaction in transactions {
for rule in rules {
match evaluate_rule(rule, transaction, transactions) {
Some(alert) => alerts.push(alert)
None => ()
}
}
}
alerts
}
///|
fn evaluate_rule(
rule : Rule,
transaction : Transaction,
all : Array[Transaction],
) -> Alert? {
match rule {
AmountAtLeast(id, minimum) =>
if transaction.amount >= minimum {
Some(
Alert::new(id, transaction.id, [
"amount=\{transaction.amount} >= \{minimum}",
]),
)
} else {
None
}
AmountAtMost(id, maximum) =>
if transaction.amount <= maximum {
Some(
Alert::new(id, transaction.id, [
"amount=\{transaction.amount} <= \{maximum}",
]),
)
} else {
None
}
CustomerFrequency(id, minimum, window) => {
let count = count_customer_in_window(transaction, all, window)
if count >= minimum {
Some(
Alert::new(id, transaction.id, [
"customer=\{transaction.customer_id} count=\{count} window=\{window}s",
]),
)
} else {
None
}
}
AccountFrequency(id, minimum, window) => {
let count = count_account_in_window(transaction, all, window)
if count >= minimum {
Some(
Alert::new(id, transaction.id, [
"account=\{transaction.account_id} count=\{count} window=\{window}s",
]),
)
} else {
None
}
}
CustomerVolume(id, minimum, window) => {
let total = customer_volume_in_window(transaction, all, window)
if total >= minimum {
Some(
Alert::new(id, transaction.id, [
"customer=\{transaction.customer_id} volume=\{total} window=\{window}s",
]),
)
} else {
None
}
}
RegionIs(id, region) =>
if transaction.region == region {
Some(Alert::new(id, transaction.id, ["region=\{region}"]))
} else {
None
}
CurrencyIs(id, currency) =>
if transaction.currency == currency {
Some(Alert::new(id, transaction.id, ["currency=\{currency}"]))
} else {
None
}
RapidMovement(id, amount, window) => {
let total = customer_volume_in_window(transaction, all, window)
if total >= amount {
Some(
Alert::new(id, transaction.id, [
"rapid_volume=\{total} window=\{window}s",
]),
)
} else {
None
}
}
Structuring(id, unit, minimum, window) => {
let mut count = 0
for candidate in all {
let delta = transaction.occurred_at - candidate.occurred_at
if candidate.customer_id == transaction.customer_id &&
candidate.amount >= unit / 2 &&
candidate.amount < unit &&
delta >= 0 &&
delta <= window {
count += 1
}
}
if count >= minimum {
Some(Alert::new(id, transaction.id, ["structuring_count=\{count}"]))
} else {
None
}
}
}
}
///|
pub fn count_customer_in_window(
tx : Transaction,
all : Array[Transaction],
window : Int,
) -> Int {
let mut count = 0
for candidate in all {
let delta = tx.occurred_at - candidate.occurred_at
if candidate.customer_id == tx.customer_id && delta >= 0 && delta <= window {
count += 1
}
}
count
}
///|
pub fn count_account_in_window(
tx : Transaction,
all : Array[Transaction],
window : Int,
) -> Int {
let mut count = 0
for candidate in all {
let delta = tx.occurred_at - candidate.occurred_at
if candidate.account_id == tx.account_id && delta >= 0 && delta <= window {
count += 1
}
}
count
}
///|
pub fn customer_volume_in_window(
tx : Transaction,
all : Array[Transaction],
window : Int,
) -> Int {
let mut total = 0
for candidate in all {
let delta = tx.occurred_at - candidate.occurred_at
if candidate.customer_id == tx.customer_id && delta >= 0 && delta <= window {
total += candidate.amount
}
}
total
}
///|
pub fn evaluate_validated(
rules : Array[Rule],
transactions : Array[Transaction],
) -> (Array[Alert], Array[ValidationIssue]) {
let normalized = []
for tx in transactions {
normalized.push(normalize_transaction(tx))
}
(evaluate(rules, normalized), validate_batch(normalized))
}
///|
pub fn deduplicate_alerts(alerts : Array[Alert]) -> Array[Alert] {
let result : Array[Alert] = []
for alert in alerts {
let mut duplicate = false
for old in result {
if old.rule_id == alert.rule_id &&
old.transaction_id == alert.transaction_id {
duplicate = true
}
}
if !duplicate {
result.push(alert)
}
}
result
}