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.
- package/.gitattributes +2 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +31 -3
- package/bunfig.toml +2 -0
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/package.json +1 -1
- package/src/catalog/composite.ts +4 -1
- package/src/cli/config-wizard.ts +8 -1
- package/src/config/defaults.ts +8 -0
- package/src/config/hot-reload.ts +58 -9
- package/src/config/schema.ts +5 -1
- package/src/config/types.ts +34 -0
- package/src/cost/ledger.ts +84 -8
- package/src/cost/report.ts +34 -4
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +35 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +2 -1
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +27 -3
- package/src/server/compaction-digest.ts +129 -0
- package/src/server/digest.ts +68 -4
- package/src/server/http.ts +50 -7
- package/src/server/providers.ts +1 -0
- package/src/server/turn.ts +38 -1
- package/src/util/sqlite.ts +7 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +7 -0
- package/test/cache-control.test.ts +1 -1
- package/test/compaction.test.ts +40 -3
- package/test/digest.test.ts +44 -0
- package/test/failover.test.ts +4 -4
- package/test/hot-reload.test.ts +37 -1
- package/test/learned.test.ts +21 -1
- package/test/migrations.test.ts +84 -0
- package/test/report-hub.test.ts +1 -1
- package/test/report-logic.test.ts +11 -1
- package/test/report.test.ts +29 -1
- package/test/select.test.ts +26 -2
- package/test/summary.test.ts +171 -0
- package/test/support/preload.ts +19 -0
- package/test/tokens.test.ts +68 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +69 -4
- package/tools/gen-migration-fixtures.ts +69 -0
- 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
|
|
11
|
-
* first (attempt > 0).
|
|
12
|
-
* the
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
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(
|
|
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
|
|
165
|
+
console.log(`\nwrote ${out}\nset classifier.learnedModelPath to it to record learned: p(${positiveName}) on every decision (advisory).`);
|
|
111
166
|
}
|