Files
aml/pkg/evaluate/learning_test.go
T
zeekayandHanzo Dev 851643291e scoring: a score means a probability, and a decision says why
Four packages, each one concept, all base-free and linkable from the
decision path:

reason     the closed vocabulary an adverse decision is defended with,
           DERIVED from anomaly.Inventory rather than declared beside it,
           so it cannot describe a model that is not there. Ranked by the
           model's own counterfactual share, never a surrogate.
calibrate  isotonic (pool-adjacent-violators, exact) and Platt, turning a
           density into a probability. Every map records the model shape it
           was fitted under and REFUSES to answer under another, which is
           the training-serving skew control: a calibration cannot outlive
           the coordinates it describes.
policy     the ladder from probability to action, as an immutable value
           with a digest over what it DECIDES. Action names stay opaque
           here so the vocabulary lives at the wire, in one place.
evaluate   the imbalanced-data metrics — ROC-AUC by midranks, average
           precision by the step definition, prevalence always beside
           both, and a cost in exact int64 nano-units with the
           cost-minimising threshold. Plus the deterministic replay of a
           candidate over recorded history, and a two-arm learning curve
           over a FIXED later window so two steps are comparable.

Accuracy is not computed. On a one-in-a-thousand stream it is not a weak
metric, it is a misleading one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:08:46 -07:00

110 lines
3.7 KiB
Go

package evaluate
import (
"math/rand"
"testing"
"github.com/luxfi/aml/pkg/calibrate"
"github.com/luxfi/aml/pkg/policy"
"github.com/luxfi/aml/pkg/replay"
)
// curveData builds a stream in time order whose truth is stationary, so the only
// thing that changes along the curve is how much of it the fit has seen.
func curveData(n int, seed int64) []Observation {
rng := rand.New(rand.NewSource(seed))
out := make([]Observation, 0, n)
for i := range n {
s := rng.Float64()
d := replay.Unproductive
if rng.Float64() < s*s {
d = replay.Productive
}
out = append(out, Observation{ID: "d_" + strconvInt(i), At: at(i), Score: s, Disposition: d})
}
return out
}
// A learning curve with one arm cannot distinguish "more data would help" from
// "the fit has started memorising". Both arms, the split is temporal, and the
// ahead window is the SAME at every step — otherwise two points are measured on
// different data and the curve is a set of unrelated numbers.
func TestLearningHasBothArmsAndImprovesAhead(t *testing.T) {
data := curveData(6000, 51)
steps := Learning(data, calibrate.Isotonic, shape, 10, policy.Cost{})
if len(steps) != 10 {
t.Fatalf("%d steps, want 10", len(steps))
}
pool := len(data) - len(data)/11
for i, s := range steps {
if want := pool * (i + 1) / 10; s.Rows != want {
t.Errorf("step %d saw %d rows, want %d — the split is not a growing temporal prefix", i, s.Rows, want)
}
if s.Held != len(data)-pool {
t.Errorf("step %d is measured against %d held rows, want the fixed %d", i, s.Held, len(data)-pool)
}
if s.Ahead.Rows != s.Held {
t.Errorf("step %d: the ahead report covers %d rows, the window is %d", i, s.Ahead.Rows, s.Held)
}
}
// The whole point of the fixed window: out-of-sample calibration error is
// comparable end to end, and it should fall as labels accrue.
early, late := steps[0].Ahead.Brier, steps[len(steps)-1].Ahead.Brier
if early == nil || late == nil {
t.Fatal("no out-of-sample Brier was measured at both ends of the curve")
}
if *late > *early {
t.Errorf("out-of-sample calibration error rose with more data: %.4f -> %.4f", *early, *late)
}
}
// The split must never let a fit see the future: every held row lies strictly
// after every row any step trained on.
func TestLearningSplitIsTemporalAndDisjoint(t *testing.T) {
data := curveData(1000, 52)
steps := Learning(data, calibrate.Isotonic, shape, 5, policy.Cost{})
if len(steps) == 0 {
t.Fatal("no steps")
}
held := steps[0].Held
cut := len(data) - held
for i, s := range steps {
if s.Held != held {
t.Fatalf("step %d moved the held window: %d vs %d", i, s.Held, held)
}
if s.Rows > cut {
t.Fatalf("step %d trained on %d rows, past the cut at %d — the fit saw the future", i, s.Rows, cut)
}
if !data[s.Rows-1].At.Before(data[cut].At) {
t.Fatalf("step %d: the held window does not lie strictly after the training prefix", i)
}
}
}
// A step whose sample is too thin to fit must SAY so and still report the
// separation, which needs no calibration. A curve that went blank would hide the
// model improving underneath it.
func TestLearningStatesWhyAStepCouldNotFit(t *testing.T) {
// 40 rows over 10 steps: the first steps have four rows each.
steps := Learning(curveData(40, 53), calibrate.Isotonic, shape, 10, policy.Cost{})
var refused int
for _, s := range steps {
if s.Refusal != "" {
refused++
if s.Train.Rows == 0 && s.Train.Judged == 0 {
t.Errorf("a refused step reported nothing at all: %+v", s)
}
}
}
if refused == 0 {
t.Fatal("no step refused on a sample this thin; the refusal path is untested")
}
}
func TestLearningOverNothing(t *testing.T) {
if got := Learning(nil, calibrate.Isotonic, shape, 10, policy.Cost{}); got != nil {
t.Errorf("drew a curve over no history: %v", got)
}
}