tinydecide 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Yun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,256 @@
1
- # Temporary Holding Version
1
+ # TinyDecide
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/tinydecide)](https://www.npmjs.com/package/tinydecide) [![test](https://github.com/Christian1011/tinydecide/actions/workflows/test.yml/badge.svg)](https://github.com/Christian1011/tinydecide/actions/workflows/test.yml) [![bundle size](https://img.shields.io/badge/browser-12%20KB%20gzipped-blue)](#how-it-works) [![license](https://img.shields.io/npm/l/tinydecide)](LICENSE)
4
+
5
+ Wrap your decision API. TinyDecide learns from its answers and takes over the calls it can answer locally, in microseconds, with agreement certified.
6
+
7
+ ```sh
8
+ npm install tinydecide # or: bun add tinydecide
9
+ ```
10
+
11
+ ```js
12
+ import { shadow } from 'tinydecide';
13
+
14
+ const classify = shadow('support', (text) => api.classify(text));
15
+
16
+ await classify('my card was charged twice'); // the same answer your API gives
17
+ ```
18
+
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
+
21
+ ```sh
22
+ npx tinydecide try # 15 seconds, no API key: TinyDecide learns a simulated API, which then changes
23
+ ```
24
+
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
+
27
+ ## What you can count on
28
+
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
+ 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
+ 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
+ 4. **Restarts resume.** In Node, a named decision keeps its answers, model and state in `.tinydecide/<name>/`.
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
+ 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
+
36
+ No dependencies. Node 22+, Bun, browsers, workers and edge runtimes; 12 KB gzipped in a browser bundle.
37
+
38
+ ## How a decision graduates
39
+
40
+ | Mode | Who answers | Until |
41
+ |---|---|---|
42
+ | **learning** | Your API, every call | 2,000 answers are logged and a compile certifies |
43
+ | **local** | The model when its margin clears the certified cutoff; your API otherwise | A live check fails, or your API returns a label the model has never seen |
44
+ | **guarded** | Your API, every call | A compile on answers given after the change certifies |
45
+
46
+ It recompiles each time the answers double, and at least every 20,000 answers. It logs one line when something changes, never per call:
47
+
48
+ ```
49
+ [tinydecide] support: learning from your API. First compile after 2,000 answers.
50
+ [tinydecide] support: compiled from 2,000 answers: answers 60% of calls locally, with ≥ 98% agreement with your API certified at 95% confidence. 0.16 MB, 8.1 µs per call.
51
+ [tinydecide] support: your API's answers changed for "billing": on calls the model leaned toward "billing" but left to your API, they now agree 9% of the time where the model expected 47%. Every call goes to your API until a model trained on its new answers certifies.
52
+ ```
53
+
54
+ `npx tinydecide status` shows every decision in `.tinydecide/` at a glance.
55
+
56
+ ## Your API's responses
57
+
58
+ `shadow(name, api)` takes any function of one input. It reads the label from:
59
+
60
+ - a string: `'billing'`
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
+
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
+
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
+
67
+ ## Options
68
+
69
+ ```js
70
+ shadow('support', api, {
71
+ target: 0.98, // agreement with your API to certify, overall across all calls
72
+ group: (input) => input.conversationId, // keep a conversation's calls together when certifying
73
+ fallback: true, // answer locally if your API throws and a model exists
74
+ log: false, // or (line, event) => logger.info(line)
75
+ });
76
+ ```
77
+
78
+ | Option | Default | |
79
+ |---|---|---|
80
+ | `target` | `0.98` | Agreement to certify. Overall: your API's own answers count as agreeing |
81
+ | `delta` | `0.05` | 1 − confidence of the certificate |
82
+ | `audit` | `0.02` | Share of calls your API also answers in the background, to check agreement live |
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 probability arrays; also names index labels |
85
+ | `fallback` | `false` | Answer locally when your API throws |
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:start`, `compile`, `install`, `guard`, `discard`, `audit`, `unreadable`, `error` |
88
+ | `dir` | `.tinydecide` | Node: where named decisions live. Also `$TINYDECIDE_DIR` |
89
+ | `store` | file in Node, memory elsewhere | Where answers, the model and its state live (see below) |
90
+ | `model` | none | Start from a compiled model |
91
+ | `autoCompile` | `{ min: 2000, growth: 2, maxEvery: 20000, window: 50000 }` | `false` compiles only on `decide.compile()` |
92
+ | `choice`, `respond` | built in | Read custom responses, build custom local answers |
93
+
94
+ Misspelled options, and out-of-range `target`, `delta` or `audit`, throw at startup with a message that says what to write instead.
95
+
96
+ The function you get back also has:
97
+
98
+ - `decide.detailed(input)` returns `{ value, source, choice, confidence, ms }`; `source` is `'local'`, `'api'` or `'fallback'`.
99
+ - `decide.stats()` returns the mode, counts, calls saved, and live agreement with its interval.
100
+ - `decide.ready` resolves once a saved model is loaded. Calls before that go to your API.
101
+ - `decide.compile()` compiles now. `decide.close()` stops compiling and closes the store.
102
+ - `decide.mode` and `decide.model` are read-only.
103
+
104
+ ## Where things live
105
+
106
+ In Node, `shadow('support', api)` keeps one folder:
107
+
108
+ ```
109
+ .tinydecide/
110
+ .gitignore written once: the folder holds raw inputs, so git ignores it
111
+ support/
112
+ answers.jsonl your API's recent answers, trimmed to the newest 100,000
113
+ model.tdm the installed model, and report.json, its compile report
114
+ state.json only while guarded, so a restart stays guarded
115
+ lock the pid of the process using the folder
116
+ ```
117
+
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.
119
+
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.
121
+
122
+ ## Deploying
123
+
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.
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:
127
+
128
+ ```sh
129
+ npx tinydecide compile answers.jsonl --out support.tdm
130
+ ```
131
+
132
+ ```js
133
+ import { loadModel, shadow } from 'tinydecide';
134
+
135
+ const classify = shadow('support', api, { model: loadModel('support.tdm'), autoCompile: false, store });
136
+ ```
137
+
138
+ The live checks still run on every instance and fall back to your API on their own.
139
+
140
+ **Browsers, workers and edge.** The same import works; answers stay in memory. Load a compiled model with `Model.fromBytes(await (await fetch('/support.tdm')).arrayBuffer())` and pass it as `model`.
141
+
142
+ ## The guarantee
143
+
144
+ Each compile splits the logged answers into train, calibration and test, by `group` or else by normalized text so repeats never leak. It trains, quantizes to int8, and then runs Learn-Then-Test with fixed-sequence binomial testing over a margin grid fixed in advance. It picks the loosest cutoff for which
145
+
146
+ > with probability at least 1 − `delta` over the calibration draw, agreement with your API on traffic like the calibration rows is at least `target`.
147
+
148
+ Only the uniform random sample calibrates. Calls logged because the model deferred them are biased toward hard cases, so they train but never certify. Every number in a report is measured on the int8 model that ships. Pass `compile: { guarantee: 'local' }` to certify agreement on local answers alone; it answers fewer calls.
149
+
150
+ The certificate covers traffic like the calibration sample. The live checks cover change:
151
+
152
+ | Check | Watches | Trips when |
153
+ |---|---|---|
154
+ | Audit | The random sample of local answers, re-asked of your API | A CUSUM test finds overall agreement below target. About 2% false alarms over a model's life when agreement sits exactly at target, fewer above it |
155
+ | Deferred | Calls your API answers anyway, against the model's guess | Agreement falls far below calibration overall, or for one label far below what the model's own calibrated confidence predicts |
156
+ | Schema | Your API's labels | A label appears that the model has never seen |
157
+
158
+ A compile that is running when a check trips is cancelled and never installed, because its answers straddle the change.
159
+
160
+ ## Results
161
+
162
+ ### Replay on real decision-API traffic
163
+
164
+ Fifteen decisions, with answers recorded from a production decision model. For each one: compile on the first N calls, then answer later calls the model never saw. Target 98%, delta 5%.
165
+
166
+ | Decision | Calls compiled | Answered locally | Agreement [95% CI] | Verdict |
167
+ |---|---:|---:|---|---|
168
+ | Toxic chat prompts | 2,000 | 87.2% | 98.3% [97.1–99.1] | ship |
169
+ | Support intents (CLINC, 151 labels) | 10,000 | 73.1% | 98.6% [98.0–98.9] | ship |
170
+ | PubMed sentence role † | 50,000 | 67.6% | 98.3% [97.7–98.7] | ship |
171
+ | Economic news relevance | 2,000 | 67.1% | 99.2% [98.5–99.6] | ship |
172
+ | Dialog emotion † | 20,000 | 62.4% | 98.2% [97.7–98.6] | ship |
173
+ | Voice-assistant intents (SLURP, 60 labels) | 5,000 | 44.1% | 99.1% [98.6–99.4] | ship |
174
+ | Adverse drug events | 10,000 | 38.1% | 98.3% [97.7–98.7] | ship |
175
+ | Airline sentiment | 5,000 | 36.7% | 99.0% [98.4–99.3] | ship |
176
+ | Hate / offensive speech | 10,000 | 31.6% | 98.5% [98.0–98.9] | ship |
177
+ | Sarcastic headlines | 20,000 | 19.8% | 98.6% [98.1–99.0] | marginal |
178
+ | Relation types (SemEval, 19 labels) | 5,000 | 19.5% | 98.5% [97.7–99.0] | marginal |
179
+ | Sexism detection (EDOS) | 5,000 | 11.9% | 98.6% [98.0–99.0] | marginal |
180
+ | Adversarial hate (DynaHate) | 5,000 | 7.5% | 99.4% [98.8–99.7] | marginal |
181
+ | Scientific inference pairs (SciNLI) † | 20,000 | 4.1% | 99.1% [98.7–99.4] | not worth it |
182
+ | Paraphrase pairs (PAWS) | 10,000 | 3.6% | 98.4% [97.8–98.8] | not worth it |
183
+
184
+ † The recorded answers mix the decision model with other labellers.
185
+
186
+ Across all 56 (decision, N) cells from 1,000 to 50,000 calls, 3 measured below 98%, close to the 5% that `delta` allows; 1 was clearly below. Pooled agreement was 98.8%. On a deliberately shifted future set built from clusters the compile never saw, 9 of 56 measured below and 2 clearly. The recorded traffic is research data and is not distributed.
187
+
188
+ ### Live, against a real API
189
+
190
+ TinyDecide wrapped an open-weights model on OpenRouter (Mistral Nemo) labelling 3,000 public airline tweets. Total cost $0.0034. After the first compile at 2,000 answers, the next 1,000 calls were 24% local with overall agreement 98.9% [98.0–99.4]. API p50 541 ms; local 16.8 µs.
191
+
192
+ ### The guard
193
+
194
+ - **False alarms:** none in 221,636 recorded calls across all 15 decisions, replayed with no API change.
195
+ - **Detection:** when a simulated 10-label API starts filing one label under another, the guard trips after a median of 1,226 calls (754–3,657 over 10 runs), with 25–309 wrong local answers in the meantime.
196
+
197
+ Both are in [`bench/results/guard-2026-10-24.md`](bench/results/guard-2026-10-24.md); `npm run bench:guard` reruns the detection runs.
198
+
199
+ ## Command line
200
+
201
+ ```
202
+ tinydecide try a 15 s simulation: learn a simulated API, which then changes
203
+ tinydecide status [dir] what each decision is doing
204
+ tinydecide compile <answers> compile from a .jsonl log or a decision folder
205
+ tinydecide inspect <model.tdm> labels and certificate
206
+ tinydecide decide <model.tdm> answer inputs from arguments or stdin
207
+ ```
208
+
209
+ `compile` prints the verdict, a held-out check and what other targets would allow:
210
+
211
+ ```
212
+ tinydecide compile traffic.jsonl → traffic.tdm
213
+
214
+ 10,000 calls · 151 labels · target 98.0% overall agreement (95% confidence)
215
+
216
+ ✓ ship Answers 69.1% of calls locally while keeping overall agreement with your API ≥ 98% (95% confidence).
217
+
218
+ held-out check 1,015 calls 69.0% local 98.4% agreement [97.5%–99.0%]
219
+ local answers 97.7% agree with your API
220
+ what if 90% → 96% 95% → 83% 97% → 76% 98% → 69% 99.0% → 56% 99.5% → 36%
221
+ model 0.66 MB · 15,948 features · 11.1 µs per call · trained in 3.5 s
222
+ ```
223
+
224
+ Answer logs are one JSON object per line: `{"input": "…", "choice": "billing"}`, optionally with `probs` and `group`. The label may also be under `label`, `decision` or `answer`.
225
+
226
+ ## How it works
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.
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.
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.
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.
232
+
233
+ ## Limits
234
+
235
+ 1. **Word-level models.** Decisions that turn on meaning across a pair, or on world knowledge, mostly stay with your API. The verdict tells you which.
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.
237
+ 3. **The answer log holds raw inputs.** Keep `.tinydecide/` private, redact inputs before they reach `shadow`, or use your own `store`.
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.
240
+
241
+ ## Development
242
+
243
+ ```sh
244
+ npm test # 49 tests, about 15 s
245
+ npm run test:bun # the same tests under Bun
246
+ npm run typecheck # the public types, with tsc
247
+ npm run bench:guard # how fast the guard catches an API change, over 10 runs
248
+ ```
249
+
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.
253
+
254
+ ## License
255
+
256
+ MIT
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { supportApi } from '../examples/support-api.js';
6
+ import { compile, loadModel, memoryStore, readJsonl, saveModel, shadow } from '../src/node.js';
7
+
8
+ const HELP = `tinydecide: wrap your decision API; answer the calls it can locally, with agreement certified
9
+
10
+ usage
11
+ tinydecide try a 15 s simulation, no keys: learn a simulated API, which then changes
12
+ tinydecide status [dir] what each decision in .tinydecide/ is doing
13
+ tinydecide compile <answers> compile a model from a .jsonl log, or a decision folder
14
+ [--out model.tdm] [--target 0.98] [--delta 0.05] [--guarantee overall|local]
15
+ [--labels a,b,c] [--audit 0.02] [--report report.json]
16
+ tinydecide inspect <model.tdm> print a model's labels and certificate
17
+ tinydecide decide <model.tdm> [text …] answer inputs; reads one per line from stdin when none are given
18
+
19
+ in code
20
+ import { shadow } from 'tinydecide';
21
+ const classify = shadow('support', (text) => yourApi.classify(text));`;
22
+
23
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
24
+ const paint = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
25
+ const dim = paint('2'), bold = paint('1'), green = paint('32'), yellow = paint('33'), red = paint('31'), blue = paint('34');
26
+ const pct = (x, digits = 1) => (x == null ? '—' : `${(x * 100).toFixed(digits)}%`);
27
+ const int = (x) => Math.round(x).toLocaleString('en-US');
28
+
29
+ function parse(argv) {
30
+ const pos = [], flags = {};
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a.startsWith('--')) {
34
+ const [k, v] = a.slice(2).split('=', 2);
35
+ flags[k] = v ?? (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true);
36
+ } else pos.push(a);
37
+ }
38
+ return { pos, flags };
39
+ }
40
+
41
+ const VERDICT = {
42
+ ship: () => green('✓ ship'),
43
+ marginal: () => yellow('~ marginal'),
44
+ 'not-worth-it': () => red('× not worth it'),
45
+ 'need-more-data': () => yellow('… need more data'),
46
+ };
47
+
48
+ // ── try ──────────────────────────────────────────────────────────────────────────────────────────────
49
+
50
+ async function cmdTry(f) {
51
+ const CALLS = +(f.calls ?? 14_000), CHANGE_AT = Math.round(CALLS * 0.55), RATE = +(f.rate ?? 1000);
52
+ const API_MS = 400, PRICE = 0.4 / 1000;
53
+ const sim = supportApi({ seed: +(f.seed ?? 1) });
54
+ const out = process.stdout;
55
+ const width = Math.min(out.columns || 100, 110);
56
+ // before the change · between the change and the guard · after the guard
57
+ const phase = [{ local: 0, right: 0, calls: 0 }, { local: 0, right: 0, calls: 0 }, { local: 0, right: 0, calls: 0 }];
58
+ let i = 0, p = 0, status = '', last = null;
59
+ const recent = new Uint8Array(500), agree = new Uint8Array(500);
60
+ let agreeN = 0;
61
+
62
+ const wrap = (text, indent) => {
63
+ const lines = [];
64
+ let line = '';
65
+ for (const word of text.split(' ')) {
66
+ if (line && indent + visible(line).length + 1 + visible(word).length > width - 2) { lines.push(line); line = word; } else line = line ? `${line} ${word}` : word;
67
+ }
68
+ lines.push(line);
69
+ return lines.join(`\n${' '.repeat(indent)}`);
70
+ };
71
+ const clear = () => { if (tty && status) out.write('\r\x1b[2K'); };
72
+ const draw = () => {
73
+ if (!tty) return;
74
+ const share = recent.reduce((a, x) => a + x, 0) / Math.min(Math.max(i, 1), recent.length);
75
+ const n = Math.min(agreeN, agree.length), ok = agree.reduce((a, x) => a + x, 0);
76
+ status = ` ${dim('calls')} ${int(i).padStart(6)} ${dim('answered locally')} ${blue(pct(share, 0).padStart(4))} ${dim('agreement on recent local answers')} ${n ? pct(ok / n) : '—'}`;
77
+ out.write(`\r\x1b[2K${status}`);
78
+ };
79
+ const say = (line) => { clear(); out.write(`${dim(int(i).padStart(8))} ${wrap(line, 10)}\n`); draw(); };
80
+
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. Partway through, the API changes how it labels some messages.`, 2)}\n\n`);
83
+
84
+ const decide = shadow('support', sim.api, {
85
+ store: memoryStore(),
86
+ log: (line, e) => {
87
+ if (e.type === 'compile') last = e.report;
88
+ if (e.type === 'guard' && p === 1) p = 2; // only after the scripted change
89
+ say(line.replace(/^\[tinydecide\] support: /, ''));
90
+ },
91
+ });
92
+
93
+ const t0 = Date.now();
94
+ for (; i < CALLS; i++) {
95
+ if (i === CHANGE_AT) { p = 1; say(yellow(`▸ ${sim.change()}. Nothing tells TinyDecide.`)); }
96
+ const text = sim.next();
97
+ const d = await decide.detailed(text);
98
+ const isLocal = d.source === 'local';
99
+ recent[i % recent.length] = isLocal ? 1 : 0;
100
+ phase[p].calls++;
101
+ if (isLocal) {
102
+ const right = d.choice === sim.truth(text);
103
+ phase[p].local++;
104
+ if (right) phase[p].right++;
105
+ agree[agreeN++ % agree.length] = right ? 1 : 0;
106
+ }
107
+ if (i % 25 === 0) {
108
+ draw();
109
+ const ahead = ((i + 1) / RATE) * 1000 - (Date.now() - t0);
110
+ await new Promise((r) => setTimeout(r, Math.max(0, ahead)));
111
+ }
112
+ }
113
+ const s = decide.stats();
114
+ clear();
115
+ await decide.close();
116
+
117
+ const local = phase.reduce((a, x) => a + x.local, 0);
118
+ const row = (k, v) => out.write(` ${dim(k.padEnd(18))}${wrap(v, 20)}\n`);
119
+ // Overall agreement, as certified: your API's own answers count as agreeing.
120
+ const rate = (x) => (x.calls ? pct(1 - (x.local - x.right) / x.calls) : '—');
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 the guard ${dim('(overall, as certified; checked on every call, which only a simulation can do)')}`);
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
+ 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
+ if (last) row('time per answer', `${last.latencyUs} µs locally, vs ${API_MS} ms from your API`);
126
+ out.write(`\n ${dim('Next: wrap your own API')}\n\n import { shadow } from 'tinydecide';\n const classify = shadow('support', (text) => yourApi.classify(text));\n\n`);
127
+ }
128
+
129
+ const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
130
+
131
+ // ── status ───────────────────────────────────────────────────────────────────────────────────────────
132
+
133
+ function cmdStatus(dir = process.env.TINYDECIDE_DIR || '.tinydecide') {
134
+ if (!existsSync(dir)) {
135
+ console.log(`\n No decisions in ${dir}/ yet. Wrap your API with shadow('name', api) and run your app.\n`);
136
+ return;
137
+ }
138
+ const names = readdirSync(dir).filter((n) => statSync(join(dir, n)).isDirectory()).sort();
139
+ console.log(`\n ${bold(dir + '/')}\n`);
140
+ if (!names.length) console.log(dim(' (empty)'));
141
+ const width = Math.max(...names.map((n) => n.length), 8);
142
+ for (const name of names) {
143
+ const at = (f) => join(dir, name, f);
144
+ const answers = existsSync(at('answers.jsonl')) ? countLines(at('answers.jsonl')) : 0;
145
+ const report = readJson(at('report.json'));
146
+ const state = readJson(at('state.json'));
147
+ const mode = state?.guardedSince ? 'guarded' : existsSync(at('model.tdm')) ? 'local' : 'learning';
148
+ const color = { local: blue, learning: dim, guarded: yellow }[mode];
149
+ let detail;
150
+ if (mode === 'guarded') detail = `back to your API ${ago(state.guardedSince)}; relearning from its new answers`;
151
+ else if (mode === 'local' && report) detail = `answers about ${pct(report.calib.share, 0)} locally at ≥ ${pct(report.target, 0)} agreement · compiled ${ago(Date.parse(statSync(at('model.tdm')).mtime))}`;
152
+ else detail = report ? `last compile: ${report.verdict}` : 'every call goes to your API until the first compile';
153
+ console.log(` ${name.padEnd(width)} ${color(mode.padEnd(8))} ${int(answers).padStart(8)} answers ${dim(detail)}`);
154
+ }
155
+ console.log('');
156
+ }
157
+
158
+ function ago(t) {
159
+ const s = (Date.now() - t) / 1000;
160
+ if (s < 90) return 'just now';
161
+ if (s < 5400) return `${Math.round(s / 60)} min ago`;
162
+ if (s < 129_600) return `${Math.round(s / 3600)} h ago`;
163
+ return `${Math.round(s / 86_400)} days ago`;
164
+ }
165
+
166
+ function readJson(path) {
167
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
168
+ }
169
+
170
+ function countLines(path) {
171
+ const buf = readFileSync(path);
172
+ let n = 0;
173
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
174
+ return n;
175
+ }
176
+
177
+ // ── compile, inspect, decide ─────────────────────────────────────────────────────────────────────────
178
+
179
+ async function cmdCompile(file, f) {
180
+ if (!file) throw new Error('compile needs a .jsonl file of answers, or a decision folder like .tinydecide/support');
181
+ if (existsSync(file) && statSync(file).isDirectory()) file = join(file, 'answers.jsonl');
182
+ if (!existsSync(file)) throw new Error(`no such file: ${file}`);
183
+ let skippedLines = 0;
184
+ const records = readJsonl(file, { onSkip: () => skippedLines++ });
185
+ const out = f.out ?? file.replace(/\.jsonl(\.gz)?$/, '') + '.tdm';
186
+ const opts = {
187
+ target: f.target ? +f.target : undefined, delta: f.delta ? +f.delta : undefined,
188
+ guarantee: f.guarantee, audit: f.audit ? +f.audit : undefined, seed: f.seed ? +f.seed : undefined,
189
+ labels: f.labels ? String(f.labels).split(',') : undefined,
190
+ };
191
+ for (const k of Object.keys(opts)) if (opts[k] === undefined) delete opts[k];
192
+ let shown = -1;
193
+ const { model, report } = await compile(records, {
194
+ ...opts,
195
+ onProgress: ({ phase, done }) => {
196
+ if (!process.stderr.isTTY || phase !== 'train') return;
197
+ const p = Math.floor(done * 20);
198
+ if (p !== shown) { shown = p; process.stderr.write(`\r training ${'▮'.repeat(p)}${dim('▯'.repeat(20 - p))}`); }
199
+ },
200
+ });
201
+ if (process.stderr.isTTY) process.stderr.write('\r\x1b[2K');
202
+ saveModel(out, model);
203
+ if (f.report) writeFileSync(f.report, JSON.stringify(report, null, 2) + '\n');
204
+
205
+ const r = report, t = r.test;
206
+ const scope = r.guarantee === 'local' ? 'agreement on local answers' : 'overall agreement';
207
+ console.log(`\n ${bold('tinydecide compile')} ${basename(file)} ${dim('→')} ${basename(out)}\n`);
208
+ console.log(dim(` ${int(r.rows.total)} calls · ${r.labels.length} labels · target ${pct(r.target, 1)} ${scope} (${pct(1 - r.delta, 0)} confidence)${skippedLines ? ` · ${skippedLines} unreadable lines skipped` : ''}\n`));
209
+ console.log(` ${VERDICT[r.verdict]()} ${r.summary}\n`);
210
+ const row = (k, v) => console.log(` ${dim(k.padEnd(15))} ${v}`);
211
+ if (t) row('held-out check', `${int(t.n)} calls ${pct(t.share)} local ${pct(t.overallAgreement)} agreement ${dim(`[${pct(t.overallCI[0])}–${pct(t.overallCI[1])}]`)}`);
212
+ if (t?.localAgreement != null) row('local answers', `${pct(t.localAgreement)} agree with your API`);
213
+ row('what if', r.whatIf.map((w) => `${pct(w.target, w.target >= 0.99 ? 1 : 0)} ${dim('→')} ${pct(w.share, 0)}`).join(' '));
214
+ row('model', `${(r.bytes / 1e6).toFixed(2)} MB · ${int(r.features)} features · ${r.latencyUs} µs per call · trained in ${(r.ms.train / 1000).toFixed(1)} s`);
215
+ console.log('');
216
+ }
217
+
218
+ function cmdInspect(file) {
219
+ if (!file) throw new Error('inspect needs a .tdm model file');
220
+ const m = loadModel(file);
221
+ console.log(JSON.stringify({
222
+ labels: m.labels, features: m.keys.length, d: m.d, bytes: m.bytes, temperature: m.T,
223
+ cutoff: Number.isFinite(m.cutoff) ? m.cutoff : null, certificate: m.meta?.certificate ?? {}, expected: m.meta?.expected, created: m.meta?.created,
224
+ }, null, 2));
225
+ }
226
+
227
+ async function cmdDecide(file, texts) {
228
+ if (!file) throw new Error('decide needs a .tdm model file');
229
+ const m = loadModel(file);
230
+ const one = (s) => {
231
+ let input = s;
232
+ try { const j = JSON.parse(s); if (j && typeof j === 'object') input = j; } catch {}
233
+ const d = m.decide(input);
234
+ console.log(JSON.stringify({ choice: d.choice, confidence: +d.confidence.toFixed(4), local: d.local }));
235
+ };
236
+ if (texts.length) return texts.forEach(one);
237
+ for await (const line of createInterface({ input: process.stdin })) if (line.trim()) one(line);
238
+ }
239
+
240
+ const { pos, flags } = parse(process.argv.slice(2));
241
+ const [cmd, ...rest] = pos;
242
+ try {
243
+ if (cmd === 'try') await cmdTry(flags);
244
+ else if (cmd === 'status') cmdStatus(rest[0]);
245
+ else if (cmd === 'compile') await cmdCompile(rest[0], flags);
246
+ else if (cmd === 'inspect') cmdInspect(rest[0]);
247
+ else if (cmd === 'decide') await cmdDecide(rest[0], rest.slice(1));
248
+ else if (!cmd || cmd === 'help' || cmd === '-h' || flags.help) console.log(HELP);
249
+ else throw new Error(`unknown command "${cmd}". Run tinydecide help.`);
250
+ } catch (err) {
251
+ console.error(`tinydecide: ${err.message}`);
252
+ process.exit(1);
253
+ }
@@ -0,0 +1,118 @@
1
+ // A simulated support-ticket classifier, used by `tinydecide try` in place of a real API.
2
+ //
3
+ // It labels customer messages the way an LLM classifier does at temperature 0: the same text always
4
+ // gets the same label, clear messages get the obvious label (with a little noise), and vague or
5
+ // mixed messages get a label no word-level model could predict. No network, no keys.
6
+
7
+ import { hashString, rng } from '../src/hash.js';
8
+
9
+ export const LABELS = ['refund', 'billing', 'login', 'order_status', 'change_address', 'cancel', 'upgrade', 'bug', 'feature_request', 'human'];
10
+
11
+ const SLOTS = {
12
+ item: ['my headphones', 'the blender', 'the jacket', 'the charger', 'the desk lamp', 'my shoes', 'the coffee maker', 'the backpack', 'the keyboard', 'my phone case', 'the monitor', 'the rug', 'the tent', 'the kettle', 'my sunglasses'],
13
+ plan: ['pro', 'the team plan', 'premium', 'the annual plan', 'business', 'the family plan'],
14
+ card: ['visa', 'credit card', 'bank', 'amex', 'paypal', 'debit card'],
15
+ device: ['iphone', 'android', 'my laptop', 'chrome', 'safari', 'the ipad app', 'windows'],
16
+ city: ['denver', 'austin', 'seattle', 'boston', 'chicago', 'portland', 'atlanta', 'miami', 'phoenix', 'toronto'],
17
+ action: ['upload a photo', 'check out', 'open settings', 'export a report', 'log out', 'search', 'save my profile', 'add a card'],
18
+ feature: ['search', 'checkout', 'the dashboard', 'notifications', 'sync', 'the mobile app', 'file upload', 'the calendar'],
19
+ wish: ['dark mode', 'bulk export', 'a public api', 'offline mode', 'calendar sync', 'sms login', 'more themes', 'csv import'],
20
+ integration: ['slack', 'zapier', 'shopify', 'google sheets', 'salesforce', 'notion', 'hubspot'],
21
+ code: ['500', '403', 'E1029', '404', 'TIMEOUT', 'E-77', '502'],
22
+ };
23
+
24
+ const TEMPLATES = {
25
+ refund: ['i want a refund for {item}', 'can i get my money back for {item}', 'please refund order {order}', '{item} arrived broken, i want a refund', 'how do i return {item}', 'requesting a refund, {item} is not what i ordered', 'refund please, {item} stopped working after a week', 'i returned {item} two weeks ago and still no refund'],
26
+ billing: ['i was charged twice for {plan}', 'there are two charges of ${amount} on my {card}', 'why was i billed ${amount} this month', 'unexpected charge on my {card} statement', 'my invoice says ${amount} but it should be less', 'duplicate charge for order {order}', 'what is this ${amount} charge'],
27
+ login: ["i can't log in", 'my password reset email never arrives', 'locked out of my account after too many attempts', "the 2fa code isn't working", 'it says invalid password but i know it is right', "can't sign in on {device}", 'login page keeps spinning on {device}', 'forgot my password and the reset link expired'],
28
+ order_status: ['where is my order {order}', "my package hasn't arrived yet", 'tracking says delivered but i got nothing', 'when will {item} ship', 'order {order} is stuck in processing', 'any update on {item}? it has been ten days', 'has order {order} shipped'],
29
+ change_address: ['i need to change my shipping address', 'please send order {order} to {city} instead', 'i moved to {city}, update my address', 'wrong address on order {order}', 'can i change where {item} gets delivered'],
30
+ cancel: ['cancel my subscription', 'how do i cancel {plan}', "please don't renew my plan", 'i want to close my account', 'cancel {plan} at the end of the month', 'end my membership please', 'how do i stop my subscription'],
31
+ upgrade: ['how do i upgrade to {plan}', 'what does {plan} cost', 'i need more seats for my team', 'switch me to {plan}', 'is there a discount for {plan}', 'can i try {plan} before paying', 'upgrade my account to {plan}'],
32
+ bug: ['the app crashes when i {action}', '{feature} is broken on {device}', 'getting error {code} when i {action}', "the page won't load on {device}", '{feature} has been down since this morning', 'error {code} every time i {action}', '{feature} shows a blank screen'],
33
+ feature_request: ['can you add {wish}', 'it would be great if you supported {integration}', 'please add {wish}', 'any plans for a {integration} integration', 'feature request: {wish}', 'would love {wish} in the app'],
34
+ human: ['talk to a human', 'can i speak to an agent', 'get me a real person', 'is there a phone number i can call', 'i need to talk to someone', 'transfer me to support staff'],
35
+ };
36
+
37
+ const VAGUE = ["it's not working", 'help', 'i have a problem with my account', 'something is wrong', 'please fix this', 'i need help with my order', 'this is ridiculous', 'hello?', 'are you there', 'why is this happening', 'ok', 'same problem as before'];
38
+ const PREFIX = ['', '', '', 'hi, ', 'hello, ', 'hey team, ', 'urgent: ', 'quick question: ', 'good morning, '];
39
+ const SUFFIX = ['', '', '', ' thanks', ' asap', ' please', '!!', ' thx', ' thank you'];
40
+
41
+ /**
42
+ * @param {{ seed?: number }} [options]
43
+ * @returns {{ labels: string[], next(): string, api(text: string): Promise<string>, truth(text: string): string, change(): string }}
44
+ */
45
+ export function supportApi({ seed = 1 } = {}) {
46
+ const rand = rng(seed);
47
+ const pick = (a) => a[Math.floor(rand() * a.length)];
48
+ const answers = new Map();
49
+ let changed = false;
50
+
51
+ function fill(template) {
52
+ return template.replace(/\{(\w+)\}/g, (_, k) => {
53
+ if (k === 'order') return `#${10000 + Math.floor(rand() * 90000)}`;
54
+ if (k === 'amount') return (5 + rand() * 195).toFixed(2);
55
+ return pick(SLOTS[k]);
56
+ });
57
+ }
58
+
59
+ function typo(text) {
60
+ const words = text.split(' ');
61
+ const i = Math.floor(rand() * words.length), w = words[i];
62
+ if (w.length < 4) return text;
63
+ const j = 1 + Math.floor(rand() * (w.length - 2));
64
+ words[i] = w.slice(0, j) + w[j + 1] + w[j] + w.slice(j + 2);
65
+ return words.join(' ');
66
+ }
67
+
68
+ // The label is fixed per text, as with a deterministic API: a hash picks among the candidates.
69
+ const coin = (text, salt) => (hashString(text, salt) % 10_000) / 10_000;
70
+
71
+ function next() {
72
+ const u = rand();
73
+ let text, label;
74
+ if (u < 0.12) {
75
+ text = pick(VAGUE);
76
+ const options = ['human', 'bug', 'login', 'order_status', 'billing'];
77
+ label = options[Math.floor(coin(text + rand().toFixed(1), 7) * options.length)];
78
+ } else if (u < 0.22) {
79
+ const a = pick(LABELS), b = pick(LABELS);
80
+ text = `${fill(pick(TEMPLATES[a]))}${pick([' and ', '. also ', ', plus '])}${fill(pick(TEMPLATES[b]))}`;
81
+ label = coin(text, 11) < 0.55 ? a : b;
82
+ } else {
83
+ label = pick(LABELS);
84
+ text = fill(pick(TEMPLATES[label]));
85
+ if (coin(text, 13) < 0.03) label = pick(LABELS); // an LLM is not perfectly consistent either
86
+ }
87
+ text = pick(PREFIX) + text + pick(SUFFIX);
88
+ if (rand() < 0.08) text = typo(text);
89
+ if (rand() < 0.2) text = text[0].toUpperCase() + text.slice(1);
90
+ if (!answers.has(text)) answers.set(text, label);
91
+ return text;
92
+ }
93
+
94
+ function truth(text) {
95
+ const label = answers.get(text) ?? route(text);
96
+ return changed && label === 'billing' ? 'refund' : label;
97
+ }
98
+
99
+ return {
100
+ labels: LABELS,
101
+ next,
102
+ truth,
103
+ api: async (text) => truth(text),
104
+ change() { changed = true; return 'your API now files duplicate and unexpected charges under refund'; },
105
+ };
106
+ }
107
+
108
+ /** A keyword router for texts the generator never produced, such as ones typed by hand. */
109
+ function route(text) {
110
+ const t = text.toLowerCase();
111
+ for (const [label, words] of [
112
+ ['refund', ['refund', 'money back', 'return']], ['billing', ['charge', 'billed', 'invoice']],
113
+ ['login', ['log in', 'password', 'sign in', '2fa', 'locked out']], ['order_status', ['where is', 'tracking', 'shipped', 'arrived']],
114
+ ['change_address', ['address']], ['cancel', ['cancel', 'renew', 'close my account']], ['upgrade', ['upgrade', 'seats', 'plan']],
115
+ ['bug', ['crash', 'error', 'broken', "won't load"]], ['feature_request', ['add ', 'support', 'integration']],
116
+ ]) if (words.some((w) => t.includes(w))) return label;
117
+ return 'human';
118
+ }