decidekit 0.0.0-stage → 0.1.0
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 +56 -0
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/dist/browser.d.ts +25 -0
- package/dist/browser.js +43 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +114 -0
- package/dist/describe.d.ts +14 -0
- package/dist/describe.js +30 -0
- package/dist/fallback.d.ts +75 -0
- package/dist/fallback.js +68 -0
- package/dist/head-io.d.ts +4 -0
- package/dist/head-io.js +16 -0
- package/dist/head.d.ts +14 -0
- package/dist/head.js +155 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +137 -0
- package/dist/react.d.ts +39 -0
- package/dist/react.js +55 -0
- package/dist/switch.d.ts +103 -0
- package/dist/switch.js +94 -0
- package/dist/typesafe.d.ts +36 -0
- package/dist/typesafe.js +47 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +26 -0
- package/package.json +93 -6
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: `decidekit train` and `decidekit 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,254 @@
|
|
|
1
|
-
#
|
|
1
|
+
# decidekit
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/decidekit) [](https://github.com/debuffjp/decidekit/actions/workflows/ci.yml) [](LICENSE) [](https://discord.gg/jshbDHSpCY)
|
|
4
|
+
|
|
5
|
+
**[Try it in your browser](https://debuff.jp/decidekit/playground/)** · [Watch it learn from an LLM](https://debuff.jp/decidekit/lanes/) · [Launch post](https://debuff.jp/blog/introducing-decidekit) · [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; decidekit can ask an LLM about them and learn from the answers. decidekit is an independent project, not affiliated with TypeSafe.
|
|
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 decidekit 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 `decidekit`: 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
|
+
## Models
|
|
2
150
|
|
|
3
|
-
|
|
151
|
+
decidekit runs the Decide models, each a sentence encoder plus a calibrated head:
|
|
152
|
+
|
|
153
|
+
| Model | Encoder | Download | Reads |
|
|
154
|
+
|---|---|---|---|
|
|
155
|
+
| Decide Small (default) | `Xenova/bge-small-en-v1.5` | 34 MB | English |
|
|
156
|
+
| Decide Multilingual | `Xenova/multilingual-e5-small` | 118 MB | 76% on Japanese and Chinese intents |
|
|
157
|
+
|
|
158
|
+
Pass the encoder id as `model` to `createDecider()`.
|
|
159
|
+
|
|
160
|
+
## Numbers
|
|
161
|
+
|
|
162
|
+
bge-small-en-v1.5 (the default) at q8, Ryzen 7 260 laptop, Node 24 and headless Chrome 156, 2026-11-01. Accuracy
|
|
163
|
+
and calibration error (ECE, lower is better) on each dataset's test split, mean of 3 seeds (one run for "all"):
|
|
164
|
+
[eval/results-bge-small.json](eval/results-bge-small.json).
|
|
165
|
+
|
|
166
|
+
| Examples per label | 5 | 10 | 25 | all |
|
|
167
|
+
|---|---|---|---|---|
|
|
168
|
+
| BANKING77 (77 labels) | 82.3% | 83.8% | 88.7% | 90.7% |
|
|
169
|
+
| CLINC150 (150 labels) | 89.6% | 92.0% | 93.4% | 94.4% |
|
|
170
|
+
| ECE, BANKING77 | 0.071 | 0.029 | 0.011 | 0.017 |
|
|
171
|
+
| ECE, CLINC150 | 0.028 | 0.010 | 0.015 | 0.008 |
|
|
172
|
+
|
|
173
|
+
Pre-release builds defaulted to all-MiniLM-L6-v2 (23.7 MB, 2026-10-31, [eval/results.json](eval/results.json)):
|
|
174
|
+
|
|
175
|
+
| Examples per label | 5 | 10 | 25 | all |
|
|
176
|
+
|---|---|---|---|---|
|
|
177
|
+
| BANKING77 (77 labels) | 78.7% | 83.0% | 87.4% | 90.6% |
|
|
178
|
+
| CLINC150 (150 labels) | 87.3% | 89.8% | 92.8% | 94.0% |
|
|
179
|
+
| ECE, BANKING77 | 0.065 | 0.048 | 0.025 | 0.019 |
|
|
180
|
+
| ECE, CLINC150 | 0.029 | 0.013 | 0.012 | 0.012 |
|
|
181
|
+
|
|
182
|
+
Calibration is loosest at 5 examples per label on BANKING77. On small tasks (2 to 10 labels sampled from CLINC150, scored on its validation
|
|
183
|
+
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
|
|
184
|
+
3 per label 98% with 0.016. Add examples before relying on
|
|
185
|
+
a threshold there.
|
|
186
|
+
|
|
187
|
+
**In Chrome** (BANKING77, full training set, worker, cross-origin isolated, 16 threads): 7.7 ms median and 13.8 ms
|
|
188
|
+
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
|
|
189
|
+
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
|
|
190
|
+
and 0.7). Earlier MiniLM run: [bench/results.json](bench/results.json).
|
|
191
|
+
|
|
192
|
+
**Text that fits no label:** CLINC150 has 1,000 test queries that match none of its 150 intents. Trained on
|
|
193
|
+
in-scope examples only, with `minConfidence` 0.93 picked on the validation split, the shipped bge-small head returns
|
|
194
|
+
`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
|
|
195
|
+
(94.0% right with no threshold).
|
|
196
|
+
|
|
197
|
+
**The smaller model.** `createDecider({ model: "Xenova/all-MiniLM-L6-v2" })` (23.7 MB, the pre-release default) is
|
|
198
|
+
about 1.8x faster per decision in Chrome and 3 to 5 points less accurate with few examples, about the same with all
|
|
199
|
+
the data. Pass it to keep using heads trained on it. Before this default changed, the README suggested
|
|
200
|
+
`{ labelNames: false }` with bge-small: label names slightly hurt it on the dev splits.
|
|
201
|
+
|
|
202
|
+
Reproduce: `npm run eval:suite` (Node), `npm run bench` then `npm run bench:run` (Chrome).
|
|
203
|
+
|
|
204
|
+
## Playground
|
|
205
|
+
|
|
206
|
+
Try it at [debuff.jp/decidekit](https://debuff.jp/decidekit/). To run it locally: `npm run playground`, then serve `playground/dist` cross-origin isolated (`node bench/serve.mjs` style headers).
|
|
207
|
+
Try zero-shot labels, the ready-made heads, a head trained in the page and "none of these".
|
|
208
|
+
[Two lanes](https://debuff.jp/decidekit/lanes/) shows `withFallback` live: decidekit starts from twelve label names,
|
|
209
|
+
asks a small LLM when it's unsure, learns from each answer, and replays the first 20 messages at the end.
|
|
210
|
+
|
|
211
|
+
## Ready-made heads
|
|
212
|
+
|
|
213
|
+
[heads/](heads/) has a BANKING77 support-intent head (90.6%) and a CLINC150 assistant-intent head (95.0%), so you
|
|
214
|
+
can try a trained head before collecting examples.
|
|
215
|
+
|
|
216
|
+
## What it's for
|
|
217
|
+
|
|
218
|
+
Routing support messages, triaging feedback, search and command intent, moderation hints while someone types,
|
|
219
|
+
labelling a dataset in the browser, a cheap first pass before an LLM that learns from it, games, extensions
|
|
220
|
+
and offline apps, and text that can't leave the device. Details, code and poor fits:
|
|
221
|
+
[docs/use-cases.md](docs/use-cases.md). Runnable examples are in [examples/](examples/).
|
|
222
|
+
|
|
223
|
+
## Compared with
|
|
224
|
+
|
|
225
|
+
| | Runs in the browser | Trains in the browser | Typed result | Calibrated confidence |
|
|
226
|
+
|---|---|---|---|---|
|
|
227
|
+
| decidekit | yes (worker) | yes | yes | trained heads |
|
|
228
|
+
| [jimothy](https://www.npmjs.com/package/jimothy) | inference (worker) | no, Node | yes | yes |
|
|
229
|
+
| [Jev](https://typesafe.ai/) (TypeSafe) | no, API | no | yes | yes |
|
|
230
|
+
| transformers.js zero-shot pipeline | yes | no | no | no |
|
|
231
|
+
| MediaPipe text classifier | yes | no, offline | no | no |
|
|
232
|
+
| LLM structured output (AI SDK `Output.choice`) | no, API | n/a | yes | no |
|
|
233
|
+
|
|
234
|
+
jimothy reports 92.4% on BANKING77, above our 90.7%. Pick it if you train in Node and want the higher number. Pick
|
|
235
|
+
decidekit if training has to happen in the page, from the user's own data.
|
|
236
|
+
|
|
237
|
+
[system-one](https://github.com/lukeramsden/system-one) is a different kind of tool: a typed client for hosted System One models (Jev and Laya). It runs no model itself.
|
|
238
|
+
|
|
239
|
+
## Docs
|
|
240
|
+
|
|
241
|
+
[docs/README.md](docs/README.md) indexes the guides (training, confidence and thresholds, the browser), the
|
|
242
|
+
API reference, how it works and the FAQ.
|
|
243
|
+
|
|
244
|
+
## Community
|
|
245
|
+
|
|
246
|
+
Questions and show-and-tell in [Discussions](https://github.com/debuffjp/decidekit/discussions), chat in the
|
|
247
|
+
[debuff Discord](https://discord.gg/jshbDHSpCY). Results on your own data, good or bad, help most: there's an
|
|
248
|
+
issue template for them. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
249
|
+
|
|
250
|
+
## Status
|
|
251
|
+
|
|
252
|
+
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 and fine-tuning the encoder (the likely route past about 91%).
|
|
253
|
+
|
|
254
|
+
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
|
+
}>;
|
package/dist/browser.js
ADDED
|
@@ -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("decidekit: 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>>;
|
package/dist/describe.js
ADDED
|
@@ -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
|
+
};
|