auto-model-router 0.4.2 → 0.4.3

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.
@@ -6,10 +6,15 @@
6
6
  * bun tools/train-classifier.ts --dry-run # evaluate only
7
7
  * bun tools/train-classifier.ts --days 30 # bound the training window
8
8
  * bun tools/train-classifier.ts --out path.json
9
+ * bun tools/train-classifier.ts --label feedback # learn from /router good|bad verdicts
9
10
  *
10
- * Label: the served turn escalated — a cheaper attempt was probe-rejected
11
- * first (attempt > 0). Split: the oldest 80% train, the newest 20% test, so
12
- * the score is what the model would have done on turns it had not seen.
11
+ * Label (default `escalation`): the served turn escalated — a cheaper attempt
12
+ * was probe-rejected first (attempt > 0). With `--label feedback` the rows are
13
+ * the turns a person judged with /router good|bad and the positive class is
14
+ * `bad`: what a user rejected, which a probe cannot see. Verdicts are scarce,
15
+ * so this needs at least FEEDBACK_MIN_ROWS of them and prints the count.
16
+ * Split: the oldest 80% train, the newest 20% test, so the score is what the
17
+ * model would have done on turns it had not seen.
13
18
  * Prints holdout AUC for the learned model and for the heuristic's own score,
14
19
  * plus precision/recall at a few thresholds. Read-only on the ledger.
15
20
  *
@@ -20,7 +25,7 @@
20
25
  import { Database } from "bun:sqlite";
21
26
  import { dirname, join } from "node:path";
22
27
  import { loadConfig } from "../src/config/load.ts";
23
- import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, learnedVector, type LearnedModel, trainLogistic } from "../src/router/learned.ts";
28
+ import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, type LearnedLabel, learnedVector, type LearnedModel, trainLogistic } from "../src/router/learned.ts";
24
29
  import type { Features } from "../src/router/types.ts";
25
30
 
26
31
  const argv = process.argv.slice(2);
@@ -29,6 +34,14 @@ const flag = (name: string): string | undefined => {
29
34
  return i >= 0 ? argv[i + 1] : undefined;
30
35
  };
31
36
  const dryRun = argv.includes("--dry-run");
37
+ const labelFlag = flag("--label") ?? "escalation";
38
+ if (labelFlag !== "escalation" && labelFlag !== "feedback") {
39
+ console.error(`--label must be escalation or feedback, not "${labelFlag}"`);
40
+ process.exit(2);
41
+ }
42
+ const label: LearnedLabel = labelFlag;
43
+ /** Verdict rows are a person's time; a fit on fewer than this is noise dressed as a model. */
44
+ const FEEDBACK_MIN_ROWS = 100;
32
45
  const days = Number.parseInt(flag("--days") ?? "0", 10);
33
46
  const cfg = loadConfig({});
34
47
  const out = flag("--out") ?? join(dirname(cfg.ledger.path), "classifier-learned.json");
@@ -38,28 +51,70 @@ const since = days > 0 ? Date.now() - days * 86_400_000 : 0;
38
51
  // For the heuristic baseline on an escalated turn, the score that MATTERS is
39
52
  // the one the rejected first attempt was routed on; the served row carries the
40
53
  // escalation classification (score 1 by construction), which would leak the label.
41
- const rows = db
42
- .query(
43
- `SELECT l.features, l.attempt,
44
- COALESCE((SELECT w.score FROM ledger w WHERE w.conversation_key = l.conversation_key AND w.turn = l.turn AND w.attempt = 0 AND w.wasted = 1 LIMIT 1), l.score) AS score
45
- FROM ledger l
46
- WHERE l.features IS NOT NULL AND l.wasted = 0 AND l.error IS NULL AND l.created_at_ms >= ?
47
- ORDER BY l.created_at_ms ASC`,
48
- )
49
- .all(since) as { features: string; attempt: number; score: number | null }[];
54
+ interface TrainRow {
55
+ features: string;
56
+ attempt: number;
57
+ score: number | null;
58
+ /** Feedback label only: the person's verdict on this turn. */
59
+ verdict?: string;
60
+ }
61
+ function feedbackRows(): TrainRow[] {
62
+ try {
63
+ return db
64
+ .query(
65
+ `SELECT l.features, l.attempt, l.score, f.verdict
66
+ FROM feedback f JOIN ledger l ON l.id = f.ledger_id
67
+ WHERE l.features IS NOT NULL AND f.created_at_ms >= ?
68
+ ORDER BY f.created_at_ms ASC`,
69
+ )
70
+ .all(since) as TrainRow[];
71
+ } catch (err) {
72
+ if (String(err).includes("no such table: feedback")) {
73
+ console.error("this ledger has no feedback table yet (a router older than v0.4.1 wrote it); restart the router once, then judge some turns");
74
+ process.exit(2);
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+ const rows =
80
+ label === "feedback"
81
+ ? feedbackRows()
82
+ : (db
83
+ .query(
84
+ `SELECT l.features, l.attempt,
85
+ COALESCE((SELECT w.score FROM ledger w WHERE w.conversation_key = l.conversation_key AND w.turn = l.turn AND w.attempt = 0 AND w.wasted = 1 LIMIT 1), l.score) AS score
86
+ FROM ledger l
87
+ WHERE l.features IS NOT NULL AND l.wasted = 0 AND l.error IS NULL AND l.created_at_ms >= ?
88
+ ORDER BY l.created_at_ms ASC`,
89
+ )
90
+ .all(since) as TrainRow[]);
50
91
  db.close();
51
- if (rows.length < 200) {
52
- console.error(`only ${rows.length} rows with features; need a few hundred to fit anything`);
92
+ const minRows = label === "feedback" ? FEEDBACK_MIN_ROWS : 200;
93
+ if (rows.length < minRows) {
94
+ console.error(
95
+ label === "feedback"
96
+ ? `only ${rows.length} judged turns with features (need ${minRows}); keep using /router good|bad and try again later`
97
+ : `only ${rows.length} rows with features; need a few hundred to fit anything`,
98
+ );
53
99
  process.exit(2);
54
100
  }
55
101
 
56
102
  const xs = rows.map((r) => learnedVector(JSON.parse(r.features) as Partial<Features>));
57
- const ys: number[] = rows.map((r) => (r.attempt > 0 ? 1 : 0));
103
+ const ys: number[] = rows.map((r) => (label === "feedback" ? (r.verdict === "bad" ? 1 : 0) : r.attempt > 0 ? 1 : 0));
104
+ const positiveName = label === "feedback" ? "bad" : "escalate";
105
+ if (label === "feedback") {
106
+ const bad = ys.reduce((s, y) => s + y, 0);
107
+ if (bad === 0 || bad === ys.length) {
108
+ console.error(`all ${ys.length} verdicts are ${bad === 0 ? "good" : "bad"}; a label with one class cannot be learned`);
109
+ process.exit(2);
110
+ }
111
+ }
58
112
  const heuristic = rows.map((r) => r.score ?? 0);
59
113
  const split = Math.floor(rows.length * 0.8);
60
114
  const fit = trainLogistic(xs.slice(0, split), ys.slice(0, split));
61
115
  const model: LearnedModel = {
62
116
  version: LEARNED_MODEL_VERSION,
117
+ label,
63
118
  trainedAtMs: Date.now(),
64
119
  rows: rows.length,
65
120
  positives: ys.reduce((s, y) => s + y, 0),
@@ -82,9 +137,9 @@ const testP = testX.map(score);
82
137
  model.auc = auc(testP, testY);
83
138
  const heuristicAuc = auc(heuristic.slice(split), testY);
84
139
 
85
- console.log(`rows ${rows.length} (train ${split}, test ${rows.length - split}); positives ${model.positives} (${((100 * model.positives) / rows.length).toFixed(2)}%)`);
140
+ console.log(`label ${label}: rows ${rows.length} (train ${split}, test ${rows.length - split}); positives (${positiveName}) ${model.positives} (${((100 * model.positives) / rows.length).toFixed(2)}%)`);
86
141
  console.log(`holdout AUC: learned ${model.auc.toFixed(3)} heuristic score ${heuristicAuc.toFixed(3)}`);
87
- console.log("\nprecision / recall on the holdout at p(escalate) thresholds:");
142
+ console.log(`\nprecision / recall on the holdout at p(${positiveName}) thresholds:`);
88
143
  for (const t of [0.5, 0.7, 0.8, 0.9]) {
89
144
  let tp = 0;
90
145
  let fp = 0;
@@ -99,7 +154,7 @@ for (const t of [0.5, 0.7, 0.8, 0.9]) {
99
154
  const rec = tp + fn > 0 ? tp / (tp + fn) : 0;
100
155
  console.log(` p ≥ ${t.toFixed(1)} flagged ${String(tp + fp).padStart(5)} precision ${(100 * prec).toFixed(1).padStart(5)}% recall ${(100 * rec).toFixed(1).padStart(5)}%`);
101
156
  }
102
- console.log("\nweights (standardised; positive ⇒ more likely to escalate):");
157
+ console.log(`\nweights (standardised; positive ⇒ more likely ${positiveName}):`);
103
158
  const ranked = model.names.map((n, i) => [n, model.weights[i]!] as const).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
104
159
  for (const [n, w] of ranked.slice(0, 12)) console.log(` ${n.padEnd(30)} ${w >= 0 ? "+" : ""}${w.toFixed(3)}`);
105
160
 
@@ -107,5 +162,5 @@ if (dryRun) {
107
162
  console.log("\ndry run: nothing written");
108
163
  } else {
109
164
  await Bun.write(out, JSON.stringify(model, null, 1));
110
- console.log(`\nwrote ${out}\nset classifier.learnedModelPath to it to record learned risk on every decision (advisory).`);
165
+ console.log(`\nwrote ${out}\nset classifier.learnedModelPath to it to record learned: p(${positiveName}) on every decision (advisory).`);
111
166
  }