Files
aml/pkg/engine/scoring.go
T
zeekayandHanzo Dev c1372cb0cc aml: anomaly scoring, retention, tokenisation, replay, reference data
Five gaps that a monitoring product cannot be sold without.

anomaly scores whether a transaction is unusual for the entity that made it,
which is the statutory question — consistency with the customer's own profile —
and one no fixed threshold can express. It complements the rule library rather
than replacing it: rules encode typologies someone has already named, and are
blind to anything nobody has written down. The design is deliberately
attributable: a score can name the features that drove it, which is what a
supervisor asks for and what a neural scorer cannot answer.

retention keeps records for their statutory period and destroys them when it
runs out, including the retained assessment whether or not it produced a report.
token protects direct identifiers at rest while keeping the engine able to
correlate: one key schedule, a deterministic org-scoped pseudonym to index on
and a sealed record for reads that are entitled to it, so a record can be found
by party without holding the party's name in the clear.

replay runs a rule over real history and reports what it would have done before
anyone activates it — the same question as "how many alerts would this raise",
which is what makes a rule change reviewable rather than a live experiment.

reference holds the published data a rule needs but cannot derive: currency
conversion and jurisdiction risk listings, each carrying the date it was current
at, each refusing to answer rather than guess. Passing an unknown currency
through unchanged moves every threshold by the exchange rate — the structuring
rule compared raw notional, so 9,500 units of a minor currency read as a
near-threshold cash amount. A jurisdiction list that answers "not listed"
because it was never loaded is indistinguishable from a clean world.

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

82 lines
2.6 KiB
Go

package engine
import (
"math"
"github.com/luxfi/aml/pkg/types"
)
// severityMultiplier returns a weight multiplier for a severity level.
func severityMultiplier(severity string) float64 {
switch severity {
case types.SeverityCritical:
return 2.0
case types.SeverityHigh:
return 1.5
case types.SeverityMedium:
return 1.0
case types.SeverityLow:
return 0.5
default:
return 1.0
}
}
// clamp01 clamps v to [0, 1].
//
// Not a number clamps to 1, not to 0. A score arrives here from arithmetic over
// weights, and once a Scorer computes a weight rather than reading a constant one,
// arithmetic can produce NaN. NaN compares false against every bound, so the
// obvious two-comparison clamp returns it unchanged: it would then travel as a
// transaction's risk score, fail to marshal into the response, and read as
// harmless in any test written with < or >. Sending it to the top of the range
// puts a broken score in front of a person.
func clamp01(v float64) float64 {
if math.IsNaN(v) {
return 1
}
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// Score computes a weight-of-evidence risk score from rule hits.
// Returns a score in [0,1] and a breakdown per rule ID.
func Score(hits []types.RuleHit) (float64, map[string]float64) {
breakdown := make(map[string]float64, len(hits))
var total float64
for _, h := range hits {
if !h.Match {
continue
}
s := h.Rule.Weight * severityMultiplier(h.Rule.Severity)
breakdown[h.Rule.ID] = s
total += s
}
return clamp01(total), breakdown
}
// Scorer contributes evidence the rule library cannot express: a statistical
// judgement about whether behaviour is unusual for the entity, where a rule can
// only ask whether it matches a pattern someone has already named.
//
// The evidence is an ordinary RuleHit, so it is scored by the same
// weight-of-evidence sum, becomes the same Alert, and reaches the same review
// queue. Nothing downstream distinguishes it, which is deliberate: an alert a
// model raised is reviewed by a person on the same terms as any other, and there
// is one scoring path rather than two.
//
// ok is false whenever the Scorer has nothing to contribute — it cannot score, or
// it can and the transaction is unremarkable. The engine then evaluates on rules
// alone. A Scorer must not signal "normal" by returning a hit with no weight, and
// the engine must not treat ok=false as a clean result; a Scorer owes its own
// account of what it declined to score, because that count is the difference
// between a control that is quiet and one that is not running.
type Scorer interface {
Assess(tx types.Transaction, entity types.Entity) (types.RuleHit, bool)
}