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 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
- That is the whole integration. 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.
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: watch it learn, take over, and catch an API change
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.** Local answers agree with your API at least 98% of the time overall, certified at 95% confidence on traffic like the traffic it learned from. You set the target.
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. **Your server never stalls.** Compiles run in a worker thread; the worst pause on the event loop during one is about 3 ms.
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 })` to read it and `respond: (decision) => …` to build local answers.
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 `text` (or `state`, or `input`); other fields are ignored by the model but logged with the answer.
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 indexes or probability arrays |
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 or out-of-range options throw at startup with a message that says what to write instead.
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, compacted to the newest 100,000
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. Use one process per folder.
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)`, `load()` and `size`, plus optional `loadModel`, `saveModel`, `loadState`, `saveState` and `close` (any of them may be async). `memoryStore()` and `fileStore(dir)` are the built-in ones.
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 watch a simulated API graduate, change, and recover
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 temperature is fitted only so the reported confidence is honest.
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 # 39 tests, about 15 s
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
- No dependencies, by design. Issues and pull requests are welcome; please run the tests first. Security reports go through [SECURITY.md](SECURITY.md).
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 watch it learn, take over, and recover from an API change (15 s, no keys)
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 recovering
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 with one line. Watch it learn, take over, and handle the API changing under it.`, 2)}\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. 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 recovering ${dim('(overall, as certified; checked on every call, which only a simulation can do)')}`);
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' || flags.help || flags.h) console.log(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.0",
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 in, a certified local model and an honest report out.
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? } — see README for accepted shapes
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
- // How often the model's guess matches the API on calls it defers. shadow() watches this live:
77
- // deferred calls get the API's answer anyway, so a drop here is a free early sign of drift.
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.add(h);
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 plain-English line per lifecycle event, so the moment a decision graduates, falls back to your
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 run in a worker thread, so they never pause your server's event loop
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
- return core(name, api, { compiler: compileInWorker, store: rest.store ?? fileStore(join(dir, name)), ...rest });
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 (compacted to the newest `limit`)
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
- * One process per folder. Writes of the model and state are atomic.
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) { writeSync(fd, JSON.stringify(record) + '\n'); size++; },
59
- load() {
60
- const rows = readJsonl(path.answers);
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() { if (fd >= 0) { closeSync(fd); fd = -1; } },
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 — the fastest sign the API changed
19
+ // against the model's own calibrated confidence. It usually trips before the audit check.
20
20
  //
21
- // One rule above all: TinyDecide never makes a call fail that would have succeeded without it.
22
- // If anything inside it throws, the call goes to your API.
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 one response from your API has been seen, its shape is unknown, so it answers.
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
- }, () => {}); // your API failing is your app's business; the audit is simply skipped
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
- shape ??= shapeOf(resp);
239
- const read = safely('choice', () => o.choice(resp, model?.labels ?? o.labels));
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
- const out = { [shape.key]: shape.numeric ? d.index : d.choice };
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: o.labels, ...o.compile, signal: ctrl.signal, background,
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 is not retried on every call.
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" only works in Node, where answers and models are saved to files');
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) — n is every calibration row
35
- * guarantee 'local' : risk = P(wrong | answered locally) — n is the rows the cutoff answers
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, cutoffs that answer fewer rows than an error-free sample needs are
47
- // skipped rather than failed. Which cutoffs are skipped depends only on the scores, never on the
48
- // errors, so the test sequence is still fixed given the scores.
49
- const floor = guarantee === 'local' ? minCalibration(alpha, delta) : 1;
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
- const data = rows.map((r) => ({ w: ids(r.w), c: ids(r.c), t: r.t }));
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 exactly like your API function. */
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 label indexes or probability arrays. */
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, load and size are required. */
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;