1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
|
// Tests for `fluxrec train`: reproducibility, metrics, and model envelope
// consumption by the `score` command.
package cmds
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"fluxrec/core"
)
// syntheticLabels builds a labels.jsonl file interleaving positives and
// negatives across time (so the newest validation window contains both
// classes), with varied feed titles so the feed-identity token is exercised.
// Timestamps are sequential by position: oldest first, newest last, so the
// time split's "newest val-frac" is a clean tail slice with mixed labels.
func syntheticLabels(t *testing.T, path string, nPos, nNeg int) {
t.Helper()
now := time.Now().UTC()
total := nPos + nNeg
rows := make([]core.LabelRow, total)
posTitles := []string{
"New evidence on the rate of productivity growth in services",
"Why headline-only models are fine actually, a reanalysis",
"Field notes from sixty days of careful RSS triage at home",
"The economics of attention and the case for reading slower",
"A long retrospective on building small personal recommendation tools",
"Decade-scale trends in academic publishing, a meta-review",
"How I learned to stop worrying and love the title-only classifier",
"Notes on serendipity, exploration budgets, and ranked recall",
"Reflections on a year of minimal-information inference methods",
"The quiet resurgence of logistic regression in small data settings",
}
negTitles := []string{
"Roundup of this week's product launches and minor updates",
"Local weather forecast for the coming weekend and beyond",
"Five ways to organize your desk for slightly better focus",
"Celebrity interview about their new streaming television show",
"Sports scores and highlights from last night's important games",
"Markets close mostly unchanged as investors await fresh data",
"Travel deals for the autumn season across several destinations",
"Cooking tips for weeknight dinners under thirty minutes flat",
"Opinion piece on city traffic and the eternal parking problem",
"Best of the app store this month, ten small utilities reviewed",
}
feeds := []string{"Marginal Revolution", "ACM Tech News", "Some Other Blog", "Hacker News", "The Atlantic"}
pi, ni := 0, 0
for i := 0; i < total; i++ {
// Place a positive on even indices while positives remain, so
// labels interleave across the whole time range including the tail.
label := core.LabelNeg
title := ""
if i%2 == 0 && pi < nPos {
label, title = core.LabelPos, posTitles[pi%len(posTitles)]
pi++
} else {
label, title = core.LabelNeg, negTitles[ni%len(negTitles)]
ni++
}
rows[i] = core.LabelRow{
EntryID: int64(i + 1), URL: "https://example.com/" + label + "/" + itoa(i),
Title: title, FeedID: int64(i%len(feeds) + 1), FeedTitle: feeds[i%len(feeds)],
Label: label, CapturedAt: now.Add(-time.Duration(total-i) * 24 * time.Hour),
}
}
if err := core.AppendLabels(path, rows); err != nil {
t.Fatalf("AppendLabels: %v", err)
}
}
func itoa(i int) string {
b := []byte{}
if i == 0 {
return "0"
}
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
return string(b)
}
func runTrain(t *testing.T, labelsPath, dir string) (modelPath, reportPath string) {
t.Helper()
modelPath = filepath.Join(dir, "model.json")
reportPath = filepath.Join(dir, "report.json")
cmd := &TrainCommand{}
if err := cmd.Init([]string{labelsPath, "--model-out", modelPath, "--report-out", reportPath, "--val-frac", "0.3"}); err != nil {
t.Fatalf("Init: %v", err)
}
var out bytes.Buffer
if err := cmd.Run(nil, &out); err != nil {
t.Fatalf("Run: %v\nstdout: %s", err, out.String())
}
return
}
// B4: same labels → byte-identical model.json (seeds make training deterministic).
func TestTrainReproducible(t *testing.T) {
dir := t.TempDir()
labelsPath := filepath.Join(dir, "labels.jsonl")
// Enough rows that a 0.3 val split has >=5 positives in val.
syntheticLabels(t, labelsPath, 50, 50)
m1, r1 := runTrain(t, labelsPath, dir)
body1, err := os.ReadFile(m1)
if err != nil {
t.Fatalf("read model1: %v", err)
}
report1, err := os.ReadFile(r1)
if err != nil {
t.Fatalf("read report1: %v", err)
}
// Second train to a second pair of paths.
dir2 := t.TempDir()
m2, r2 := runTrain(t, labelsPath, dir2)
body2, err := os.ReadFile(m2)
if err != nil {
t.Fatalf("read model2: %v", err)
}
report2, err := os.ReadFile(r2)
if err != nil {
t.Fatalf("read report2: %v", err)
}
// Strip CreatedAt (wall clock) before comparing models — the rest must
// be byte-identical. Compare reports fully.
var env1, env2 core.ModelEnvelope
if err := json.Unmarshal(body1, &env1); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body2, &env2); err != nil {
t.Fatal(err)
}
env1.CreatedAt = time.Time{}
env2.CreatedAt = time.Time{}
c1, _ := json.Marshal(env1)
c2, _ := json.Marshal(env2)
if string(c1) != string(c2) {
t.Fatal("model not reproducible: weights/vocab differ across runs with same labels")
}
if string(report1) != string(report2) {
t.Fatal("report not reproducible: differs across runs with same labels")
}
}
// B5: report.json parses and precision@15 is a valid probability.
func TestTrainReportValid(t *testing.T) {
dir := t.TempDir()
labelsPath := filepath.Join(dir, "labels.jsonl")
syntheticLabels(t, labelsPath, 50, 50)
modelPath, reportPath := runTrain(t, labelsPath, dir)
var report trainReport
body, err := os.ReadFile(reportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if err := json.Unmarshal(body, &report); err != nil {
t.Fatalf("report unparseable: %v", err)
}
if report.PrecisionAt15 < 0 || report.PrecisionAt15 > 1 {
t.Errorf("precision@15 = %v, want in [0,1]", report.PrecisionAt15)
}
if report.VocabSize <= 0 {
t.Errorf("vocab size = %d, want > 0", report.VocabSize)
}
if report.TrainPos == 0 || report.TrainNeg == 0 {
t.Errorf("train should have both classes: pos=%d neg=%d", report.TrainPos, report.TrainNeg)
}
if report.ValPos < 5 {
t.Errorf("val pos = %d, guard should have required >=5", report.ValPos)
}
if report.Seed != trainSeed {
t.Errorf("seed = %d, want %d", report.Seed, trainSeed)
}
// Model file exists and has non-empty vocab + weights.
mbody, err := os.ReadFile(modelPath)
if err != nil {
t.Fatalf("read model: %v", err)
}
var model core.ModelEnvelope
if err := json.Unmarshal(mbody, &model); err != nil {
t.Fatalf("model unparseable: %v", err)
}
if len(model.Weights) == 0 || len(model.OrderedVocab) == 0 {
t.Fatal("model missing weights or vocab")
}
}
// B6: the produced model.json is consumable by the score command's loader
// (same ModelEnvelope type). Round-trips through CreateVectorizerFromModel.
func TestTrainModelConsumedByScore(t *testing.T) {
dir := t.TempDir()
labelsPath := filepath.Join(dir, "labels.jsonl")
syntheticLabels(t, labelsPath, 50, 50)
modelPath, _ := runTrain(t, labelsPath, dir)
model, err := loadModel(modelPath) // same loader the score command uses
if err != nil {
t.Fatalf("loadModel (score's loader): %v", err)
}
vec := core.CreateVectorizerFromModel(model)
title := "A long retrospective on building small personal recommendation tools"
out := vec.Transform([]string{core.ScoringText(title, "Marginal Revolution")})
score, err := core.PredictScore(out[0], model.Weights)
if err != nil {
t.Fatalf("PredictScore: %v", err)
}
if score < 0 || score > 1 {
t.Errorf("score = %v, want in [0,1]", score)
}
}
// Guard: too few validation positives must error loudly.
func TestTrainGuardFewValPositives(t *testing.T) {
dir := t.TempDir()
labelsPath := filepath.Join(dir, "labels.jsonl")
// 5 positives total, val-frac 0.3 → 1-2 val positives < 5.
syntheticLabels(t, labelsPath, 5, 20)
cmd := &TrainCommand{}
if err := cmd.Init([]string{labelsPath, "--val-frac", "0.3", "--model-out",
filepath.Join(dir, "m.json"), "--report-out", filepath.Join(dir, "r.json")}); err != nil {
t.Fatalf("Init: %v", err)
}
var out bytes.Buffer
err := cmd.Run(nil, &out)
if err == nil {
t.Fatal("expected error for <5 validation positives, got nil")
}
if !strings.Contains(err.Error(), "positives in validation") {
t.Errorf("error should mention validation positives: %v", err)
}
}
// Guard: empty labels file errors.
func TestTrainEmptyLabels(t *testing.T) {
dir := t.TempDir()
labelsPath := filepath.Join(dir, "empty.jsonl")
if err := os.WriteFile(labelsPath, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cmd := &TrainCommand{}
if err := cmd.Init([]string{labelsPath}); err != nil {
t.Fatalf("Init: %v", err)
}
if err := cmd.Run(nil, &bytes.Buffer{}); err == nil {
t.Fatal("expected error for empty labels")
}
}
|