decidekit 0.0.0-stage → 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-02)
4
+
5
+ The first release. Numbers are on public test splits unless noted; the scripts that produced them are in `eval/`.
6
+
7
+ ### Deciding
8
+
9
+ - `createDecider()` picks one of your labels for a text, in Node or the browser, with `confidence` and `probabilities`. Labels
10
+ are the type: `choice` has the literal type of the labels you pass.
11
+ - Zero-shot from label names, or from described labels (`{ card_arrival: "My card hasn't arrived." }`), which score by
12
+ the better of name and description. Zero-shot decisions report `similarity`, the best cosine similarity to any
13
+ option, which is low for off-topic text.
14
+ - `minConfidence` returns `null` below a threshold. `decideMany()` for batches.
15
+ - Default encoder: `Xenova/bge-small-en-v1.5` at 8-bit (34 MB). Any transformers.js feature-extraction model can be
16
+ passed as `model`.
17
+ - Supported encoders with measured settings: `Xenova/bge-small-en-v1.5` (default) and `Xenova/multilingual-e5-small`.
18
+ The temperature fit now searches 0.01 to 10, so encoders with close similarities calibrate.
19
+ - `describeLabels()` asks an LLM once to write label descriptions (7 to 10 points better zero-shot on the dev splits).
20
+
21
+ ### Training
22
+
23
+ - `train()` fits one softmax layer on frozen embeddings: 90.7% on BANKING77 and 94.4% on CLINC150 with all examples,
24
+ 82.3% and 89.6% with 5 per label. HWU64, never used for tuning: 90.2%.
25
+ - Calibrated: the temperature is fitted on held-out examples (5 folds for small sets), with a per-model floor for
26
+ small heads. Calibration error 0.01 to 0.03 for heads with 10 or more examples per label.
27
+ - `labels` adds labels without examples yet (they start from their names). `calibrateOn` fits the temperature on
28
+ examples known to be right, for training data from a source that is sometimes wrong.
29
+ - `saveHead()` and `loadHead()`. Ready-made heads for BANKING77 and CLINC150.
30
+
31
+ ### Learning from an LLM
32
+
33
+ - `withFallback()` answers locally when confident and calls your `ask` otherwise; each answer becomes a training
34
+ example and `retrainEvery` retrains in the background. It can start from labels alone.
35
+ - Built for an LLM that is sometimes wrong: starting from labels, it fits confidence on zero-shot's own confident
36
+ answers rather than the LLM's, and learns `null` answers as the label `NONE` (`learnNone`) when the text is far
37
+ from every label (`noneBelow`), so it can answer `null` itself for off-topic text.
38
+ - `route.saved()` returns the learned answers and the calibration set as plain JSON; pass them back as `restore` after
39
+ a reload. With real Llama 3.1 8B answers on
40
+ 12 labels, the share sent fell from 30% (zero-shot) to 19%, and off-topic text given a wrong label from 6.7% to 2.4%.
41
+
42
+ ### Browser and tools
43
+
44
+ - `decidekit/typesafe`: `typesafeClient()` gives a decider the shape of TypeSafe's SDK (`systemOne({ state, questions })`,
45
+ `choice()`, `probabilities`, their `confidence` formula). Choice questions only.
46
+ - `decidekit/browser`: `createWorkerDecider()` runs the encoder in a Web Worker (training BANKING77 froze the page for
47
+ 171 s on the main thread; the longest stall in a worker was 18 ms).
48
+ - `decidekit/react`: `useDecider()` and `useDecision()`.
49
+ - `decidekit/switch`: a decider for the switch agent harness.
50
+ - A CLI: `decidejs train` and `decidejs decide`.
51
+
52
+ ### Known limits
53
+
54
+ - The default encoder reads English. `Xenova/multilingual-e5-small` is supported for other languages (76% on Japanese
55
+ and Chinese MASSIVE intents, calibrated), but `withFallback`'s off-topic gate doesn't fire with it.
56
+ - Weak at tone: 61% on tweet sentiment and 49% on emotion with 32 examples per label. See the FAQ.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 debuff contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,242 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # decidejs
2
+
3
+ [![npm](https://img.shields.io/npm/v/decidekit?color=cffb2d&labelColor=11140d)](https://www.npmjs.com/package/decidekit) [![ci](https://github.com/debuffjp/decidejs/actions/workflows/ci.yml/badge.svg)](https://github.com/debuffjp/decidejs/actions/workflows/ci.yml) [![MIT](https://img.shields.io/badge/licence-MIT-cffb2d?labelColor=11140d)](LICENSE) [![Discord](https://img.shields.io/badge/discord-debuff-a78bfa?labelColor=11140d)](https://discord.gg/jshbDHSpCY)
4
+
5
+ **[Try it in your browser](https://debuff.jp/decidejs/playground/)** · [Watch it learn from an LLM](https://debuff.jp/decidejs/lanes/) · [Launch post](https://debuff.jp/blog/introducing-decidejs) · [Research: when the LLM is wrong](https://debuff.jp/research/learning-from-a-noisy-llm)
6
+
7
+ A decision model for the browser and Node: it picks one of your labels for a piece of text, with confidence you can trust, and learns from an LLM when it isn't sure. Your labels are the type. No server, no API key.
8
+
9
+ A System One model in the browser: like TypeSafe's Jev, but on the user's device, so each decision is free, private and a few milliseconds. Jev leaves unsure cases to your code; decidejs can ask an LLM about them and learn from the answers.
10
+
11
+ ```ts
12
+ import { createDecider } from "decidekit";
13
+
14
+ const d = await createDecider();
15
+ const r = await d.decide("my card never arrived", ["card_arrival", "refund", "other"]);
16
+ r.choice; // "card_arrival" | "refund" | "other"
17
+ ```
18
+
19
+ Give it 5 examples per label and it reaches 82% on BANKING77 (77 intents); give it the full training set and it
20
+ reaches 90.7%, with each answer taking about 8 ms (median) in Chrome. Training runs in the user's tab, so their data never
21
+ leaves the device.
22
+
23
+ ## Zero-shot
24
+
25
+ Pass labels, or labels with descriptions. Labels like `card_arrival` are read as "card arrival".
26
+
27
+ ```ts
28
+ await d.decide(text, ["question", "bug_report", "praise"]);
29
+ await d.decide(text, { refund: "wants their money back", bug: "something is broken" });
30
+ ```
31
+
32
+ Zero-shot is for trying things out: 61% on BANKING77 and 70% on CLINC150 (dev splits), against 90-94% trained.
33
+ Its confidence came out close to calibrated on those datasets (error 0.005-0.045 with 2 to 150 options), but check
34
+ it on your own labels before relying on a threshold.
35
+
36
+ ## Train a head
37
+
38
+ A head is a small layer trained on your examples: 156 KB for 77 labels. Train it once and ship it as a static
39
+ file, or train it in the page.
40
+
41
+ ```ts
42
+ import { createDecider, saveHead, loadHead } from "decidekit";
43
+
44
+ const d = await createDecider();
45
+ const head = await d.train(examples); // { text, label }[]
46
+ await writeFile("intent.head.json", saveHead(head));
47
+
48
+ // later, in the app
49
+ const r = await d.decide(text, loadHead(json)); // choice is typed as your labels
50
+ const rs = await d.decideMany(texts, head); // many texts, encoded in batches
51
+ ```
52
+
53
+ Each label starts at the average of its examples plus its own name, so a handful per label already works. If your
54
+ labels are opaque ids (`c17`), pass `{ labelNames: false }`. Training then fits a temperature on held-out examples,
55
+ so `confidence` is calibrated: of the answers given at 0.9, about 90% are right.
56
+
57
+ Or train from the command line and ship the file:
58
+
59
+ ```sh
60
+ npx decidekit train examples.csv -o intent.head.json # CSV with text,label columns, or JSONL
61
+ npx decidekit decide intent.head.json "my card never arrived"
62
+ ```
63
+
64
+ Add `--types` to also write `intent.head.labels.ts`, so `loadHead<Label>(json)` types the choice as your labels.
65
+
66
+ ## "None of these"
67
+
68
+ ```ts
69
+ const r = await d.decide(text, head, { minConfidence: 0.9 });
70
+ r.choice; // "card_arrival" | "refund" | ... | null
71
+ ```
72
+
73
+ Below the threshold, `choice` is `null`. Use it to send unclear messages to a person or a bigger model. It needs
74
+ enough examples to mean something: with 3 examples per label, a head still gave "hello" 0.89. The numbers below come
75
+ from CLINC150's 100 examples per label. With fewer, check the threshold on some off-topic text of your own, or add an
76
+ `other` label trained on it.
77
+
78
+ ## Fall back to an LLM
79
+
80
+ ```ts
81
+ const route = withFallback(d, head, { minConfidence: 0.9, ask: (text, labels) => askMyLlm(text, labels), examples });
82
+ const r = await route(text); // r.source: "local" | "fallback"
83
+ ```
84
+
85
+ Below the threshold, `ask` answers and its answer becomes a training example. Simulated on CLINC150 from 5 examples
86
+ per label with an LLM that is always right, the share sent to the LLM fell from 41% to 14-17% over 10,000 queries
87
+ while the head went from 86% to 94%. With real Llama 3.1 8B answers, starting from 12 described labels alone, it fell
88
+ from 30% to 19%.
89
+
90
+ Real LLMs are sometimes wrong, and `withFallback()` is built for that: it sets its confidence on its own sure answers
91
+ rather than the LLM's, and learns the LLM's "none of these" (`null`) as a label, `NONE`, so it can say `null` itself
92
+ for off-topic text. Details and numbers: [docs/guide-fallback.md](docs/guide-fallback.md).
93
+
94
+ ## Coming from Jev
95
+
96
+ `decidekit/typesafe` has the shape of TypeSafe's SDK, for Choice questions:
97
+
98
+ ```ts
99
+ import { choice, typesafeClient } from "decidekit/typesafe";
100
+
101
+ const client = typesafeClient(await createDecider());
102
+ const { answers } = await client.systemOne({
103
+ state: "I was charged twice",
104
+ questions: { category: choice("What is this about?", { billing: null, technical: null, other: null }) },
105
+ });
106
+ ```
107
+
108
+ The [guide](docs/guide-typesafe.md) covers what differs, including what `confidence` means.
109
+
110
+ ## switch
111
+
112
+ `decidekit/switch` makes decidejs a decision model for [switch](https://github.com/osolmaz/switch), the harness that
113
+ runs a decision model through a task. Zero-shot, it won two of the three WikiRace races from switch's example on a
114
+ laptop CPU. See the [switch guide](docs/guide-switch.md).
115
+
116
+ ## React
117
+
118
+ `useDecider()` and `useDecision(decider, text, labels)` from `decidekit/react`: [docs/guide-react.md](docs/guide-react.md).
119
+
120
+ ## In the browser
121
+
122
+ Use the worker version. The encoder blocks whatever thread it runs on (we measured a 171 s freeze while training
123
+ on the main thread; in a worker the page's longest stall was 18 ms).
124
+
125
+ ```ts
126
+ import { createWorkerDecider, loadHead } from "decidekit/browser";
127
+
128
+ const head = loadHead(await (await fetch("/intent.head.json")).text());
129
+ const d = await createWorkerDecider({
130
+ onProgress: ({ file, loaded, total }) => showProgress(file, loaded / total), // first visit only
131
+ });
132
+ const r = await d.decide(text, head);
133
+ ```
134
+
135
+ Import `loadHead` from `decidekit/browser` too, not from `decidejs`: that keeps the encoder out of your page bundle.
136
+
137
+ Tested from npm in Vite 7, webpack 5 and Next.js 16 apps; see [docs/bundlers.md](docs/bundlers.md) for the config, including a one-line alias
138
+ that halves the wasm download. Serve the page cross-origin isolated if you can:
139
+
140
+ ```
141
+ Cross-Origin-Opener-Policy: same-origin
142
+ Cross-Origin-Embedder-Policy: credentialless
143
+ ```
144
+
145
+ That lets wasm use several threads: in our runs with all-MiniLM-L6-v2 it cut p95 decision time from 14.3 ms to 7.8 ms. With the default bge-small, isolated, p95 is 13.8 ms.
146
+
147
+ Download on first visit: the model (34 MB) and onnxruntime's wasm (23.6 MB, 5.8 MB gzipped), both cached after.
148
+
149
+ ## Numbers
150
+
151
+ bge-small-en-v1.5 (the default) at q8, Ryzen 7 260 laptop, Node 24 and headless Chrome 156, 2026-11-01. Accuracy
152
+ and calibration error (ECE, lower is better) on each dataset's test split, mean of 3 seeds (one run for "all"):
153
+ [eval/results-bge-small.json](eval/results-bge-small.json).
154
+
155
+ | Examples per label | 5 | 10 | 25 | all |
156
+ |---|---|---|---|---|
157
+ | BANKING77 (77 labels) | 82.3% | 83.8% | 88.7% | 90.7% |
158
+ | CLINC150 (150 labels) | 89.6% | 92.0% | 93.4% | 94.4% |
159
+ | ECE, BANKING77 | 0.071 | 0.029 | 0.011 | 0.017 |
160
+ | ECE, CLINC150 | 0.028 | 0.010 | 0.015 | 0.008 |
161
+
162
+ Pre-release builds defaulted to all-MiniLM-L6-v2 (23.7 MB, 2026-10-31, [eval/results.json](eval/results.json)):
163
+
164
+ | Examples per label | 5 | 10 | 25 | all |
165
+ |---|---|---|---|---|
166
+ | BANKING77 (77 labels) | 78.7% | 83.0% | 87.4% | 90.6% |
167
+ | CLINC150 (150 labels) | 87.3% | 89.8% | 92.8% | 94.0% |
168
+ | ECE, BANKING77 | 0.065 | 0.048 | 0.025 | 0.019 |
169
+ | ECE, CLINC150 | 0.029 | 0.013 | 0.012 | 0.012 |
170
+
171
+ Calibration is loosest at 5 examples per label on BANKING77. On small tasks (2 to 10 labels sampled from CLINC150, scored on its validation
172
+ queries, [eval/tiny.ts](eval/tiny.ts), measured with all-MiniLM-L6-v2), 1 example per label was right 93% of the time with calibration error 0.020, and
173
+ 3 per label 98% with 0.016. Add examples before relying on
174
+ a threshold there.
175
+
176
+ **In Chrome** (BANKING77, full training set, worker, cross-origin isolated, 16 threads): 7.7 ms median and 13.8 ms
177
+ p95 per decision, 228 s to train on 10,003 examples with no page stall longer than 18 ms. all-MiniLM-L6-v2 in the
178
+ same run: 4.2 ms median, 7.7 ms p95. In Node: 3.2 ms median for a single text, 1.1 ms per text batched (MiniLM 2.0
179
+ and 0.7). Earlier MiniLM run: [bench/results.json](bench/results.json).
180
+
181
+ **Text that fits no label:** CLINC150 has 1,000 test queries that match none of its 150 intents. Trained on
182
+ in-scope examples only, with `minConfidence` 0.93 picked on the validation split, the shipped bge-small head returns
183
+ `null` for 89.9% of them. With all-MiniLM-L6-v2 that was 86.3%, and 82.9% of in-scope queries were answered and right
184
+ (94.0% right with no threshold).
185
+
186
+ **The smaller model.** `createDecider({ model: "Xenova/all-MiniLM-L6-v2" })` (23.7 MB, the pre-release default) is
187
+ about 1.8x faster per decision in Chrome and 3 to 5 points less accurate with few examples, about the same with all
188
+ the data. Pass it to keep using heads trained on it. Before this default changed, the README suggested
189
+ `{ labelNames: false }` with bge-small: label names slightly hurt it on the dev splits.
190
+
191
+ Reproduce: `npm run eval:suite` (Node), `npm run bench` then `npm run bench:run` (Chrome).
192
+
193
+ ## Playground
194
+
195
+ Try it at [debuff.jp/decidejs](https://debuff.jp/decidejs/). To run it locally: `npm run playground`, then serve `playground/dist` cross-origin isolated (`node bench/serve.mjs` style headers).
196
+ Try zero-shot labels, the ready-made heads, a head trained in the page and "none of these".
197
+ [Two lanes](https://debuff.jp/decidejs/lanes/) shows `withFallback` live: decidejs starts from twelve label names,
198
+ asks a small LLM when it's unsure, learns from each answer, and replays the first 20 messages at the end.
199
+
200
+ ## Ready-made heads
201
+
202
+ [heads/](heads/) has a BANKING77 support-intent head (90.6%) and a CLINC150 assistant-intent head (95.0%), so you
203
+ can try a trained head before collecting examples.
204
+
205
+ ## What it's for
206
+
207
+ Routing support messages, triaging feedback, search and command intent, moderation hints while someone types,
208
+ labelling a dataset in the browser, a cheap first pass before an LLM that learns from it, games, extensions
209
+ and offline apps, and text that can't leave the device. Details, code and poor fits:
210
+ [docs/use-cases.md](docs/use-cases.md). Runnable examples are in [examples/](examples/).
211
+
212
+ ## Compared with
213
+
214
+ | | Runs in the browser | Trains in the browser | Typed result | Calibrated confidence |
215
+ |---|---|---|---|---|
216
+ | decidejs | yes (worker) | yes | yes | trained heads |
217
+ | [jimothy](https://www.npmjs.com/package/jimothy) | inference (worker) | no, Node | yes | yes |
218
+ | [Jev](https://typesafe.ai/) (TypeSafe) | no, API | no | yes | yes |
219
+ | transformers.js zero-shot pipeline | yes | no | no | no |
220
+ | MediaPipe text classifier | yes | no, offline | no | no |
221
+ | LLM structured output (AI SDK `Output.choice`) | no, API | n/a | yes | no |
222
+
223
+ jimothy reports 92.4% on BANKING77, above our 90.7%. Pick it if you train in Node and want the higher number. Pick
224
+ decidejs if training has to happen in the page, from the user's own data.
225
+
226
+ ## Docs
227
+
228
+ [docs/README.md](docs/README.md) indexes the guides (training, confidence and thresholds, the browser), the
229
+ API reference, how it works and the FAQ.
230
+
231
+ ## Community
232
+
233
+ Questions and show-and-tell in [Discussions](https://github.com/debuffjp/decidejs/discussions), chat in the
234
+ [debuff Discord](https://discord.gg/jshbDHSpCY). Results on your own data, good or bad, help most: there's an
235
+ issue template for them. See [CONTRIBUTING.md](CONTRIBUTING.md).
236
+
237
+ ## Status
238
+
239
+ 0.1. English with the default model, other languages with `multilingual-e5-small` ([FAQ](docs/faq.md#which-languages-does-it-support)); short texts (about a paragraph). Not done yet: WebGPU in the worker, a
240
+ fallback to a hosted model below the threshold, and fine-tuning the encoder (the likely route past about 91%).
241
+
242
+ Made by the [debuff](https://debuff.jp) team. MIT.
@@ -0,0 +1,25 @@
1
+ export { saveHead, loadHead } from "./head-io.ts";
2
+ export { withFallback, NONE, type FallbackOptions, type RoutedDecision, type Trainable } from "./fallback.ts";
3
+ export type { Choices, DecideOptions, DeciderOptions, Decision, Example, Head, TrainOptions } from "./index.ts";
4
+ import type { Choices, DecideOptions, DeciderOptions, Decision, Example, Head, TrainOptions } from "./index.ts";
5
+ /**
6
+ * `createDecider()` in a Web Worker: same `decide()`, `decideMany()` and `train()`, plus `terminate()`. Each call costs about 1 ms
7
+ * of message passing. Serve the page cross-origin isolated (COOP `same-origin`, COEP `credentialless`) so wasm
8
+ * can use several threads. Pass your own `worker` if your bundler needs a different worker URL.
9
+ */
10
+ export declare function createWorkerDecider({ onProgress, ...options }?: DeciderOptions, worker?: Worker): Promise<{
11
+ decide: {
12
+ <const L extends string>(text: string, using: Choices<L>, options?: {
13
+ minConfidence?: undefined;
14
+ }): Promise<Decision<L>>;
15
+ <const L extends string>(text: string, using: Choices<L>, options: DecideOptions): Promise<Decision<L, L | null>>;
16
+ };
17
+ decideMany: {
18
+ <const L extends string>(texts: readonly string[], using: Choices<L>, options?: {
19
+ minConfidence?: undefined;
20
+ }): Promise<Decision<L>[]>;
21
+ <const L extends string>(texts: readonly string[], using: Choices<L>, options: DecideOptions): Promise<Decision<L, L | null>[]>;
22
+ };
23
+ train: <const L extends string>(examples: readonly Example<L>[], { onEpoch, ...opts }?: TrainOptions<L>) => Promise<Head<L>>;
24
+ terminate: () => void;
25
+ }>;
@@ -0,0 +1,43 @@
1
+ // The same decide() and train() as createDecider, running in a Web Worker so the page never freezes.
2
+ export { saveHead, loadHead } from "./head-io.js";
3
+ export { withFallback, NONE } from "./fallback.js";
4
+ /**
5
+ * `createDecider()` in a Web Worker: same `decide()`, `decideMany()` and `train()`, plus `terminate()`. Each call costs about 1 ms
6
+ * of message passing. Serve the page cross-origin isolated (COOP `same-origin`, COEP `credentialless`) so wasm
7
+ * can use several threads. Pass your own `worker` if your bundler needs a different worker URL.
8
+ */
9
+ export async function createWorkerDecider({ onProgress, ...options } = {}, worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" })) {
10
+ let next = 0;
11
+ const pending = new Map();
12
+ worker.onmessage = ({ data }) => {
13
+ const p = pending.get(data.id);
14
+ if (!p)
15
+ return;
16
+ if (data.progress)
17
+ return p.progress?.(data.progress);
18
+ pending.delete(data.id);
19
+ data.error ? p.reject(new Error(data.error)) : p.resolve(data.value);
20
+ };
21
+ const call = (op, payload, progress) => new Promise((resolve, reject) => {
22
+ const id = next++;
23
+ pending.set(id, { resolve, reject, progress });
24
+ worker.postMessage({ id, op, ...payload });
25
+ });
26
+ await call("init", { options }, onProgress);
27
+ function decide(text, using, opts) {
28
+ return call("decide", { args: [text, using, opts] });
29
+ }
30
+ function decideMany(texts, using, opts) {
31
+ return call("decideMany", { args: [texts, using, opts] });
32
+ }
33
+ return {
34
+ decide, decideMany,
35
+ train: (examples, { onEpoch, ...opts } = {}) => call("train", { args: [examples, opts] }, (p) => onEpoch?.(p.epoch, p.accuracy)),
36
+ terminate: () => {
37
+ worker.terminate();
38
+ for (const p of pending.values())
39
+ p.reject(new Error("decidejs: the worker was terminated"));
40
+ pending.clear();
41
+ },
42
+ };
43
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { type Example } from "./index.ts";
3
+ export declare function readExamples(file: string): Promise<Example<string>[]>;
4
+ /** A module with the head's labels, so code that loads the head file can type it: `loadHead<Label>(json)`. */
5
+ export declare function labelsModule(labels: readonly string[]): string;
package/dist/cli.js ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // decidekit train <examples.csv|.jsonl> [-o head.json] [--model id] [--no-label-names] [--types]
3
+ // decidekit decide <head.json> "<text>" [--min-confidence 0.9]
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { parseArgs } from "node:util";
6
+ import { createDecider, loadHead, saveHead } from "./index.js";
7
+ const USAGE = `usage:
8
+ decidekit train <examples.csv|.jsonl> [-o head.json] [--model <hf-id>] [--no-label-names] [--types]
9
+ decidekit decide <head.json> "<text>" [--min-confidence <0-1>]
10
+
11
+ --types also writes head.labels.ts with the labels as a const array and a union type.
12
+
13
+ CSV needs a header with "text" and "label" columns. JSONL needs {"text", "label"} per line.`;
14
+ function parseCsv(source) {
15
+ const rows = [];
16
+ let row = [], field = "", quoted = false;
17
+ for (let i = 0; i < source.length; i++) {
18
+ const ch = source[i];
19
+ if (quoted) {
20
+ if (ch === '"' && source[i + 1] === '"') {
21
+ field += '"';
22
+ i++;
23
+ }
24
+ else if (ch === '"')
25
+ quoted = false;
26
+ else
27
+ field += ch;
28
+ }
29
+ else if (ch === '"')
30
+ quoted = true;
31
+ else if (ch === ",") {
32
+ row.push(field);
33
+ field = "";
34
+ }
35
+ else if (ch === "\n") {
36
+ row.push(field);
37
+ rows.push(row);
38
+ row = [];
39
+ field = "";
40
+ }
41
+ else if (ch !== "\r")
42
+ field += ch;
43
+ }
44
+ if (field || row.length) {
45
+ row.push(field);
46
+ rows.push(row);
47
+ }
48
+ return rows.filter((r) => r.some((f) => f !== ""));
49
+ }
50
+ export async function readExamples(file) {
51
+ const source = await readFile(file, "utf8");
52
+ if (file.endsWith(".jsonl"))
53
+ return source.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
54
+ const [header, ...rows] = parseCsv(source);
55
+ const t = header.indexOf("text"), l = header.indexOf("label");
56
+ if (t < 0 || l < 0)
57
+ throw new Error(`${file}: the header needs "text" and "label" columns, found ${header.join(", ")}`);
58
+ return rows.map((r) => ({ text: r[t], label: r[l] }));
59
+ }
60
+ /** A module with the head's labels, so code that loads the head file can type it: `loadHead<Label>(json)`. */
61
+ export function labelsModule(labels) {
62
+ return `// Written by decidekit train --types. Labels of the head next to this file.
63
+ export const labels = ${JSON.stringify(labels)} as const;
64
+ export type Label = (typeof labels)[number];
65
+ `;
66
+ }
67
+ async function main(argv) {
68
+ const { values, positionals } = parseArgs({
69
+ args: argv, allowPositionals: true,
70
+ options: {
71
+ out: { type: "string", short: "o" }, model: { type: "string" }, "no-label-names": { type: "boolean" }, types: { type: "boolean" },
72
+ "min-confidence": { type: "string" }, help: { type: "boolean", short: "h" },
73
+ },
74
+ });
75
+ const [command, file, text] = positionals;
76
+ if (values.help || !command)
77
+ return console.log(USAGE);
78
+ if (command === "train" && file) {
79
+ const examples = await readExamples(file);
80
+ const counts = new Map();
81
+ for (const e of examples)
82
+ counts.set(e.label, (counts.get(e.label) ?? 0) + 1);
83
+ const fewest = Math.min(...counts.values());
84
+ console.error(`${examples.length} examples, ${counts.size} labels, fewest per label: ${fewest}`);
85
+ if (fewest < 3)
86
+ console.error("warning: labels with fewer than 3 examples will be unreliable");
87
+ const d = await createDecider({ model: values.model });
88
+ const t0 = performance.now();
89
+ const head = await d.train(examples, { labelNames: !values["no-label-names"] });
90
+ const out = values.out ?? "head.json";
91
+ const json = saveHead(head);
92
+ await writeFile(out, json);
93
+ if (values.types) {
94
+ const ts = out.replace(/\.json$/, "") + ".labels.ts";
95
+ await writeFile(ts, labelsModule(head.labels));
96
+ console.error(`wrote ${ts}`);
97
+ }
98
+ console.error(`wrote ${out} (${Math.round(json.length / 1024)} KB) in ${((performance.now() - t0) / 1000).toFixed(1)} s, temperature ${head.temperature}`);
99
+ return;
100
+ }
101
+ if (command === "decide" && file && text !== undefined) {
102
+ const head = loadHead(await readFile(file, "utf8"));
103
+ const d = await createDecider({ model: head.model });
104
+ const min = values["min-confidence"];
105
+ if (min !== undefined && !(Number(min) >= 0 && Number(min) <= 1))
106
+ throw new Error(`--min-confidence needs a number from 0 to 1, got ${min}`);
107
+ const r = min === undefined ? await d.decide(text, head) : await d.decide(text, head, { minConfidence: Number(min) });
108
+ console.log(JSON.stringify({ choice: r.choice, confidence: Number(r.confidence.toFixed(4)) }));
109
+ return;
110
+ }
111
+ console.error(USAGE);
112
+ process.exitCode = 2;
113
+ }
114
+ await main(process.argv.slice(2)).catch((e) => { console.error(String(e?.message ?? e)); process.exitCode = 1; });
@@ -0,0 +1,14 @@
1
+ export interface DescribeOptions {
2
+ /** What the texts are, so descriptions match them. For example "messages to a bank's support chat". */
3
+ context?: string;
4
+ }
5
+ /** The prompt `describeLabels()` sends. Exported so you can run it yourself and pass the reply to `parseDescriptions()`. */
6
+ export declare function describePrompt(labels: readonly string[], { context }?: DescribeOptions): string;
7
+ /** Reads an LLM's reply to `describePrompt()`. Labels the reply missed, or answered with anything but a string, are left out. */
8
+ export declare function parseDescriptions<const L extends string>(labels: readonly L[], reply: string): Partial<Record<L, string>>;
9
+ /**
10
+ * Asks an LLM once for a one-sentence description of each label and returns a record `decide()` takes as its
11
+ * options. A label the LLM skipped gets its own name, so every label is present. Save the result and reuse it;
12
+ * there's no need to call this on every load.
13
+ */
14
+ export declare function describeLabels<const L extends string>(labels: readonly L[], complete: (prompt: string) => string | Promise<string>, options?: DescribeOptions): Promise<Record<L, string>>;
@@ -0,0 +1,30 @@
1
+ // Label descriptions written once by an LLM. Zero-shot decide() with a one-sentence description per label, in the
2
+ // user's voice, was 7 to 10 points more accurate than label names on two intent datasets (eval/NOTES-accuracy.md).
3
+ /** The prompt `describeLabels()` sends. Exported so you can run it yourself and pass the reply to `parseDescriptions()`. */
4
+ export function describePrompt(labels, { context } = {}) {
5
+ return [
6
+ `Write one short sentence for each label below, phrased as a typical ${context ?? "message"} that belongs under it, in the voice of the person writing it.`,
7
+ "Don't repeat the label's words if a more natural phrasing exists. Reply with only a JSON object mapping each label to its sentence.",
8
+ "",
9
+ ...labels.map((l) => `- ${l}`),
10
+ ].join("\n");
11
+ }
12
+ /** Reads an LLM's reply to `describePrompt()`. Labels the reply missed, or answered with anything but a string, are left out. */
13
+ export function parseDescriptions(labels, reply) {
14
+ const json = reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1);
15
+ let parsed = {};
16
+ try {
17
+ parsed = JSON.parse(json);
18
+ }
19
+ catch { }
20
+ return Object.fromEntries(labels.filter((l) => typeof parsed[l] === "string" && parsed[l].trim()).map((l) => [l, parsed[l].trim()]));
21
+ }
22
+ /**
23
+ * Asks an LLM once for a one-sentence description of each label and returns a record `decide()` takes as its
24
+ * options. A label the LLM skipped gets its own name, so every label is present. Save the result and reuse it;
25
+ * there's no need to call this on every load.
26
+ */
27
+ export async function describeLabels(labels, complete, options) {
28
+ const got = parseDescriptions(labels, await complete(describePrompt(labels, options)));
29
+ return Object.fromEntries(labels.map((l) => [l, got[l] ?? l.replace(/[_-]+/g, " ")]));
30
+ }
@@ -0,0 +1,75 @@
1
+ import type { Choices, Decision, Example, Head, TrainOptions } from "./index.ts";
2
+ /** What `withFallback()` needs from a decider: `createDecider()` and `createWorkerDecider()` both fit. */
3
+ export interface Trainable {
4
+ decide<const L extends string>(text: string, using: Choices<L>): Promise<Decision<L>>;
5
+ train<const L extends string>(examples: readonly Example<L>[], options?: TrainOptions<L>): Promise<Head<L>>;
6
+ }
7
+ export interface FallbackOptions<L extends string> {
8
+ /** Below this confidence, call `ask`. Default 0.9. */
9
+ minConfidence?: number;
10
+ /**
11
+ * Answers the texts the local decision isn't sure about: return one of `labels`, or `null` for none of them.
12
+ * Answers that aren't one of the labels count as `null`.
13
+ */
14
+ ask: (text: string, labels: readonly L[], local: Decision<L>) => L | null | Promise<L | null>;
15
+ /**
16
+ * The examples a trained head was trained on. Pass them (or start from labels) to learn from `ask`: each answer
17
+ * becomes an example, and `retrain()` trains on both.
18
+ */
19
+ examples?: readonly Example<L>[];
20
+ /**
21
+ * Learn `ask`'s `null` answers ("none of these") as a label of their own, `NONE`, so the head learns what off-topic
22
+ * text looks like and answers `null` itself when it is sure. Default true. On CLINC150 with 4.5% off-topic traffic, it
23
+ * halved the off-topic texts given a confident wrong label (7.6% to 3.8%) and answered 56% of them locally, at no
24
+ * cost on in-scope text (eval/none-label.ts).
25
+ */
26
+ learnNone?: boolean;
27
+ /**
28
+ * Learn a `null` answer as `NONE` only when the text's zero-shot `similarity` to every label is below this. LLMs also
29
+ * answer `null` for ambiguous texts that do belong to a label, and those sit close to the labels. Default 0.6, set
30
+ * for bge-small; `0` turns the check off. With Llama 3.1 8B on 12 BANKING77 labels it cut sends (20.7% to 19.1%) and
31
+ * raised in-scope answers right locally (81.3% to 83.3%) against learning every `null` (eval/real-stream.ts).
32
+ */
33
+ noneBelow?: number;
34
+ /** Retrain on its own after this many new answers. Default: only when you call `retrain()`. */
35
+ retrainEvery?: number;
36
+ /**
37
+ * Passed to `train()` when retraining. Starting from labels, `calibrateOn` defaults to the texts zero-shot answered
38
+ * locally (up to 4 per label, or 50 divided by the number of labels if that is more, once there are 50): they fit the head's temperature better than the LLM's answers,
39
+ * which are sometimes wrong. Pass your own `calibrateOn` to replace them.
40
+ */
41
+ trainOptions?: Omit<TrainOptions<L>, "labels">;
42
+ /**
43
+ * What an earlier session learned, from `route.saved()`, so a reload keeps it. Restoring doesn't retrain: call
44
+ * `retrain()` or pass the saved head as `using`.
45
+ */
46
+ restore?: {
47
+ learned?: readonly Example<L>[];
48
+ confident?: readonly Example<L>[];
49
+ };
50
+ }
51
+ /** A decision plus where it came from. `confidence` and `probabilities` are always the local decision's. */
52
+ export interface RoutedDecision<L extends string> extends Decision<L, L | null> {
53
+ source: "local" | "fallback";
54
+ }
55
+ /** The label `withFallback()` learns `ask`'s `null` answers as. It shows in `route.head.labels` and `route.learned`. */
56
+ export declare const NONE = "none_of_these";
57
+ /**
58
+ * Routes each text to the local decision, or to `ask` when its confidence is below `minConfidence`.
59
+ *
60
+ * - With a trained head and its `examples`, answers from `ask` are kept as new examples.
61
+ * - With labels (`["refund", "bug"]` or described labels), it starts zero-shot and always learns: `retrain()` (or
62
+ * `retrainEvery`) trains the first head from the answers. Labels with no answers yet start from their names.
63
+ */
64
+ export declare function withFallback<const L extends string>(decider: Trainable, using: Choices<L>, options: FallbackOptions<L>): ((text: string) => Promise<RoutedDecision<L>>) & {
65
+ retrain: () => Promise<Head<L>>;
66
+ /** The trained head in use, or `null` while still zero-shot. Replaced after each retrain. */
67
+ readonly head: Head<L> | null;
68
+ /** Answers from `ask` kept as examples since the start; `null` answers appear with the label `NONE`. */
69
+ readonly learned: readonly Example<L>[];
70
+ /** Everything to pass back as `restore` after a reload: the learned answers and the calibration set. Plain JSON. */
71
+ saved(): {
72
+ learned: Example<L>[];
73
+ confident: Example<L>[];
74
+ };
75
+ };