auto-model-router 0.4.2 → 0.4.4

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.
Files changed (48) hide show
  1. package/.gitattributes +2 -0
  2. package/.omp-plugin/marketplace.json +2 -2
  3. package/README.md +31 -3
  4. package/bunfig.toml +2 -0
  5. package/omp-extension/report-logic.ts +46 -0
  6. package/omp-extension/router-configure.ts +55 -3
  7. package/package.json +1 -1
  8. package/src/catalog/composite.ts +4 -1
  9. package/src/cli/config-wizard.ts +8 -1
  10. package/src/config/defaults.ts +8 -0
  11. package/src/config/hot-reload.ts +58 -9
  12. package/src/config/schema.ts +5 -1
  13. package/src/config/types.ts +34 -0
  14. package/src/cost/ledger.ts +84 -8
  15. package/src/cost/report.ts +34 -4
  16. package/src/cost/summary.ts +231 -0
  17. package/src/cost/types.ts +35 -3
  18. package/src/router/candidates.ts +1 -1
  19. package/src/router/classify.ts +2 -2
  20. package/src/router/compaction.ts +2 -1
  21. package/src/router/learned.ts +11 -1
  22. package/src/router/select.ts +27 -3
  23. package/src/server/compaction-digest.ts +129 -0
  24. package/src/server/digest.ts +68 -4
  25. package/src/server/http.ts +50 -7
  26. package/src/server/providers.ts +1 -0
  27. package/src/server/turn.ts +38 -1
  28. package/src/util/sqlite.ts +7 -0
  29. package/src/wire/openai/request.ts +4 -0
  30. package/src/wire/types.ts +7 -0
  31. package/test/cache-control.test.ts +1 -1
  32. package/test/compaction.test.ts +40 -3
  33. package/test/digest.test.ts +44 -0
  34. package/test/failover.test.ts +4 -4
  35. package/test/hot-reload.test.ts +37 -1
  36. package/test/learned.test.ts +21 -1
  37. package/test/migrations.test.ts +84 -0
  38. package/test/report-hub.test.ts +1 -1
  39. package/test/report-logic.test.ts +11 -1
  40. package/test/report.test.ts +29 -1
  41. package/test/select.test.ts +26 -2
  42. package/test/summary.test.ts +171 -0
  43. package/test/support/preload.ts +19 -0
  44. package/test/tokens.test.ts +68 -0
  45. package/test/trust-attribution.test.ts +37 -0
  46. package/test/turn.test.ts +69 -4
  47. package/tools/gen-migration-fixtures.ts +69 -0
  48. package/tools/train-classifier.ts +75 -20
@@ -463,3 +463,40 @@ describe("feedback in trust", () => {
463
463
  db.close();
464
464
  });
465
465
  });
466
+
467
+ describe("task-scoped feedback (filters.feedbackByTask)", () => {
468
+ test("a verdict counts only for its task type; an untasked verdict counts everywhere", () => {
469
+ const db = openDb(":memory:");
470
+ try {
471
+ const c = structuredClone(cfg);
472
+ c.filters.feedbackWeight = 3;
473
+ c.filters.feedbackByTask = true;
474
+ const ledger = createLedger(db, c);
475
+ const fb = createFeedbackStore(db);
476
+ for (let i = 0; i < 10; i++) ledger.record(entry({ error: null, task: "coding" }));
477
+ const prose = entry({ error: null, task: "documentation" });
478
+ ledger.record(prose);
479
+ const untasked = entry({ error: null, task: null });
480
+ ledger.record(untasked);
481
+ fb.record({ ledgerId: prose.id, ompSessionId: "s", slug: "vendor/model", tier: "simple", verdict: "bad", note: "" });
482
+ // 12 clean attempts, weight 3, one bad verdict on a documentation turn.
483
+ const pooled = (12 - 0 + 1) / (12 + 2);
484
+ const withBad = (15 - 3 + 1) / (15 + 2);
485
+ expect(ledger.trust("vendor/model", undefined, "coding")!.successRate).toBeCloseTo(pooled, 9);
486
+ expect(ledger.trust("vendor/model", undefined, "documentation")!.successRate).toBeCloseTo(withBad, 9);
487
+ // No task given (allTrust, reports): pooled behaviour, the verdict counts.
488
+ expect(ledger.trust("vendor/model")!.successRate).toBeCloseTo(withBad, 9);
489
+ expect(ledger.allTrust()[0]!.successRate).toBeCloseTo(withBad, 9);
490
+ // signals() honours the task the same way.
491
+ expect(ledger.signals?.(["vendor/model"], undefined, "coding").get("vendor/model")!.trust!.successRate).toBeCloseTo(pooled, 9);
492
+ // A verdict on a turn that recorded no task counts for every task.
493
+ fb.record({ ledgerId: untasked.id, ompSessionId: "s", slug: "vendor/model", tier: "simple", verdict: "bad", note: "" });
494
+ expect(ledger.trust("vendor/model", undefined, "coding")!.successRate).toBeCloseTo(withBad, 9);
495
+ // Off: task is ignored and every verdict pools.
496
+ c.filters.feedbackByTask = false;
497
+ expect(ledger.trust("vendor/model", undefined, "coding")!.successRate).toBeCloseTo((18 - 6 + 1) / (18 + 2), 9);
498
+ } finally {
499
+ db.close();
500
+ }
501
+ });
502
+ });
package/test/turn.test.ts CHANGED
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
50
50
  classifier: {
51
51
  ambiguityThreshold: 0,
52
52
  model: "test/adjudicator", learnedModelPath: "",
@@ -74,12 +74,12 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
74
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
75
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
76
76
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
77
- compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
77
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
- report: { baselines: [] },
79
+ report: { baselines: [], dailySummary: false },
80
80
  digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
81
81
  profiles: [],
82
- ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
82
+ ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
83
83
  adaptiveTierFloors: true,
84
84
  adaptivePriceCeilings: false,
85
85
  logLevel: "silent",
@@ -930,3 +930,68 @@ describe("latency measurement covers the work the router actually does", () => {
930
930
  });
931
931
 
932
932
  });
933
+
934
+ describe("summarising compaction in a turn", () => {
935
+ function reqWithTool(): NormRequest {
936
+ const content = "line\n".repeat(400);
937
+ const req = mkReq();
938
+ req.messages = [
939
+ { role: "user", text: "fix it", images: 0, textBytes: 6, toolCalls: [] },
940
+ { role: "assistant", text: "", images: 0, textBytes: 0, toolCalls: [{ id: "c1", name: "read", argsJson: '{"path":"a.ts"}' }] },
941
+ { role: "tool", text: content, images: 0, textBytes: Buffer.byteLength(content), toolCalls: [], toolCallId: "c1" },
942
+ { role: "user", text: "go on", images: 0, textBytes: 5, toolCalls: [] },
943
+ ];
944
+ req.promptBytes = req.messages.reduce((s, m) => s + m.textBytes, 0);
945
+ return req;
946
+ }
947
+ function decisionWithPlan(): Decision {
948
+ const d = mkDecision("hard", "dear/model", { escalateTo: null });
949
+ d.compactionPlan = [{ index: 2, mode: "truncate", keepHead: 100, keepTail: 100, note: "large read result", bytes: 2_000 }];
950
+ d.compactionSavedBytes = 1_700;
951
+ return d;
952
+ }
953
+
954
+ test("new edits are digested with the turn's tier, persisted with the plan, and not paid twice on a retry", async () => {
955
+ const seen: { toolName: string; tier?: string; source?: string; content: string; input: Record<string, unknown> }[] = [];
956
+ const digester = {
957
+ digest: async (r: { toolName: string; tier?: string; source?: string; content: string; input: Record<string, unknown> }) => {
958
+ seen.push(r);
959
+ return { digested: true as const, text: "[digest] the file", model: "cheap/model", usd: 0.0001, inputBytes: r.content.length, outputChars: 17, ms: 5 };
960
+ },
961
+ };
962
+ // First attempt is probe-rejected and escalates; the second dispatches. Both decisions carry the same new edit.
963
+ const { router } = mkRouter([decisionWithPlan(), decisionWithPlan()]);
964
+ const { upstream } = mkUpstream([
965
+ { kind: "chunks", chunks: [startChunk("dear/model"), textChunk("I'm sorry, but I can't help with that request."), finishChunk("stop")] },
966
+ { kind: "chunks", chunks: [startChunk("dear/model"), textChunk("done"), finishChunk("stop"), usageChunk({ promptTokens: 50, completionTokens: 2 }, 0.001)] },
967
+ ]);
968
+ const { ledger } = mkLedger();
969
+ const { store, map } = mkConversations();
970
+ const { sink, errors } = mkSink();
971
+ const cfg = mkConfig();
972
+ cfg.compaction.digestToolResults = true;
973
+ cfg.escalation.maxAttempts = 2;
974
+
975
+ await runTurn(reqWithTool(), sink, { config: cfg, router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge(), digester }, new AbortController().signal);
976
+
977
+ expect(errors).toHaveLength(0);
978
+ expect(seen).toHaveLength(1);
979
+ expect(seen[0]).toMatchObject({ toolName: "read", tier: "hard", source: "compaction", input: { path: "a.ts" } });
980
+ expect(seen[0]!.content.startsWith("line\n")).toBe(true);
981
+ const plan = map.get("conv-test")!.compactionPlan!;
982
+ expect(plan[0]?.digest).toBe("[digest] the file");
983
+ });
984
+
985
+ test("without compaction.digestToolResults the digester is never consulted", async () => {
986
+ let calls = 0;
987
+ const digester = { digest: async () => { calls++; return { digested: false as const, reason: "n/a" }; } };
988
+ const { router } = mkRouter([decisionWithPlan()]);
989
+ const { upstream } = mkUpstream([{ kind: "chunks", chunks: [startChunk("dear/model"), textChunk("done"), finishChunk("stop")] }]);
990
+ const { ledger } = mkLedger();
991
+ const { store, map } = mkConversations();
992
+ const { sink } = mkSink();
993
+ await runTurn(reqWithTool(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge(), digester }, new AbortController().signal);
994
+ expect(calls).toBe(0);
995
+ expect(map.get("conv-test")!.compactionPlan![0]?.digest).toBeUndefined();
996
+ });
997
+ });
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Regenerates the old-schema ledger fixtures that test/migrations.test.ts
4
+ * opens with the CURRENT bootstrap.
5
+ *
6
+ * bun tools/gen-migration-fixtures.ts
7
+ *
8
+ * For each release tag that changed the schema, the bootstrap of THAT tag is
9
+ * taken from git, run against a fresh file, and a dummy row is inserted into
10
+ * every table (filling each NOT NULL column without a default by its declared
11
+ * type), so the migrations have data to carry, not just DDL. The WAL is folded
12
+ * back into the main file and the result lands in test/fixtures/migrations/
13
+ * as router-v<user_version>.db. Small (a few dozen KB each); commit them.
14
+ *
15
+ * Re-run only when adding a NEW historical version: rewriting existing
16
+ * fixtures would erase the very thing the test guards.
17
+ */
18
+
19
+ import { Database } from "bun:sqlite";
20
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { $ } from "bun";
24
+
25
+ /** One tag per schema version that shipped. */
26
+ const TAGS = ["v0.1.0", "v0.1.4", "v0.2.0", "v0.2.10", "v0.2.22", "v0.2.28", "v0.3.0"];
27
+ const OUT_DIR = join(import.meta.dir, "..", "test", "fixtures", "migrations");
28
+ mkdirSync(OUT_DIR, { recursive: true });
29
+ const work = mkdtempSync(join(tmpdir(), "amr-migrations-"));
30
+
31
+ function dummy(type: string, name: string): string | number {
32
+ const t = type.toUpperCase();
33
+ if (name === "id" || name === "key") return `fixture-${name}`;
34
+ if (name === "created_at_ms" || name === "updated_at_ms" || name === "fetched_at_ms") return 1_756_000_000_000;
35
+ if (t.includes("INT") || t.includes("REAL")) return 1;
36
+ if (name === "usage") return JSON.stringify({ promptTokens: 100, cachedTokens: 0, cacheWriteTokens: 0, completionTokens: 10, reasoningTokens: 0, images: 0 });
37
+ if (name === "reasons") return JSON.stringify(["fixture"]);
38
+ if (name === "payload") return JSON.stringify({ data: [] });
39
+ return `fixture-${name}`;
40
+ }
41
+
42
+ for (const tag of TAGS) {
43
+ const src = await $`git show ${tag}:src/util/sqlite.ts`.text();
44
+ const modPath = join(work, `sqlite-${tag}.ts`);
45
+ await Bun.write(modPath, src);
46
+ const dbPath = join(work, `${tag}.db`);
47
+ const mod = (await import(modPath)) as { openDb(path: string): Database };
48
+ const db = mod.openDb(dbPath);
49
+ const version = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
50
+ const tables = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all() as { name: string }[]).map((r) => r.name);
51
+ for (const table of tables) {
52
+ const cols = db.query(`PRAGMA table_info(${table})`).all() as { name: string; type: string; notnull: number; dflt_value: string | null; pk: number }[];
53
+ // NOT NULL columns without a default, plus text primary keys (SQLite lets
54
+ // a TEXT PRIMARY KEY be NULL, but the router never writes one that way).
55
+ const fill = cols.filter((c) => (c.notnull === 1 && c.dflt_value === null && !(c.pk === 1 && c.type.toUpperCase().includes("INT"))) || (c.pk === 1 && !c.type.toUpperCase().includes("INT")));
56
+ // A ledger row with an error string exercises the v4 error_kind backfill.
57
+ const values = fill.map((c) => (table === "ledger" && c.name === "error" ? "upstream_error: 502" : dummy(c.type, c.name)));
58
+ if (fill.length === 0) continue;
59
+ db.run(`INSERT INTO ${table} (${fill.map((c) => c.name).join(", ")}) VALUES (${fill.map(() => "?").join(", ")})`, values);
60
+ if (table === "ledger" && cols.some((c) => c.name === "error")) db.run(`UPDATE ledger SET error = 'upstream_error: 502'`);
61
+ }
62
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
63
+ db.run("PRAGMA journal_mode = DELETE");
64
+ db.close();
65
+ const out = join(OUT_DIR, `router-v${version}.db`);
66
+ await Bun.write(out, Bun.file(dbPath));
67
+ console.log(`${tag} → ${out} (user_version ${version}, tables: ${tables.join(", ")})`);
68
+ }
69
+ rmSync(work, { recursive: true, force: true });
@@ -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
  }