mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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]>
82 lines
2.6 KiB
Go
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)
|
|
}
|