tinydecide 0.1.0 → 0.1.2
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/README.md +21 -17
- package/bin/tinydecide.js +5 -5
- package/package.json +2 -2
- package/src/compile.js +16 -15
- package/src/features.js +2 -2
- package/src/log.js +1 -2
- package/src/node.js +75 -20
- package/src/shadow.js +24 -21
- package/src/stats.js +17 -6
- package/src/train.js +9 -6
- package/types/index.d.ts +13 -11
package/README.md
CHANGED
|
@@ -16,21 +16,21 @@ const classify = shadow('support', (text) => api.classify(text));
|
|
|
16
16
|
await classify('my card was charged twice'); // the same answer your API gives
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
On day one every call still goes to your API. After 2,000 answers TinyDecide compiles a small local model and certifies how far to trust it. From then on, calls it is sure about are answered in about 10 µs, and the rest still go to your API.
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
npx tinydecide try # 15 seconds, no API key:
|
|
22
|
+
npx tinydecide try # 15 seconds, no API key: TinyDecide learns a simulated API, which then changes
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
Wrapping an LLM classifier is the common case. [`examples/llm-classifier.js`](examples/llm-classifier.js) does it for any OpenAI-compatible endpoint, defaulting to an open-weights model.
|
|
26
26
|
|
|
27
27
|
## What you can count on
|
|
28
28
|
|
|
29
|
-
1. **Your API stays the reference.**
|
|
29
|
+
1. **Your API stays the reference.** Across all calls, the answers you get agree with your API at least 98% of the time, counting your API's own answers as agreeing. This is certified at 95% confidence on traffic like the traffic it learned from. You set the target. With `compile: { guarantee: 'local' }`, the local answers alone meet it too.
|
|
30
30
|
2. **A call never fails because of TinyDecide.** If anything inside it throws (the model, the disk, your custom hooks), the call goes to your API.
|
|
31
31
|
3. **API changes are caught.** A small random sample of local answers is re-checked against your API, and calls your API answers anyway are checked for free. When your API's answers move, every call goes back to it until a model trained on the new answers certifies. Replaying 221,636 real recorded calls produced no false alarms.
|
|
32
32
|
4. **Restarts resume.** In Node, a named decision keeps its answers, model and state in `.tinydecide/<name>/`.
|
|
33
|
-
5. **
|
|
33
|
+
5. **Training runs off the main thread.** Compiles train in a worker thread. Two steps still pause the main thread: reading the answer log at the start of each compile, and trimming the log once every 25,000 answers. On an M3 Pro each takes about 45 ms with 100,000 short answers, and about 0.6 s when inputs are 2,000 characters long.
|
|
34
34
|
6. **It says when it is not worth it.** Some decisions are beyond a word-level model. TinyDecide tells you and keeps calling your API.
|
|
35
35
|
|
|
36
36
|
No dependencies. Node 22+, Bun, browsers, workers and edge runtimes; 12 KB gzipped in a browser bundle.
|
|
@@ -60,9 +60,9 @@ It recompiles each time the answers double, and at least every 20,000 answers. I
|
|
|
60
60
|
- a string: `'billing'`
|
|
61
61
|
- an object with `label`, `choice`, `decision` or `answer`, plus optional `probs` (a label → probability map, or an array in `labels` order). Probabilities help: the model learns from how sure your API was.
|
|
62
62
|
|
|
63
|
-
Local answers come back in the same shape: a string for a string, or an object with the label under the same key, plus `probs` and `confidence` if your API returns them. Other fields of your API's response are not reproduced. For anything else, pass `choice: (response) => ({ choice, probs })
|
|
63
|
+
Local answers come back in the same shape: a string for a string, or an object with the label under the same key, plus `probs` and `confidence` if your API returns them. Other fields of your API's response are not reproduced. For anything else, pass `choice: (response) => label` (or `{ choice, probs }`) to read it and `respond: (decision) => …` to build local answers.
|
|
64
64
|
|
|
65
|
-
The input can be a string, `{ a, b }` for a pair, or an object with
|
|
65
|
+
The input can be a string, `{ a, b }` for a pair, or an object with a string `state`, `text`, `input`, `content` or `message`. The model reads the first of those it finds, or every field when there is none. The whole input is logged with the answer.
|
|
66
66
|
|
|
67
67
|
## Options
|
|
68
68
|
|
|
@@ -81,17 +81,17 @@ shadow('support', api, {
|
|
|
81
81
|
| `delta` | `0.05` | 1 − confidence of the certificate |
|
|
82
82
|
| `audit` | `0.02` | Share of calls your API also answers in the background, to check agreement live |
|
|
83
83
|
| `group` | none | Keeps related calls in one split, so the certificate covers new conversations, not just new messages |
|
|
84
|
-
| `labels` | inferred | Fixes the label set and order. Needed when your API returns
|
|
84
|
+
| `labels` | inferred | Fixes the label set and order. Needed when your API returns probability arrays; also names index labels |
|
|
85
85
|
| `fallback` | `false` | Answer locally when your API throws |
|
|
86
86
|
| `log` | `true` | One line per lifecycle event. `false`, or a function to route it |
|
|
87
|
-
| `onEvent` | none | Every event as an object: `start`, `compile`, `install`, `guard`, `discard`, `audit`, `unreadable`, `error` |
|
|
87
|
+
| `onEvent` | none | Every event as an object: `start`, `compile:start`, `compile`, `install`, `guard`, `discard`, `audit`, `unreadable`, `error` |
|
|
88
88
|
| `dir` | `.tinydecide` | Node: where named decisions live. Also `$TINYDECIDE_DIR` |
|
|
89
89
|
| `store` | file in Node, memory elsewhere | Where answers, the model and its state live (see below) |
|
|
90
90
|
| `model` | none | Start from a compiled model |
|
|
91
91
|
| `autoCompile` | `{ min: 2000, growth: 2, maxEvery: 20000, window: 50000 }` | `false` compiles only on `decide.compile()` |
|
|
92
92
|
| `choice`, `respond` | built in | Read custom responses, build custom local answers |
|
|
93
93
|
|
|
94
|
-
Misspelled
|
|
94
|
+
Misspelled options, and out-of-range `target`, `delta` or `audit`, throw at startup with a message that says what to write instead.
|
|
95
95
|
|
|
96
96
|
The function you get back also has:
|
|
97
97
|
|
|
@@ -109,18 +109,19 @@ In Node, `shadow('support', api)` keeps one folder:
|
|
|
109
109
|
.tinydecide/
|
|
110
110
|
.gitignore written once: the folder holds raw inputs, so git ignores it
|
|
111
111
|
support/
|
|
112
|
-
answers.jsonl your API's recent answers,
|
|
112
|
+
answers.jsonl your API's recent answers, trimmed to the newest 100,000
|
|
113
113
|
model.tdm the installed model, and report.json, its compile report
|
|
114
114
|
state.json only while guarded, so a restart stays guarded
|
|
115
|
+
lock the pid of the process using the folder
|
|
115
116
|
```
|
|
116
117
|
|
|
117
|
-
Model and state writes are atomic, and a line cut off by a crash is skipped.
|
|
118
|
+
Model and state writes are atomic, and a line cut off by a crash is skipped. The lock keeps each folder to one process. If the folder can't be opened (it is read-only, or another process holds it), answers stay in memory and one line is logged.
|
|
118
119
|
|
|
119
|
-
To keep answers elsewhere, pass a `store`: an object with `append(record)
|
|
120
|
+
To keep answers elsewhere, pass a `store`: an object with `append(record)` and `load()`, plus optional `size`, `loadModel`, `saveModel`, `loadState`, `saveState` and `close` (any of them may be async). `memoryStore()` and `fileStore(dir)` are the built-in ones.
|
|
120
121
|
|
|
121
122
|
## Deploying
|
|
122
123
|
|
|
123
|
-
**One long-running server.** Nothing to do. Answers and models persist in `.tinydecide/`.
|
|
124
|
+
**One long-running server process.** Nothing to do. Answers and models persist in `.tinydecide/`. If a process manager runs several copies (`pm2 -i`, Node cluster), the first one keeps the folder and the others keep their answers in memory.
|
|
124
125
|
|
|
125
126
|
**Several instances, or serverless.** Each instance can learn on its own, but they learn faster together. Send answers to one place with a custom `store`, compile on a schedule, and ship the model:
|
|
126
127
|
|
|
@@ -198,7 +199,7 @@ Both are in [`bench/results/guard-2026-10-24.md`](bench/results/guard-2026-10-24
|
|
|
198
199
|
## Command line
|
|
199
200
|
|
|
200
201
|
```
|
|
201
|
-
tinydecide try
|
|
202
|
+
tinydecide try a 15 s simulation: learn a simulated API, which then changes
|
|
202
203
|
tinydecide status [dir] what each decision is doing
|
|
203
204
|
tinydecide compile <answers> compile from a .jsonl log or a decision folder
|
|
204
205
|
tinydecide inspect <model.tdm> labels and certificate
|
|
@@ -226,7 +227,7 @@ Answer logs are one JSON object per line: `{"input": "…", "choice": "billing"}
|
|
|
226
227
|
|
|
227
228
|
- **Features.** Text is normalized (NFKC, lowercase). Features are word unigrams and bigrams, number shapes, and character 2–4 grams within words, hashed to 32 bits. Han and kana characters are their own tokens. Pairs get side-aware and overlap features.
|
|
228
229
|
- **Model.** Words and characters are pooled separately and summed into a linear head, fastText-style, trained with SGD on your API's soft labels and stored as int8 with one scale per row.
|
|
229
|
-
- **Cutoff.** The score is the margin between the top two logits. A
|
|
230
|
+
- **Cutoff.** The score is the margin between the top two logits. A fitted temperature makes the reported confidence match how often your API agrees. It does not change the cutoff.
|
|
230
231
|
- **Size and speed.** 0.2–6 MB per decision, most under 3 MB. On an M3 Pro, a call takes 8–30 µs for short texts, 45–70 µs for pairs, and about 200 µs for news articles a few thousand characters long.
|
|
231
232
|
|
|
232
233
|
## Limits
|
|
@@ -235,17 +236,20 @@ Answer logs are one JSON object per line: `{"input": "…", "choice": "billing"}
|
|
|
235
236
|
2. **Detection takes calls.** A change confined to one label of ten was caught after a median of about 1,200 calls. A higher `audit` rate catches changes sooner and saves less.
|
|
236
237
|
3. **The answer log holds raw inputs.** Keep `.tinydecide/` private, redact inputs before they reach `shadow`, or use your own `store`.
|
|
237
238
|
4. **One input, one label.** Multi-label and free-text outputs are out of scope.
|
|
239
|
+
5. **Long inputs need memory to compile.** Features take about 22 KB per training row at 2,000 characters, about 0.7 GB at the default 50,000-answer window. Lower `autoCompile.window` for long texts on small machines.
|
|
238
240
|
|
|
239
241
|
## Development
|
|
240
242
|
|
|
241
243
|
```sh
|
|
242
|
-
npm test #
|
|
244
|
+
npm test # 49 tests, about 15 s
|
|
243
245
|
npm run test:bun # the same tests under Bun
|
|
244
246
|
npm run typecheck # the public types, with tsc
|
|
245
247
|
npm run bench:guard # how fast the guard catches an API change, over 10 runs
|
|
246
248
|
```
|
|
247
249
|
|
|
248
|
-
|
|
250
|
+
Issues and pull requests are welcome; please run the tests first. Security reports go through [SECURITY.md](SECURITY.md).
|
|
251
|
+
|
|
252
|
+
To release: bump `version` in package.json, add a CHANGELOG entry, and push a `vX.Y.Z` tag. CI runs the tests and stages the version on npm, and it goes public once a maintainer approves it with 2FA.
|
|
249
253
|
|
|
250
254
|
## License
|
|
251
255
|
|
package/bin/tinydecide.js
CHANGED
|
@@ -8,7 +8,7 @@ import { compile, loadModel, memoryStore, readJsonl, saveModel, shadow } from '.
|
|
|
8
8
|
const HELP = `tinydecide: wrap your decision API; answer the calls it can locally, with agreement certified
|
|
9
9
|
|
|
10
10
|
usage
|
|
11
|
-
tinydecide try
|
|
11
|
+
tinydecide try a 15 s simulation, no keys: learn a simulated API, which then changes
|
|
12
12
|
tinydecide status [dir] what each decision in .tinydecide/ is doing
|
|
13
13
|
tinydecide compile <answers> compile a model from a .jsonl log, or a decision folder
|
|
14
14
|
[--out model.tdm] [--target 0.98] [--delta 0.05] [--guarantee overall|local]
|
|
@@ -53,7 +53,7 @@ async function cmdTry(f) {
|
|
|
53
53
|
const sim = supportApi({ seed: +(f.seed ?? 1) });
|
|
54
54
|
const out = process.stdout;
|
|
55
55
|
const width = Math.min(out.columns || 100, 110);
|
|
56
|
-
// before the change · between the change and the guard · after
|
|
56
|
+
// before the change · between the change and the guard · after the guard
|
|
57
57
|
const phase = [{ local: 0, right: 0, calls: 0 }, { local: 0, right: 0, calls: 0 }, { local: 0, right: 0, calls: 0 }];
|
|
58
58
|
let i = 0, p = 0, status = '', last = null;
|
|
59
59
|
const recent = new Uint8Array(500), agree = new Uint8Array(500);
|
|
@@ -79,7 +79,7 @@ async function cmdTry(f) {
|
|
|
79
79
|
const say = (line) => { clear(); out.write(`${dim(int(i).padStart(8))} ${wrap(line, 10)}\n`); draw(); };
|
|
80
80
|
|
|
81
81
|
out.write(`\n ${bold('tinydecide try')}\n\n`);
|
|
82
|
-
out.write(` ${wrap(`A simulated support-ticket classifier stands in for your API: ${sim.labels.length} labels, ${API_MS} ms and $0.40 per 1,000 calls (assumed). TinyDecide wraps it
|
|
82
|
+
out.write(` ${wrap(`A simulated support-ticket classifier stands in for your API: ${sim.labels.length} labels, ${API_MS} ms and $0.40 per 1,000 calls (assumed). TinyDecide wraps it. Partway through, the API changes how it labels some messages.`, 2)}\n\n`);
|
|
83
83
|
|
|
84
84
|
const decide = shadow('support', sim.api, {
|
|
85
85
|
store: memoryStore(),
|
|
@@ -119,7 +119,7 @@ async function cmdTry(f) {
|
|
|
119
119
|
// Overall agreement, as certified: your API's own answers count as agreeing.
|
|
120
120
|
const rate = (x) => (x.calls ? pct(1 - (x.local - x.right) / x.calls) : '—');
|
|
121
121
|
out.write(`\n ${bold('Done.')} ${int(CALLS)} calls, ${int(local)} answered locally ${dim(`(${pct(local / CALLS, 0)})`)}.\n\n`);
|
|
122
|
-
row('agreement', `${rate(phase[0])} before the change, ${rate(phase[2])} after
|
|
122
|
+
row('agreement', `${rate(phase[0])} before the change, ${rate(phase[2])} after the guard ${dim('(overall, as certified; checked on every call, which only a simulation can do)')}`);
|
|
123
123
|
if (p === 2) row('the change', `caught after ${int(phase[1].calls)} calls; ${int(phase[1].local - phase[1].right)} local answers were wrong in that window`);
|
|
124
124
|
row('API calls saved', `${int(s.apiCallsSaved)} ${dim(`$${(s.apiCallsSaved * PRICE).toFixed(2)} and ${(s.apiCallsSaved * API_MS / 60_000).toFixed(0)} min of waiting, at the assumed price and latency`)}`);
|
|
125
125
|
if (last) row('time per answer', `${last.latencyUs} µs locally, vs ${API_MS} ms from your API`);
|
|
@@ -245,7 +245,7 @@ try {
|
|
|
245
245
|
else if (cmd === 'compile') await cmdCompile(rest[0], flags);
|
|
246
246
|
else if (cmd === 'inspect') cmdInspect(rest[0]);
|
|
247
247
|
else if (cmd === 'decide') await cmdDecide(rest[0], rest.slice(1));
|
|
248
|
-
else if (!cmd || cmd === 'help' ||
|
|
248
|
+
else if (!cmd || cmd === 'help' || cmd === '-h' || flags.help) console.log(HELP);
|
|
249
249
|
else throw new Error(`unknown command "${cmd}". Run tinydecide help.`);
|
|
250
250
|
} catch (err) {
|
|
251
251
|
console.error(`tinydecide: ${err.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tinydecide",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Wrap your decision API. TinyDecide learns from its answers and takes over the calls it can answer locally, in microseconds, with agreement certified.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"classification",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"scripts": {
|
|
52
52
|
"test": "node --test \"test/*.test.js\"",
|
|
53
53
|
"test:bun": "bun test ./test/",
|
|
54
|
-
"typecheck": "tsc -p test/tsconfig.json",
|
|
54
|
+
"typecheck": "npx -y -p typescript@5.9.3 tsc -p test/tsconfig.json",
|
|
55
55
|
"bench:guard": "node bench/guard-drift.js change 10",
|
|
56
56
|
"prepublishOnly": "npm test"
|
|
57
57
|
}
|
package/src/compile.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// compile(): your API's answers
|
|
1
|
+
// compile(): trains a local model on your API's answers and certifies a cutoff.
|
|
2
2
|
//
|
|
3
3
|
// 1. Split rows into train / calib / test by group (or by normalized text), so repeats never leak.
|
|
4
4
|
// 2. Train on train, quantize to int8. Every later number is measured on the int8 model you ship.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { Featurizer, inputKey } from './features.js';
|
|
10
10
|
import { hashString } from './hash.js';
|
|
11
11
|
import { Model, quantize, softmax, topTwo } from './model.js';
|
|
12
|
-
import { certify, minCalibration, wilson } from './stats.js';
|
|
12
|
+
import { certify, localStart, minCalibration, wilson } from './stats.js';
|
|
13
13
|
import { RECIPE, abortError, tick, train } from './train.js';
|
|
14
14
|
|
|
15
15
|
export const DEFAULTS = Object.freeze({ target: 0.98, delta: 0.05, guarantee: 'overall', calib: 0.25, test: 0.1, seed: 1, audit: 0.02 });
|
|
@@ -17,17 +17,20 @@ export const DEFAULTS = Object.freeze({ target: 0.98, delta: 0.05, guarantee: 'o
|
|
|
17
17
|
// Cutoffs on the logit margin. Fixed in advance, as Learn-Then-Test requires.
|
|
18
18
|
const GRID = [0, ...Array.from({ length: 600 }, (_, i) => 0.01 * 6000 ** (i / 599))];
|
|
19
19
|
const WHAT_IF = [0.9, 0.95, 0.97, 0.98, 0.99, 0.995];
|
|
20
|
-
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
|
-
* @param {Iterable<object>} records { input, choice?, probs?, group? }
|
|
22
|
+
* @param {Iterable<object>} records { input, choice?, probs?, group? }; see README for accepted shapes
|
|
24
23
|
* @param {object} [opts]
|
|
25
24
|
* @returns {Promise<{ model: Model, report: object }>}
|
|
26
25
|
*/
|
|
27
26
|
export async function compile(records, opts = {}) {
|
|
28
27
|
const o = { ...DEFAULTS, ...opts };
|
|
28
|
+
for (const k of ['target', 'delta']) {
|
|
29
|
+
if (!(typeof o[k] === 'number' && o[k] > 0 && o[k] < 1)) throw new RangeError(`${k} must be a number between 0 and 1; got ${o[k]}`);
|
|
30
|
+
}
|
|
31
|
+
if (o.guarantee !== 'overall' && o.guarantee !== 'local') throw new RangeError(`guarantee must be "overall" or "local"; got ${o.guarantee}`);
|
|
29
32
|
const alpha = 1 - o.target;
|
|
30
|
-
const t0 = now();
|
|
33
|
+
const t0 = performance.now();
|
|
31
34
|
const onProgress = o.onProgress ?? (() => {});
|
|
32
35
|
const pause = async () => { await tick(); if (o.signal?.aborted) throw abortError(); };
|
|
33
36
|
|
|
@@ -53,7 +56,7 @@ export async function compile(records, opts = {}) {
|
|
|
53
56
|
if (trainRows.length % 2000 === 0) await pause();
|
|
54
57
|
}
|
|
55
58
|
const params = await train(trainRows, K, { ...pick(o, Object.keys(RECIPE)), seed: o.seed, onProgress, yieldMs: o.yieldMs, signal: o.signal });
|
|
56
|
-
const trainMs = now() - t0;
|
|
59
|
+
const trainMs = performance.now() - t0;
|
|
57
60
|
|
|
58
61
|
await pause();
|
|
59
62
|
const draft = quantize({ labels, ...params });
|
|
@@ -73,10 +76,8 @@ export async function compile(records, opts = {}) {
|
|
|
73
76
|
};
|
|
74
77
|
const calShare = cal.n ? cert.local / cal.n : 0;
|
|
75
78
|
const calLocalAgree = cert.local ? 1 - cert.errors / cert.local : null;
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// Per label the model leans toward, how its confidence on deferred calls compares with how often
|
|
79
|
-
// the API actually agreed: shadow() expects that ratio live. Shrunk toward 1 when rows are few.
|
|
79
|
+
// Agreement on deferred calibration rows, overall and per guessed label (as a ratio to the model's
|
|
80
|
+
// confidence, shrunk toward 1 when rows are few). shadow() checks both live.
|
|
80
81
|
let dn = 0, dAgree = 0;
|
|
81
82
|
const seen = new Float64Array(K), expected = new Float64Array(K);
|
|
82
83
|
for (let i = 0; i < cal.n; i++) {
|
|
@@ -101,7 +102,7 @@ export async function compile(records, opts = {}) {
|
|
|
101
102
|
const latencyUs = timeDecide(model, (split.test.length ? split.test : split.calib).slice(0, 400));
|
|
102
103
|
|
|
103
104
|
const share = test ? test.share : calShare;
|
|
104
|
-
const minCal = minCalibration(alpha, o.delta);
|
|
105
|
+
const minCal = o.guarantee === 'local' ? localStart(alpha, o.delta) : minCalibration(alpha, o.delta);
|
|
105
106
|
const verdict =
|
|
106
107
|
cal.n < minCal ? 'need-more-data' : calShare >= 0.25 ? 'ship' : calShare >= 0.05 ? 'marginal' : 'not-worth-it';
|
|
107
108
|
const report = {
|
|
@@ -118,7 +119,7 @@ export async function compile(records, opts = {}) {
|
|
|
118
119
|
temperature: T,
|
|
119
120
|
bytes: model.toBytes().byteLength,
|
|
120
121
|
latencyUs,
|
|
121
|
-
ms: { train: Math.round(trainMs), total: Math.round(now() - t0) },
|
|
122
|
+
ms: { train: Math.round(trainMs), total: Math.round(performance.now() - t0) },
|
|
122
123
|
};
|
|
123
124
|
model.meta.verdict = verdict;
|
|
124
125
|
onProgress({ phase: 'done', done: 1 });
|
|
@@ -240,10 +241,10 @@ function evaluate(model, rows, params) {
|
|
|
240
241
|
function timeDecide(model, rows) {
|
|
241
242
|
if (!rows.length) return null;
|
|
242
243
|
for (const r of rows) model.decide(r.input);
|
|
243
|
-
const t = now();
|
|
244
|
+
const t = performance.now();
|
|
244
245
|
let reps = 0;
|
|
245
|
-
while (now() - t < 50) { for (const r of rows) model.decide(r.input); reps++; }
|
|
246
|
-
return Math.round(((now() - t) * 1000) / (reps * rows.length) * 10) / 10;
|
|
246
|
+
while (performance.now() - t < 50) { for (const r of rows) model.decide(r.input); reps++; }
|
|
247
|
+
return Math.round(((performance.now() - t) * 1000) / (reps * rows.length) * 10) / 10;
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
function summarize({ verdict, o, calShare, cal, minCal, whatIf }) {
|
package/src/features.js
CHANGED
|
@@ -93,12 +93,12 @@ export class Featurizer {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
#side(toks, salt) {
|
|
96
|
-
const uni = new Set();
|
|
96
|
+
const uni = salt ? new Set() : null; // only pairs compare their sides
|
|
97
97
|
let prev = 0;
|
|
98
98
|
for (let i = 0; i < toks.length; i++) {
|
|
99
99
|
const t = toks[i];
|
|
100
100
|
const h = hashString(t, SEED.word);
|
|
101
|
-
uni
|
|
101
|
+
uni?.add(h);
|
|
102
102
|
this.w.push(salt ? fmix(h ^ salt) : h);
|
|
103
103
|
if (i > 0) this.w.push(fmix((Math.imul(prev, 0x9e3779b1) ^ h ^ SEED.bigram ^ salt) >>> 0));
|
|
104
104
|
prev = h;
|
package/src/log.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// One
|
|
2
|
-
// API, or recovers is visible in your logs. Nothing is logged per call.
|
|
1
|
+
// One log line per lifecycle event (start, compile, guard, error), never per call.
|
|
3
2
|
|
|
4
3
|
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
5
4
|
const int = (x) => x.toLocaleString('en-US');
|
package/src/node.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// The Node entry. Same API as the browser build, plus:
|
|
2
2
|
// - shadow('name', api) keeps answers, the model and its state in .tinydecide/name/, so a restart
|
|
3
3
|
// resumes where it left off
|
|
4
|
-
// - compiles
|
|
4
|
+
// - compiles train in a worker thread, off your server's event loop
|
|
5
5
|
|
|
6
6
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
7
|
-
import { dirname, join } from 'node:path';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { Worker } from 'node:worker_threads';
|
|
9
9
|
import { gunzipSync } from 'node:zlib';
|
|
10
10
|
import { compile } from './compile.js';
|
|
11
|
+
import { logger } from './log.js';
|
|
11
12
|
import { Model } from './model.js';
|
|
12
|
-
import { NAME, shadow as core } from './shadow.js';
|
|
13
|
+
import { NAME, shadow as core, validate } from './shadow.js';
|
|
14
|
+
import { memoryStore } from './store.js';
|
|
13
15
|
import { abortError } from './train.js';
|
|
14
16
|
|
|
15
17
|
export * from './index.js';
|
|
@@ -22,23 +24,36 @@ export function shadow(name, api, options) {
|
|
|
22
24
|
if (typeof name === 'function') return core(name, { compiler: compileInWorker, ...api });
|
|
23
25
|
const { dir = process.env.TINYDECIDE_DIR || '.tinydecide', ...rest } = options ?? {};
|
|
24
26
|
if (typeof name !== 'string' || !NAME.test(name)) return core(name, api, rest); // core explains the error
|
|
25
|
-
|
|
27
|
+
validate(name, api, rest); // before anything touches the disk
|
|
28
|
+
let store = rest.store;
|
|
29
|
+
if (!store) {
|
|
30
|
+
try {
|
|
31
|
+
store = fileStore(join(dir, name));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// A folder that can't be written, or that another process holds, must not stop the app.
|
|
34
|
+
store = memoryStore();
|
|
35
|
+
logger(name, rest.log ?? true)({ type: 'error', where: `opening ${join(dir, name)}`, error: `${err.message}. Answers stay in memory.` });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return core(name, api, { compiler: compileInWorker, ...rest, store });
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
/**
|
|
29
42
|
* Keeps one decision in a folder:
|
|
30
|
-
* answers.jsonl your API's recent answers (
|
|
43
|
+
* answers.jsonl your API's recent answers (trimmed to the newest `limit`)
|
|
31
44
|
* model.tdm the installed model, and report.json, its compile report
|
|
32
45
|
* state.json present only while guarded
|
|
33
|
-
*
|
|
46
|
+
* lock the pid of the process using the folder; a second store on it throws
|
|
47
|
+
* Writes of the model and state are atomic.
|
|
34
48
|
*/
|
|
35
49
|
export function fileStore(dir, { limit = 100_000 } = {}) {
|
|
36
50
|
const root = dirname(dir);
|
|
37
51
|
const fresh = !existsSync(root);
|
|
38
|
-
mkdirSync(dir, { recursive: true });
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
39
53
|
// The log holds raw inputs, so keep the whole folder out of git unless someone opts in.
|
|
40
54
|
if (fresh) safeWrite(join(root, '.gitignore'), '# created by tinydecide: answers.jsonl holds raw inputs\n*\n');
|
|
41
|
-
const path = { answers: join(dir, 'answers.jsonl'), model: join(dir, 'model.tdm'), report: join(dir, 'report.json'), state: join(dir, 'state.json') };
|
|
55
|
+
const path = { answers: join(dir, 'answers.jsonl'), model: join(dir, 'model.tdm'), report: join(dir, 'report.json'), state: join(dir, 'state.json'), lock: resolve(dir, 'lock') };
|
|
56
|
+
takeLock(path.lock);
|
|
42
57
|
let fd = -1, size = 0;
|
|
43
58
|
|
|
44
59
|
function open() {
|
|
@@ -49,22 +64,32 @@ export function fileStore(dir, { limit = 100_000 } = {}) {
|
|
|
49
64
|
// A crash mid-write can leave a partial last line; end it so the next answer starts clean.
|
|
50
65
|
if (buf.length && buf[buf.length - 1] !== 10) writeFileSync(path.answers, '\n', { flag: 'a' });
|
|
51
66
|
}
|
|
52
|
-
fd = openSync(path.answers, 'a');
|
|
67
|
+
fd = openSync(path.answers, 'a', 0o600);
|
|
53
68
|
}
|
|
54
69
|
open();
|
|
55
70
|
|
|
71
|
+
/** Keeps the newest `limit` lines. Scans bytes for newlines, so nothing is parsed. */
|
|
72
|
+
function trim() {
|
|
73
|
+
const buf = readFileSync(path.answers);
|
|
74
|
+
let at = buf.length - 1; // the newline that ends the last line
|
|
75
|
+
for (let k = 0; k < limit && at > 0; k++) at = buf.lastIndexOf(10, at - 1);
|
|
76
|
+
closeSync(fd); // first: Windows will not rename over a file that is still open
|
|
77
|
+
fd = -1;
|
|
78
|
+
try {
|
|
79
|
+
atomicWrite(path.answers, buf.subarray(at + 1));
|
|
80
|
+
} finally {
|
|
81
|
+
open();
|
|
82
|
+
size = Math.min(size, limit); // after a failure, wait another quarter of `limit` before retrying
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
return {
|
|
57
87
|
dir,
|
|
58
|
-
append(record) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (rows.length <= limit * 1.25) return rows.slice(-limit);
|
|
62
|
-
const keep = rows.slice(-limit);
|
|
63
|
-
closeSync(fd); // first: Windows will not rename over a file that is still open
|
|
64
|
-
atomicWrite(path.answers, keep.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
65
|
-
open();
|
|
66
|
-
return keep;
|
|
88
|
+
append(record) {
|
|
89
|
+
writeSync(fd, JSON.stringify(record) + '\n');
|
|
90
|
+
if (++size > limit * 1.25) trim();
|
|
67
91
|
},
|
|
92
|
+
load: () => readJsonl(path.answers).slice(-limit),
|
|
68
93
|
get size() { return size; },
|
|
69
94
|
loadModel: () => (existsSync(path.model) ? Model.fromBytes(readFileSync(path.model)) : null),
|
|
70
95
|
saveModel(model, report) {
|
|
@@ -79,10 +104,40 @@ export function fileStore(dir, { limit = 100_000 } = {}) {
|
|
|
79
104
|
if (state) atomicWrite(path.state, JSON.stringify(state) + '\n');
|
|
80
105
|
else rmSync(path.state, { force: true });
|
|
81
106
|
},
|
|
82
|
-
close() {
|
|
107
|
+
close() {
|
|
108
|
+
if (fd < 0) return;
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
fd = -1;
|
|
111
|
+
rmSync(path.lock, { force: true });
|
|
112
|
+
held.delete(path.lock);
|
|
113
|
+
},
|
|
83
114
|
};
|
|
84
115
|
}
|
|
85
116
|
|
|
117
|
+
const held = new Set(); // lock files this process holds
|
|
118
|
+
|
|
119
|
+
/** One process per folder: a second writer would lose answers when the log is trimmed. */
|
|
120
|
+
function takeLock(path) {
|
|
121
|
+
if (held.has(path)) throw new Error(`${dirname(path)} is already open in this process`);
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.code !== 'EEXIST') throw err;
|
|
126
|
+
const pid = Number(readFileSync(path, 'utf8'));
|
|
127
|
+
// Our own pid here means an earlier run left it, as when a container restarts with the same pid.
|
|
128
|
+
if (pid && pid !== process.pid && alive(pid)) {
|
|
129
|
+
throw new Error(`${dirname(path)} is in use by process ${pid}. Use one process per folder, or delete ${path} if that process is not using it`);
|
|
130
|
+
}
|
|
131
|
+
rmSync(path, { force: true }); // stale: the process that wrote it is gone
|
|
132
|
+
writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
133
|
+
}
|
|
134
|
+
held.add(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function alive(pid) {
|
|
138
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
let workerBroken = false;
|
|
87
142
|
|
|
88
143
|
/** compile() in a worker thread. Falls back to the calling thread if workers are unavailable. */
|
|
@@ -146,7 +201,7 @@ export function saveModel(path, model) {
|
|
|
146
201
|
|
|
147
202
|
function atomicWrite(path, data) {
|
|
148
203
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
149
|
-
writeFileSync(tmp, data);
|
|
204
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
150
205
|
renameSync(tmp, path);
|
|
151
206
|
}
|
|
152
207
|
|
package/src/shadow.js
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
// alarms over a model's life when agreement sits exactly at target, fewer above it
|
|
17
17
|
// deferred on deferred calls (answered by your API anyway, so free to check), the model's guess
|
|
18
18
|
// matches your API far less often than it did on calibration, overall or for one label
|
|
19
|
-
// against the model's own calibrated confidence
|
|
19
|
+
// against the model's own calibrated confidence. It usually trips before the audit check.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
21
|
+
// TinyDecide never makes a call fail that would have succeeded without it: if anything inside it
|
|
22
|
+
// throws, the call goes to your API.
|
|
23
23
|
|
|
24
24
|
import { compile } from './compile.js';
|
|
25
25
|
import { logger } from './log.js';
|
|
@@ -39,7 +39,6 @@ const LABEL_SLACK = 0.1; // room for the model's confidence being somewhat off o
|
|
|
39
39
|
const LABEL_KEYS = ['choice', 'label', 'decision', 'answer'];
|
|
40
40
|
const PROBS_KEYS = ['probs', 'probabilities'];
|
|
41
41
|
const OPTIONS = ['target', 'delta', 'audit', 'labels', 'model', 'store', 'choice', 'respond', 'group', 'fallback', 'log', 'onEvent', 'autoCompile', 'compile', 'compiler', 'random'];
|
|
42
|
-
const now = () => globalThis.performance?.now() ?? Date.now();
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
44
|
* @param {string} [name] names the decision in logs; in Node it also names its folder in .tinydecide/
|
|
@@ -56,6 +55,7 @@ export function shadow(name, api, options) {
|
|
|
56
55
|
onEvent: () => {}, compile: {}, compiler: compile, random: Math.random,
|
|
57
56
|
...options,
|
|
58
57
|
};
|
|
58
|
+
o.labels ??= o.model?.labels; // a model you pass in fixes the label order
|
|
59
59
|
const auto = o.autoCompile === false ? null : { min: 2000, growth: 2, maxEvery: 20_000, window: 50_000, ...o.autoCompile };
|
|
60
60
|
const store = o.store ?? memoryStore();
|
|
61
61
|
const say = logger(name, o.log);
|
|
@@ -187,10 +187,10 @@ export function shadow(name, api, options) {
|
|
|
187
187
|
|
|
188
188
|
async function detailed(input) {
|
|
189
189
|
n.calls++;
|
|
190
|
-
const t0 = now();
|
|
190
|
+
const t0 = performance.now();
|
|
191
191
|
const sample = mode !== 'local' || o.random() < o.audit;
|
|
192
192
|
let d = null;
|
|
193
|
-
// Until
|
|
193
|
+
// Until your API has given one readable response, its shape is unknown, so it answers.
|
|
194
194
|
if (mode === 'local' && model && (shape || o.respond)) {
|
|
195
195
|
d = safely('the local model', () => model.decide(input, { probs: wantsProbs() }));
|
|
196
196
|
if (d === NOPE) d = null;
|
|
@@ -199,7 +199,7 @@ export function shadow(name, api, options) {
|
|
|
199
199
|
if (value !== NOPE) {
|
|
200
200
|
n.local++;
|
|
201
201
|
if (sample) audit(input, d.choice);
|
|
202
|
-
return { value, source: 'local', choice: d.choice, confidence: d.confidence, ms: now() - t0 };
|
|
202
|
+
return { value, source: 'local', choice: d.choice, confidence: d.confidence, ms: performance.now() - t0 };
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
}
|
|
@@ -212,13 +212,13 @@ export function shadow(name, api, options) {
|
|
|
212
212
|
const value = d && d !== NOPE ? safely('respond', () => answer(d)) : NOPE;
|
|
213
213
|
if (value === NOPE) throw err;
|
|
214
214
|
n.fallback++;
|
|
215
|
-
return { value, source: 'fallback', choice: d.choice, confidence: d.confidence, ms: now() - t0 };
|
|
215
|
+
return { value, source: 'fallback', choice: d.choice, confidence: d.confidence, ms: performance.now() - t0 };
|
|
216
216
|
}
|
|
217
217
|
n.api++;
|
|
218
218
|
const choice = record(input, resp, sample);
|
|
219
219
|
if (mode === 'local' && d && choice != null) observeDeferred(d.choice, d.confidence, d.choice === choice);
|
|
220
220
|
if (sample && mode === 'local') observe(true);
|
|
221
|
-
return { value: resp, source: 'api', choice, confidence: null, ms: now() - t0 };
|
|
221
|
+
return { value: resp, source: 'api', choice, confidence: null, ms: performance.now() - t0 };
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
function audit(input, localChoice) {
|
|
@@ -230,18 +230,19 @@ export function shadow(name, api, options) {
|
|
|
230
230
|
if (!agree) n.auditDisagree++;
|
|
231
231
|
emit({ type: 'audit', agree, local: localChoice, api: choice });
|
|
232
232
|
if (mode === 'local') observe(agree);
|
|
233
|
-
}, () => {}); //
|
|
233
|
+
}, () => {}); // a failed audit is skipped
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
/** Logs one answer from your API. Returns the label, or null when none could be read. */
|
|
237
237
|
function record(input, resp, sample) {
|
|
238
|
-
|
|
239
|
-
const read = safely('choice', () => o.choice(resp,
|
|
240
|
-
const choice = read === NOPE ? null : read?.choice;
|
|
238
|
+
// Index labels and probs arrays map only through labels you gave; a model's sorted labels are not your API's order.
|
|
239
|
+
const read = safely('choice', () => o.choice(resp, o.labels));
|
|
240
|
+
const choice = read === NOPE ? null : typeof read === 'string' ? read : read?.choice;
|
|
241
241
|
if (choice == null) {
|
|
242
242
|
if (!unreadable) { unreadable = true; emit({ type: 'unreadable', preview: preview(resp) }); }
|
|
243
243
|
return null;
|
|
244
244
|
}
|
|
245
|
+
shape ??= shapeOf(resp); // learned from a response whose label could be read
|
|
245
246
|
// A label nobody has seen yet joins the label set, so the next compile can learn it.
|
|
246
247
|
if (o.labels && !o.labels.includes(choice)) o.labels = [...o.labels, choice];
|
|
247
248
|
if (model && !model.labels.includes(choice)) trip('schema', { label: choice });
|
|
@@ -265,7 +266,8 @@ export function shadow(name, api, options) {
|
|
|
265
266
|
function answer(d) {
|
|
266
267
|
if (o.respond) return o.respond(d, shape);
|
|
267
268
|
if (!shape || shape.kind === 'string') return d.choice;
|
|
268
|
-
|
|
269
|
+
if (shape.probsArray && !o.labels) throw new Error('your API returns probs as an array, so pass labels: [...] in that order to answer locally');
|
|
270
|
+
const out = { [shape.key]: shape.numeric ? (o.labels ? d.index : Number(d.choice)) : d.choice };
|
|
269
271
|
if (shape.probsKey && d.probs) out[shape.probsKey] = shape.probsArray ? model.labels.map((l) => d.probs[l]) : d.probs;
|
|
270
272
|
if (shape.confidence) out.confidence = d.confidence;
|
|
271
273
|
return out;
|
|
@@ -294,18 +296,20 @@ export function shadow(name, api, options) {
|
|
|
294
296
|
rows = rows.slice(-(auto?.window ?? 50_000));
|
|
295
297
|
emit({ type: 'compile:start', rows: rows.length });
|
|
296
298
|
n.compiles++;
|
|
299
|
+
// Keep the labels the model knows: one missing from this window is rare, not new.
|
|
300
|
+
const labels = o.labels ?? (model ? [...new Set([...model.labels, ...rows.map((r) => r.choice).filter((c) => typeof c === 'string')])] : undefined);
|
|
297
301
|
const ctrl = (running = new AbortController());
|
|
298
302
|
let result = null;
|
|
299
303
|
try {
|
|
300
304
|
result = await o.compiler(rows, {
|
|
301
|
-
target: o.target, delta: o.delta, audit: o.audit, labels
|
|
305
|
+
...o.compile, target: o.target, delta: o.delta, audit: o.audit, labels, signal: ctrl.signal, background,
|
|
302
306
|
});
|
|
303
307
|
} catch (err) {
|
|
304
308
|
if (err?.name !== 'AbortError') throw err;
|
|
305
309
|
} finally {
|
|
306
310
|
running = null;
|
|
307
|
-
// Set even when the compile fails, so a failing compile
|
|
308
|
-
if (epoch === started) basis = rows.length;
|
|
311
|
+
// Set even when the compile fails, and to at least 1, so a failing compile waits for more answers.
|
|
312
|
+
if (epoch === started) basis = Math.max(rows.length, 1);
|
|
309
313
|
}
|
|
310
314
|
if (!result || epoch !== started || closed) {
|
|
311
315
|
// The guard tripped while this compile ran, so its rows straddle the change. Never install it.
|
|
@@ -348,7 +352,7 @@ export function shadow(name, api, options) {
|
|
|
348
352
|
await persist('closing the store', () => store.close?.());
|
|
349
353
|
};
|
|
350
354
|
decide.stats = () => ({
|
|
351
|
-
name: name ?? null, mode, ...n, logged: available,
|
|
355
|
+
name: name ?? null, mode, ...n, logged: store.size ?? available,
|
|
352
356
|
share: n.calls ? n.local / n.calls : 0,
|
|
353
357
|
apiCallsSaved: n.local - n.audits,
|
|
354
358
|
live: { sampled: ringN, agreement: ringN ? ringAgree / ringN : null, ci: ringN ? wilson(ringAgree, ringN) : null },
|
|
@@ -391,7 +395,7 @@ function preview(resp) {
|
|
|
391
395
|
|
|
392
396
|
export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
393
397
|
|
|
394
|
-
function validate(name, api, options) {
|
|
398
|
+
export function validate(name, api, options) {
|
|
395
399
|
const fail = (msg) => { throw new TypeError(`tinydecide: ${msg}`); };
|
|
396
400
|
if (name !== undefined && (typeof name !== 'string' || !NAME.test(name))) {
|
|
397
401
|
fail(`the name must be 1–64 letters, digits, dots, dashes or underscores, like "support-intent"; got ${JSON.stringify(name)}`);
|
|
@@ -399,8 +403,7 @@ function validate(name, api, options) {
|
|
|
399
403
|
if (typeof api !== 'function') fail(`shadow(${name ? `"${name}", ` : ''}api) needs your API call as a function, like (text) => classify(text); got ${typeof api}`);
|
|
400
404
|
if (typeof options !== 'object') fail(`options must be an object; got ${typeof options}`);
|
|
401
405
|
for (const k of Object.keys(options)) {
|
|
402
|
-
if (k === 'dir') fail('"dir"
|
|
403
|
-
if (k === 'sink' || k === 'onModel') fail(`"${k}" was replaced by "store"; see the README`);
|
|
406
|
+
if (k === 'dir') fail('"dir" needs a named decision in Node, like shadow("support", api, { dir })');
|
|
404
407
|
if (!OPTIONS.includes(k)) fail(`unknown option "${k}". Options: ${OPTIONS.join(', ')}`);
|
|
405
408
|
}
|
|
406
409
|
const unit = (k, lo, hi) => {
|
package/src/stats.js
CHANGED
|
@@ -31,8 +31,8 @@ export function wilson(x, n, z = 1.959963984540054) {
|
|
|
31
31
|
* at level delta; it stops at the first cutoff that fails. The grid must not be chosen from the
|
|
32
32
|
* calibration rows themselves.
|
|
33
33
|
*
|
|
34
|
-
* guarantee 'overall': risk = P(answered locally and wrong)
|
|
35
|
-
* guarantee 'local' : risk = P(wrong | answered locally)
|
|
34
|
+
* guarantee 'overall': risk = P(answered locally and wrong) (n: all calibration rows)
|
|
35
|
+
* guarantee 'local' : risk = P(wrong | answered locally) (n: rows answered locally)
|
|
36
36
|
*
|
|
37
37
|
* @param {Float64Array|number[]} score per calibration row; answered locally when score ≥ cutoff
|
|
38
38
|
* @param {Uint8Array|boolean[]} wrong per calibration row; the local answer differs from the API's
|
|
@@ -43,10 +43,11 @@ export function certify(score, wrong, grid, { alpha, delta = 0.05, guarantee = '
|
|
|
43
43
|
const n = score.length;
|
|
44
44
|
const order = Array.from(score.keys()).sort((i, j) => score[j] - score[i]);
|
|
45
45
|
const cuts = [...new Set(grid)].sort((a, b) => b - a);
|
|
46
|
-
// For the local guarantee,
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
46
|
+
// For the local guarantee, testing starts at the first cutoff that answers localStart() rows:
|
|
47
|
+
// starting at the bare minimum fails the whole sequence on a single mislabelled row among the most
|
|
48
|
+
// confident few. Which cutoffs are skipped depends only on the scores, never on the errors, so the
|
|
49
|
+
// test sequence is still fixed given the scores.
|
|
50
|
+
const floor = guarantee === 'local' ? localStart(alpha, delta) : 1;
|
|
50
51
|
let best = { cutoff: Infinity, local: 0, errors: 0, pValue: 0, tested: 0 };
|
|
51
52
|
let i = 0, local = 0, errors = 0;
|
|
52
53
|
for (let c = 0; c < cuts.length; c++) {
|
|
@@ -61,6 +62,16 @@ export function certify(score, wrong, grid, { alpha, delta = 0.05, guarantee = '
|
|
|
61
62
|
return best;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Local answers needed before the local guarantee starts testing: the smallest sample on which an
|
|
67
|
+
* error rate of half the budget would still certify, so one stray label error cannot end the test.
|
|
68
|
+
*/
|
|
69
|
+
export function localStart(alpha, delta = 0.05) {
|
|
70
|
+
let m = minCalibration(alpha, delta);
|
|
71
|
+
while (binomCdf(Math.floor((m * alpha) / 2), m, alpha) > delta) m++;
|
|
72
|
+
return m;
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
/** Smallest calibration size at which an error-free sample can certify alpha at level delta. */
|
|
65
76
|
export function minCalibration(alpha, delta = 0.05) {
|
|
66
77
|
return Math.ceil(Math.log(delta) / Math.log1p(-alpha));
|
package/src/train.js
CHANGED
|
@@ -6,7 +6,6 @@ import { U32Map, rng } from './hash.js';
|
|
|
6
6
|
export const RECIPE = Object.freeze({ d: 32, lr: 0.5, minCount: 2, maxFeatures: 200_000, steps: 200_000, minEpochs: 5, maxEpochs: 25 });
|
|
7
7
|
|
|
8
8
|
export const tick = makeTick();
|
|
9
|
-
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
10
9
|
|
|
11
10
|
// Yield to the event loop between chunks of work. Browsers clamp nested setTimeout to 4 ms, so use a
|
|
12
11
|
// MessageChannel there; Node has setImmediate.
|
|
@@ -21,7 +20,7 @@ function makeTick() {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
|
-
* @param {{ w: Uint32Array, c: Uint32Array, t: Float32Array }[]} rows features and target distribution
|
|
23
|
+
* @param {{ w: Uint32Array, c: Uint32Array, t: Float32Array }[]} rows features and target distribution; remapped in place
|
|
25
24
|
* @param {number} K
|
|
26
25
|
* @returns {Promise<{ d, keys: Uint32Array, E: Float32Array, W: Float32Array, b: Float32Array, epochs: number }>}
|
|
27
26
|
*/
|
|
@@ -45,7 +44,11 @@ export async function train(rows, K, opts = {}) {
|
|
|
45
44
|
for (const f of feats) { const r = index.get(f); if (r >= 0) out.push(r); }
|
|
46
45
|
return Int32Array.from(out);
|
|
47
46
|
};
|
|
48
|
-
|
|
47
|
+
// In place, so each row's raw feature copy can be freed as soon as it is mapped.
|
|
48
|
+
// shortcut: rows still take about 22 KB each at 2,000-char inputs (0.7 GB at the default 50,000-row
|
|
49
|
+
// window); cap the window by total features if long-input decisions must fit in less memory.
|
|
50
|
+
for (let i = 0; i < rows.length; i++) rows[i] = { w: ids(rows[i].w), c: ids(rows[i].c), t: rows[i].t };
|
|
51
|
+
const data = rows;
|
|
49
52
|
|
|
50
53
|
const V = keys.length;
|
|
51
54
|
const E = new Float32Array(V * d);
|
|
@@ -65,7 +68,7 @@ export async function train(rows, K, opts = {}) {
|
|
|
65
68
|
const h = new Float32Array(d), hw = new Float32Array(d), hc = new Float32Array(d), gh = new Float32Array(d);
|
|
66
69
|
const p = new Float32Array(K);
|
|
67
70
|
const order = Int32Array.from({ length: N }, (_, i) => i);
|
|
68
|
-
let step = 0, last = now();
|
|
71
|
+
let step = 0, last = performance.now();
|
|
69
72
|
|
|
70
73
|
for (let ep = 0; ep < epochs; ep++) {
|
|
71
74
|
for (let i = N - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); const x = order[i]; order[i] = order[j]; order[j] = x; }
|
|
@@ -95,11 +98,11 @@ export async function train(rows, K, opts = {}) {
|
|
|
95
98
|
}
|
|
96
99
|
if (nw) scatter(E, ex.w, gh, -rate / nw, d);
|
|
97
100
|
if (nc) scatter(E, ex.c, gh, -rate / nc, d);
|
|
98
|
-
if ((step & 255) === 0 && now() - last > yieldMs) {
|
|
101
|
+
if ((step & 255) === 0 && performance.now() - last > yieldMs) {
|
|
99
102
|
onProgress({ phase: 'train', done: step / total });
|
|
100
103
|
await tick();
|
|
101
104
|
if (opts.signal?.aborted) throw abortError();
|
|
102
|
-
last = now();
|
|
105
|
+
last = performance.now();
|
|
103
106
|
}
|
|
104
107
|
}
|
|
105
108
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Type definitions for tinydecide.
|
|
2
|
-
|
|
3
1
|
/** learning: every call goes to your API · local: confident calls answered locally · guarded: back to your API after a failed check. */
|
|
4
2
|
export type Mode = 'learning' | 'local' | 'guarded';
|
|
5
3
|
|
|
6
|
-
/** Call it
|
|
4
|
+
/** Call it like your API function: one input in, a Promise of the response out. */
|
|
7
5
|
export interface Decide<I, R> {
|
|
8
6
|
(input: I): Promise<R>;
|
|
9
7
|
/** The answer plus who gave it. */
|
|
@@ -39,18 +37,18 @@ export interface ShadowOptions<I = any, R = any> {
|
|
|
39
37
|
delta?: number;
|
|
40
38
|
/** Share of calls your API also answers in the background, to check agreement live. Default 0.02. */
|
|
41
39
|
audit?: number;
|
|
42
|
-
/** Fixes the label set and its order. Needed when your API returns
|
|
40
|
+
/** Fixes the label set and its order. Needed when your API returns probability arrays; also names index labels. */
|
|
43
41
|
labels?: string[];
|
|
44
42
|
/** Starts from a compiled model, such as one built with `tinydecide compile`. */
|
|
45
43
|
model?: Model;
|
|
46
|
-
/** Where answers, the model and its state live. Default: memory; in Node, `.tinydecide/<name>/` for a named decision. */
|
|
44
|
+
/** Where answers, the model and its state live. Default: memory; in Node, `.tinydecide/<name>/` for a named decision, or memory with one log line if that folder can't be opened. */
|
|
47
45
|
store?: Store;
|
|
48
46
|
/** Node only: the folder that holds named decisions. Default `.tinydecide`, or `$TINYDECIDE_DIR`. */
|
|
49
47
|
dir?: string;
|
|
50
48
|
/** Keeps related calls (one conversation, one customer) in the same split, so the certificate covers new ones. */
|
|
51
49
|
group?: (input: I) => string | number | null | undefined;
|
|
52
50
|
/** Reads the label, and optionally probabilities, from your API's response. Default: a string, or `choice` / `label` / `decision` / `answer` with `probs`. */
|
|
53
|
-
choice?: (response: R, labels: string[] | undefined) => { choice: string | null; probs?: Record<string, number> | null } | null | undefined;
|
|
51
|
+
choice?: (response: R, labels: string[] | undefined) => string | { choice: string | null; probs?: Record<string, number> | null } | null | undefined;
|
|
54
52
|
/** Builds a local answer. Default: the label under the same key your API uses, plus `probs` and `confidence` if your API returns them. */
|
|
55
53
|
respond?: (decision: Decision, shape: ResponseShape | null) => R;
|
|
56
54
|
/** Answers locally when your API throws and a model exists. Default false. */
|
|
@@ -69,8 +67,8 @@ export interface ShadowOptions<I = any, R = any> {
|
|
|
69
67
|
/** Most recent answers a compile uses. Default 50000. */
|
|
70
68
|
window?: number;
|
|
71
69
|
};
|
|
72
|
-
/** Extra options for each compile. */
|
|
73
|
-
compile?: Partial<CompileOptions
|
|
70
|
+
/** Extra options for each compile. `target`, `delta`, `audit` and `labels` are set above. */
|
|
71
|
+
compile?: Partial<Omit<CompileOptions, 'target' | 'delta' | 'audit' | 'labels'>>;
|
|
74
72
|
/** Replaces the compile function. Node uses a worker thread by default. */
|
|
75
73
|
compiler?: (rows: AnswerRecord[], options: CompileOptions & { background?: boolean }) => Promise<{ model: Model; report: Report }>;
|
|
76
74
|
/** Source of randomness for the audit sample. Default Math.random. */
|
|
@@ -141,7 +139,7 @@ export interface AnswerRecord {
|
|
|
141
139
|
sample?: false;
|
|
142
140
|
}
|
|
143
141
|
|
|
144
|
-
/** Where shadow() keeps answers, the model and its state. Only append
|
|
142
|
+
/** Where shadow() keeps answers, the model and its state. Only append and load are required. */
|
|
145
143
|
export interface Store {
|
|
146
144
|
append(record: AnswerRecord): void;
|
|
147
145
|
/** The most recent answers, oldest first. */
|
|
@@ -176,6 +174,10 @@ export interface CompileOptions {
|
|
|
176
174
|
seed: number;
|
|
177
175
|
/** The audit rate the model will run with, used to estimate savings. Default 0.02. */
|
|
178
176
|
audit: number;
|
|
177
|
+
/** Width of each feature's vector. Default 32. */
|
|
178
|
+
d?: number;
|
|
179
|
+
/** Most features the model keeps, the most frequent first. Default 200000. */
|
|
180
|
+
maxFeatures?: number;
|
|
179
181
|
onProgress?: (progress: { phase: string; done: number }) => void;
|
|
180
182
|
signal?: AbortSignal;
|
|
181
183
|
}
|
|
@@ -249,7 +251,7 @@ export interface ModelMeta {
|
|
|
249
251
|
created?: string;
|
|
250
252
|
certificate?: Certificate;
|
|
251
253
|
rows?: { total: number; train: number; calib: number; test: number };
|
|
252
|
-
expected?: { share: number; localAgreement: number | null; deferred: { n: number; agreement: number | null } };
|
|
254
|
+
expected?: { share: number; localAgreement: number | null; deferred: { n: number; agreement: number | null; ratio: Record<string, number> } };
|
|
253
255
|
verdict?: Verdict;
|
|
254
256
|
response?: ResponseShape | null;
|
|
255
257
|
[key: string]: unknown;
|
|
@@ -275,7 +277,7 @@ export class Model {
|
|
|
275
277
|
|
|
276
278
|
// ── Node only ───────────────────────────────────────────────────────────────────────────────────
|
|
277
279
|
|
|
278
|
-
/** Node only. Keeps one decision in a folder: answers.jsonl, model.tdm, report.json and, while guarded, state.json. */
|
|
280
|
+
/** Node only. Keeps one decision in a folder: answers.jsonl, model.tdm, report.json, a lock file and, while guarded, state.json. Throws if another store holds the folder. */
|
|
279
281
|
export function fileStore(dir: string, options?: { limit?: number }): Store & { readonly dir: string };
|
|
280
282
|
/** Node only. */
|
|
281
283
|
export function loadModel(path: string): Model;
|