Files
aml/pkg/engine/scorer_test.go
zeekayandHanzo Dev 3c041ed331 merge the detection library and the screening rewrite into one engine
Two branches had independently rebuilt the same two packages, which is one
implementation too many. This resolves them into one.

Their side is the base for detection and screening, because it is better:

  - Rules name evidence, and SetRules refuses a set whose evidence this
    deployment cannot supply — so the eleven stub helpers that silently disabled
    thirteen rules cannot exist. A rule that cannot fire is worse than an absent
    one, because it still appears in the catalog a regulator reads.
  - One screening store, filled by the refresh and read by the endpoints. Two of
    them is why sanctions search answered "no match" for every name while
    designations were being loaded somewhere else.
  - Parsers for all four publishers against their real files, recovering what the
    single-parser path could not read at all: OFAC's digital-currency addresses,
    weak aliases held apart from screening candidates, and OFSI's row-per-name
    layout collapsed to subjects.
  - Name matching over runes, not bytes. Two distinct Han names scored 0.867
    byte-wise, above the 0.85 threshold, and 0.700 correctly.
  - Deviation from the median and MAD rather than the mean, because a monitoring
    baseline contains the outliers it is used to hunt.
  - A rule vocabulary with providers behind every term, in place of expressions
    reaching into a struct.

Kept from this side, because their branch did not have it:

  - The tenant comes from an authenticated identity, not from a request header.
  - A rule that could not run carries no weight. Their action() already capped a
    failing rule at review, which is right, but the hit still entered the score
    at full weight — so a broken rule saturated every transaction's score and the
    queue stopped ranking. Faults are now excluded from the aggregate while still
    raising their own alert and still forcing review.
  - Per-source readiness: which list, how many designations, how long ago, and
    the reason if the last attempt failed. Their store answers whether screening
    can be relied on at all; this answers what is behind it, which is what an
    operator can act on.
  - Behavioural scoring, retention, tokenisation, replay and reference data.

Conversion now refuses an unknown currency instead of passing the amount
through. Passing it through understates every currency worth more than a dollar,
so a payment three times over a reporting limit tested as sitting under it.

One ordering table for actions (types.ActionRank), one EvalAll signature, one
screening store, one readiness surface. 15 packages, tests and race clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:49:08 -07:00

252 lines
9.4 KiB
Go

package engine
import (
"context"
"math"
"testing"
"time"
"github.com/luxfi/aml/pkg/types"
)
// scorer is a Scorer that returns exactly what a test tells it to, including the
// things an honest one never would.
type scorer struct {
hit types.RuleHit
ok bool
panic bool
}
func (s scorer) Assess(types.Transaction, types.Entity) (types.RuleHit, bool) {
if s.panic {
panic("scorer fault")
}
return s.hit, s.ok
}
func evidence(weight float64, action string) types.RuleHit {
return types.RuleHit{
Rule: types.Rule{
ID: "anomaly", Name: "Unusual for this customer",
Severity: types.SeverityMedium, Weight: weight, Action: action, Enabled: true,
},
Match: true,
}
}
func flagRule() types.Rule {
return types.Rule{
ID: "r-flag", Name: "flag", DSL: `Tx.Notional > 100.0`,
Severity: types.SeverityMedium, Weight: 0.3, Action: types.ActionFlag, Enabled: true,
}
}
var sample = types.Transaction{ID: "t1", OrgID: "acme", UserID: "u1", Notional: 500}
// A fault in the statistical plane must not take the rule plane with it. Every
// rule has already been evaluated by the time the Scorer runs, and losing that
// verdict to a panic would turn a degraded control into no control.
func TestPanickingScorerDoesNotLoseTheRulesVerdict(t *testing.T) {
e := withRules(t, flagRule())
e.SetScorer(scorer{panic: true})
alerts, score, action := e.Evaluate(context.Background(), sample, types.Entity{})
if action != types.ActionFlag {
t.Fatalf("action = %q, want the rule's %q", action, types.ActionFlag)
}
if len(alerts) != 1 || score <= 0 {
t.Fatalf("rule verdict lost: %d alerts, score %v", len(alerts), score)
}
// The fault must be countable. A Scorer that faults on every transaction
// contributes nothing while the engine keeps answering, and this counter is
// the only difference between that and one that finds nothing to report.
if e.ScorerFaults() != 1 {
t.Fatalf("faults = %d, want 1", e.ScorerFaults())
}
}
// Weight-of-evidence is a sum, so a negative weight would let a model argue a
// transaction down below what the rules found. Rules are already forbidden
// negative weights; a computed weight is held to the same rule, and so are the
// values only a computation can produce.
func TestScorerCannotWeakenAVerdict(t *testing.T) {
base := withRules(t, flagRule())
_, want, wantAction := base.Evaluate(context.Background(), sample, types.Entity{})
for name, weight := range map[string]float64{
"negative": -5,
"nan": math.NaN(),
"+infinity": math.Inf(1),
"-infinity": math.Inf(-1),
} {
e := withRules(t, flagRule())
e.SetScorer(scorer{hit: evidence(weight, types.ActionReview), ok: true})
alerts, got, action := e.Evaluate(context.Background(), sample, types.Entity{})
if got < want {
t.Errorf("%s weight lowered the score from %v to %v", name, want, got)
}
if math.IsNaN(got) {
t.Errorf("%s weight produced a score that is not a number", name)
}
if types.ActionRank(action) < types.ActionRank(wantAction) {
t.Errorf("%s weight weakened the action from %q to %q", name, wantAction, action)
}
if len(alerts) != 1 {
t.Errorf("%s weight: %d alerts, want the rule's 1", name, len(alerts))
}
if e.ScorerFaults() != 1 {
t.Errorf("%s weight was not counted as a fault", name)
}
}
}
// The model summons a person; it does not act. Whatever a Scorer asks for, the
// strongest thing it can obtain is a review — enforced on the Scorer's output
// rather than trusted to the Scorer, because the property has to hold for one
// that is wrong as well as one that is honest.
func TestScorerActionIsCappedAtTheCeiling(t *testing.T) {
for _, asked := range []string{types.ActionBlock, types.ActionReport} {
e := withRules(t)
e.SetScorer(scorer{hit: evidence(0.9, asked), ok: true})
alerts, _, action := e.Evaluate(context.Background(), sample, types.Entity{})
if action != types.ActionCeiling {
t.Errorf("scorer asked for %q and got %q, ceiling is %q", asked, action, types.ActionCeiling)
}
if len(alerts) != 1 || alerts[0].ActionTaken != types.ActionCeiling {
t.Errorf("scorer asked for %q and the alert carries %q", asked, alerts[0].ActionTaken)
}
}
// At or below the ceiling it gets what it asked for.
for _, asked := range []string{types.ActionFlag, types.ActionReview} {
e := withRules(t)
e.SetScorer(scorer{hit: evidence(0.9, asked), ok: true})
if _, _, action := e.Evaluate(context.Background(), sample, types.Entity{}); action != asked {
t.Errorf("scorer asked for %q and got %q", asked, action)
}
}
}
// The statistical plane may raise a transaction no rule matched — that is the
// point of having it — and may never lower one they did.
func TestScorerOnlyAdds(t *testing.T) {
rules := []types.Rule{flagRule()}
plain := withRules(t, rules...)
_, want, wantAction := plain.Evaluate(context.Background(), sample, types.Entity{})
e := withRules(t, rules...)
e.SetScorer(scorer{hit: evidence(0.2, types.ActionReview), ok: true})
alerts, got, action := e.Evaluate(context.Background(), sample, types.Entity{})
if got <= want {
t.Fatalf("evidence did not add: %v -> %v", want, got)
}
if types.ActionRank(action) < types.ActionRank(wantAction) {
t.Fatalf("evidence weakened the action: %q -> %q", wantAction, action)
}
if len(alerts) != 2 {
t.Fatalf("%d alerts, want the rule's and the model's", len(alerts))
}
// With no rules at all the model can still raise a transaction.
only := withRules(t)
only.SetScorer(scorer{hit: evidence(0.2, types.ActionReview), ok: true})
if _, score, action := only.Evaluate(context.Background(), sample, types.Entity{}); score <= 0 || action != types.ActionReview {
t.Fatalf("model alone produced score %v action %q", score, action)
}
}
// Declining to score contributes nothing at all — not an alert with no weight,
// which downstream would read as a verdict of normal.
func TestScorerDecliningContributesNothing(t *testing.T) {
e := withRules(t)
e.SetScorer(scorer{hit: evidence(0.9, types.ActionReview), ok: false})
alerts, score, action := e.Evaluate(context.Background(), sample, types.Entity{})
if len(alerts) != 0 || score != 0 || action != types.ActionAllow {
t.Fatalf("a declining scorer produced %d alerts, score %v, action %q", len(alerts), score, action)
}
if e.ScorerFaults() != 0 {
t.Fatal("declining to score was counted as a fault")
}
}
// A model's alert is a number until it carries its reasons, so the attribution has
// to survive the trip into the alert an investigator opens.
func TestCausesReachTheAlert(t *testing.T) {
hit := evidence(0.2, types.ActionReview)
hit.Causes = []types.Cause{{
Feature: "subthreshold", Typology: "structuring",
Indicator: "transactions split to circumvent reporting limits",
Citation: "EBA/GL/2021/02, Guideline 4.60(a)", Severity: types.SeverityHigh,
Unit: "share of transactions falling just below the reporting threshold",
Observed: 11, Baseline: 11, Without: 0.31, Share: 0.62,
}}
e := withRules(t)
e.SetScorer(scorer{hit: hit, ok: true})
alerts, _, _ := e.Evaluate(context.Background(), sample, types.Entity{})
if len(alerts) != 1 {
t.Fatalf("%d alerts", len(alerts))
}
got := alerts[0].Causes
if len(got) != 1 {
t.Fatalf("alert carries %d causes", len(got))
}
if got[0].Feature != "subthreshold" || got[0].Citation == "" || got[0].Typology == "" {
t.Fatalf("attribution arrived incomplete: %+v", got[0])
}
// A rule alert explains itself and must not acquire causes it never had.
rules := withRules(t, flagRule())
ruleAlerts, _, _ := rules.Evaluate(context.Background(), sample, types.Entity{})
if len(ruleAlerts[0].Causes) != 0 {
t.Fatal("a rule alert carries model attribution")
}
}
// Once a weight is computed rather than read from a constant, arithmetic can
// produce a value that is not a number, and NaN compares false against every
// bound — so the obvious two-comparison clamp passes it straight through, where it
// becomes a transaction's risk score and fails to marshal into the response.
func TestScoreClampHandlesNotANumber(t *testing.T) {
if got := clamp01(math.NaN()); math.IsNaN(got) {
t.Fatal("clamp01 passed NaN through as a risk score")
}
if got := clamp01(math.NaN()); got != 1 {
t.Fatalf("clamp01(NaN) = %v, want the top of the range so a broken score reaches a person", got)
}
if clamp01(math.Inf(1)) != 1 || clamp01(math.Inf(-1)) != 0 {
t.Fatal("clamp01 does not bound the infinities")
}
if clamp01(0.4) != 0.4 {
t.Fatal("clamp01 altered a value already in range")
}
}
// No Scorer at all must leave the engine exactly as it was.
func TestNoScorerChangesNothing(t *testing.T) {
rules := []types.Rule{flagRule()}
a := withRules(t, rules...)
wantAlerts, want, wantAction := a.Evaluate(context.Background(), sample, types.Entity{})
b := withRules(t, rules...)
b.SetScorer(nil)
gotAlerts, got, gotAction := b.Evaluate(context.Background(), sample, types.Entity{})
if got != want || gotAction != wantAction || len(gotAlerts) != len(wantAlerts) {
t.Fatalf("installing a nil scorer changed the verdict: %v/%q vs %v/%q", got, gotAction, want, wantAction)
}
if b.ScorerFaults() != 0 {
t.Fatal("a nil scorer produced faults")
}
}
// withRules builds an engine over providers a scorer test does not need, and
// installs the rules through SetRules so the test goes through the same admission
// the daemon does.
func withRules(t *testing.T, rules ...types.Rule) *Engine {
t.Helper()
e := New(Providers{Zone: time.UTC})
if err := e.SetRules(rules); err != nil {
t.Fatalf("SetRules: %v", err)
}
return e
}