auto-model-router 0.4.1 → 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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +48 -2
- package/omp-extension/digest-logic.ts +53 -0
- package/omp-extension/pi-coding-agent.d.ts +14 -1
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/omp-extension/router-digest.ts +93 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +22 -1
- package/src/config/defaults.ts +20 -0
- package/src/config/schema.ts +18 -1
- package/src/config/types.ts +54 -0
- package/src/cost/ledger.ts +77 -9
- package/src/cost/report.ts +24 -3
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +31 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +1 -0
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +5 -1
- package/src/server/compaction-digest.ts +127 -0
- package/src/server/digest.ts +243 -0
- package/src/server/http.ts +51 -1
- package/src/server/turn.ts +27 -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/compaction.test.ts +40 -3
- package/test/controls.test.ts +34 -0
- package/test/digest.test.ts +229 -0
- package/test/embed-lifecycle.test.ts +1 -1
- package/test/failover.test.ts +4 -3
- package/test/learned.test.ts +21 -1
- package/test/report-hub.test.ts +3 -0
- package/test/report-logic.test.ts +11 -1
- package/test/report.test.ts +3 -0
- package/test/select.test.ts +2 -2
- package/test/summary.test.ts +171 -0
- package/test/tokens.test.ts +44 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +69 -3
- 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,9 +74,10 @@ 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
|
+
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
80
81
|
profiles: [],
|
|
81
82
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
82
83
|
adaptiveTierFloors: true,
|
|
@@ -929,3 +930,68 @@ describe("latency measurement covers the work the router actually does", () => {
|
|
|
929
930
|
});
|
|
930
931
|
|
|
931
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
|
+
});
|
|
@@ -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
|
}
|