@pi-in-go/pigpen-pi-typesafe-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "reflect"
7
+ "strings"
8
+ "sync/atomic"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func sample(score float64, label bool, id string) ScoredSample {
14
+ return ScoredSample{Score: score, Label: label, ID: id}
15
+ }
16
+
17
+ // Eight observations: two clear positives, two clear negatives, and a muddy middle.
18
+ func muddy() []ScoredSample {
19
+ return []ScoredSample{
20
+ sample(0.95, true, "p1"), sample(0.8, true, "p2"), sample(0.6, true, "p3"), sample(0.4, true, "p4"),
21
+ sample(0.7, false, "n1"), sample(0.5, false, "n2"), sample(0.2, false, "n3"), sample(0.05, false, "n4"),
22
+ }
23
+ }
24
+
25
+ func f(v float64) *float64 { return &v }
26
+
27
+ func TestCalibrate(t *testing.T) {
28
+ tw(t, "calibrate", "AUC is the rank-based probability that a positive outranks a negative", func(t *testing.T) {
29
+ eq := func(got *float64, want float64) {
30
+ t.Helper()
31
+ if got == nil || *got != want {
32
+ t.Errorf("auc = %v, want %v", got, want)
33
+ }
34
+ }
35
+ eq(AUC([]ScoredSample{sample(1, true, ""), sample(0, false, "")}), 1)
36
+ eq(AUC([]ScoredSample{sample(0, true, ""), sample(1, false, "")}), 0)
37
+ eq(AUC([]ScoredSample{sample(0.5, true, ""), sample(0.5, false, "")}), 0.5)
38
+ eq(AUC([]ScoredSample{sample(0.5, true, ""), sample(0.5, true, ""), sample(0.5, false, ""), sample(0.5, false, "")}), 0.5)
39
+ // One class only: nothing to rank against.
40
+ if AUC([]ScoredSample{sample(0.5, true, ""), sample(0.9, true, "")}) != nil || AUC(nil) != nil {
41
+ t.Error("a single class has no AUC")
42
+ }
43
+ // Three positives above one negative and one below: 3 wins of 4 pairs.
44
+ eq(AUC([]ScoredSample{sample(0.9, true, ""), sample(0.8, true, ""), sample(0.7, true, ""), sample(0.6, false, ""), sample(0.1, true, "")}), 0.75)
45
+ })
46
+ tw(t, "calibrate", "a threshold row counts every case once", func(t *testing.T) {
47
+ row := MetricsAt(muddy(), 0.6)
48
+ if row.Threshold != 0.6 || row.Flagged != 4 || row.TP != 3 || row.FP != 1 || row.FN != 1 || row.TN != 3 || *row.Precision != 0.75 || *row.Recall != 0.75 || row.FlagRate != 0.5 {
49
+ t.Fatalf("row = %+v", row)
50
+ }
51
+ // At the top of the range nothing is flagged, so precision has no denominator.
52
+ strict := MetricsAt(muddy(), 1)
53
+ if strict.Flagged != 0 || strict.Precision != nil || *strict.Recall != 0 {
54
+ t.Fatalf("strict = %+v", strict)
55
+ }
56
+ if got := Sweep(muddy(), []float64{0.6}); got[0].TP != 3 {
57
+ t.Fatalf("sweep = %+v", got)
58
+ }
59
+ })
60
+ tw(t, "calibrate", "the default threshold grid is every distinct score, ascending", func(t *testing.T) {
61
+ if got := DefaultThresholds(muddy(), 0); !reflect.DeepEqual(got, []float64{0.05, 0.2, 0.4, 0.5, 0.6, 0.7, 0.8, 0.95}) {
62
+ t.Fatalf("grid = %v", got)
63
+ }
64
+ if len(DefaultThresholds(muddy(), 3)) != 3 {
65
+ t.Error("limit 3 must give 3 rows")
66
+ }
67
+ if got := DefaultThresholds([]ScoredSample{sample(0.5, true, ""), sample(0.5, false, "")}, 0); !reflect.DeepEqual(got, []float64{0.5}) {
68
+ t.Errorf("grid = %v", got)
69
+ }
70
+ })
71
+ tw(t, "calibrate", "a recommendation honours the floors, and the report names what it misses and flags", func(t *testing.T) {
72
+ c := Calibrate("muddy", muddy(), CalibrateOptions{Thresholds: []float64{0.5, 0.6, 0.7, 0.8}, MinPrecision: f(0.75), MinRecall: f(0.5)})
73
+ if c.Recommended == nil || c.Recommended.Threshold != 0.6 || c.Scored != 8 || c.Positives != 4 || c.Negatives != 4 || c.Errors != 0 || c.AUC == nil || *c.AUC <= 0.7 {
74
+ t.Fatalf("calibration = %+v", c)
75
+ }
76
+ if len(c.Missed) != 1 || c.Missed[0].ID != "p4" || len(c.Flagged) != 1 || c.Flagged[0].ID != "n1" {
77
+ t.Fatalf("missed=%v flagged=%v", c.Missed, c.Flagged)
78
+ }
79
+ text := FormatCalibration(c)
80
+ for _, want := range []string{"muddy: 8 scored, 4 positives, 4 negatives", "recommended 0.60: precision 75%, recall 75%", "missed positives (1): p4", "flagged negatives (1): n1"} {
81
+ if !strings.Contains(text, want) {
82
+ t.Errorf("report lacks %q:\n%s", want, text)
83
+ }
84
+ }
85
+ // Floors nothing can meet leave no recommendation, and the report says so.
86
+ impossible := Calibrate("strict", muddy(), CalibrateOptions{Thresholds: []float64{0.5}, MinPrecision: f(0.99), MinRecall: f(0.99)})
87
+ if impossible.Recommended != nil || !strings.Contains(FormatCalibration(impossible), "recommended: none") {
88
+ t.Fatalf("impossible = %+v", impossible)
89
+ }
90
+ })
91
+ tw(t, "calibrate", "with no floors, the recommendation is the best F1 among the rows", func(t *testing.T) {
92
+ rows := Sweep(muddy(), []float64{0.4, 0.5, 0.6, 0.7})
93
+ var tps []int
94
+ for _, r := range rows {
95
+ tps = append(tps, r.TP)
96
+ }
97
+ // At 0.4 every positive is caught for two false alarms: F1 0.8 beats the tighter rows.
98
+ if !reflect.DeepEqual(tps, []int{4, 3, 3, 2}) {
99
+ t.Fatalf("tp = %v", tps)
100
+ }
101
+ if got := PickThreshold(rows, nil, nil); got == nil || got.Threshold != 0.4 {
102
+ t.Fatalf("pick = %+v", got)
103
+ }
104
+ if PickThreshold(nil, nil, nil) != nil {
105
+ t.Error("no rows, no pick")
106
+ }
107
+ // No positives: precision has no denominator, so no row can be ranked.
108
+ if PickThreshold(Sweep([]ScoredSample{sample(0.5, false, "")}, []float64{0.5}), nil, nil) != nil {
109
+ t.Error("no positives, no pick")
110
+ }
111
+ })
112
+ tw(t, "calibrate", "replay scores labelled cases with bounded concurrency and keeps per-case order", func(t *testing.T) {
113
+ var inFlight, peak atomic.Int32
114
+ cases := []ReplayCase[int]{{"a", true, 1}, {"b", false, 2}, {"c", true, 3}, {"d", false, 4}}
115
+ results := Replay(context.Background(), cases, func(_ context.Context, data int, _ int) (float64, error) {
116
+ n := inFlight.Add(1)
117
+ for {
118
+ p := peak.Load()
119
+ if n <= p || peak.CompareAndSwap(p, n) {
120
+ break
121
+ }
122
+ }
123
+ time.Sleep(5 * time.Millisecond)
124
+ inFlight.Add(-1)
125
+ return float64(data), nil
126
+ }, ReplayOptions{Concurrency: 2})
127
+ if peak.Load() != 2 {
128
+ t.Errorf("peak = %d", peak.Load())
129
+ }
130
+ for i, r := range results {
131
+ if r.ID != cases[i].ID || r.Score != float64(i+1) || r.Skipped || r.Error != "" {
132
+ t.Errorf("result %d = %+v", i, r)
133
+ }
134
+ }
135
+ if samples, errs := SamplesOf(results); errs != 0 || len(samples) != 4 {
136
+ t.Errorf("samples = %v, %d", samples, errs)
137
+ }
138
+ })
139
+ tw(t, "calibrate", "a budget failure stops the replay and leaves the rest unsubmitted", func(t *testing.T) {
140
+ var started []int
141
+ cases := []ReplayCase[int]{{"a", true, 1}, {"b", false, 2}, {"c", true, 3}, {"d", false, 4}}
142
+ msg := "TypeSafe request limit reached (1 attempts per client instance)."
143
+ results := Replay(context.Background(), cases, func(_ context.Context, data int, _ int) (float64, error) {
144
+ started = append(started, data)
145
+ if data == 2 {
146
+ return 0, newError(CodeBudget, msg)
147
+ }
148
+ return 0.1, nil
149
+ }, ReplayOptions{Concurrency: 1, StopOn: func(err error) bool { return hasCode(err, CodeBudget) }})
150
+ if !reflect.DeepEqual(started, []int{1, 2}) || results[1].Error != msg || results[1].Skipped {
151
+ t.Fatalf("started=%v r1=%+v", started, results[1])
152
+ }
153
+ // The budget stop kept every later case from being submitted at all.
154
+ if !results[2].Skipped || !results[3].Skipped || results[3].Scored {
155
+ t.Fatalf("r2=%+v r3=%+v", results[2], results[3])
156
+ }
157
+ samples, errs := SamplesOf(results)
158
+ if errs != 3 || !reflect.DeepEqual(samples, []ScoredSample{{Label: true, Score: 0.1, ID: "a"}}) {
159
+ t.Fatalf("samples = %v, %d", samples, errs)
160
+ }
161
+ // A replay that lost most of its cases still yields honest numbers.
162
+ c := Calibrate("replay", samples, CalibrateOptions{Errors: errs})
163
+ if c.Errors != 3 || c.Scored != 1 || c.AUC != nil {
164
+ t.Fatalf("calibration = %+v", c)
165
+ }
166
+ })
167
+ tw(t, "calibrate", "replay reports scorer failures with the caller's own message", func(t *testing.T) {
168
+ thrown := errors.New("upstream body with a key: sk-secret")
169
+ one := []ReplayCase[string]{{"boom", true, "case"}}
170
+ fail := func(context.Context, string, int) (float64, error) { return 0, thrown }
171
+ // The default is the scorer's own message, which the caller controls; a supplied describer replaces it.
172
+ plain := Replay(context.Background(), one, fail, ReplayOptions{})
173
+ if plain[0].Error != "upstream body with a key: sk-secret" || plain[0].Scored {
174
+ t.Fatalf("plain = %+v", plain[0])
175
+ }
176
+ described := Replay(context.Background(), one, fail, ReplayOptions{DescribeError: func(error) string { return "The scorer failed." }})
177
+ if described[0].Error != "The scorer failed." {
178
+ t.Fatalf("described = %+v", described[0])
179
+ }
180
+ // An error with no message reads as the generic failure (a thrown non-Error in the original).
181
+ blank := Replay(context.Background(), one, func(context.Context, string, int) (float64, error) { return 0, errors.New("") }, ReplayOptions{})
182
+ if blank[0].Error != "The scorer failed." {
183
+ t.Fatalf("blank = %+v", blank[0])
184
+ }
185
+ })
186
+ }