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 +21 -0
- package/README.md +255 -2
- package/bin/tinydecide.js +253 -0
- package/examples/support-api.js +118 -0
- package/package.json +56 -4
- package/src/compile.js +265 -0
- package/src/features.js +128 -0
- package/src/hash.js +87 -0
- package/src/index.js +5 -0
- package/src/log.js +55 -0
- package/src/model.js +154 -0
- package/src/node.js +210 -0
- package/src/shadow.js +415 -0
- package/src/stats.js +78 -0
- package/src/store.js +21 -0
- package/src/train.js +129 -0
- package/src/worker.js +12 -0
- package/types/index.d.ts +287 -0
package/src/shadow.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// shadow(): wrap a decision API so it keeps working exactly as before, while a local model learns
|
|
2
|
+
// from its answers and gradually takes over the calls it can answer within the agreement target.
|
|
3
|
+
//
|
|
4
|
+
// Modes
|
|
5
|
+
// learning every call goes to your API; every answer is logged
|
|
6
|
+
// local confident calls are answered locally, the rest go to your API
|
|
7
|
+
// guarded a live check failed or a new label appeared; every call goes to your API until a
|
|
8
|
+
// compile on post-incident answers certifies again
|
|
9
|
+
//
|
|
10
|
+
// A uniform random sample of calls (rate `audit`) is also answered by your API whatever the route.
|
|
11
|
+
// That sample is iid over traffic, so it drives both the live agreement check and later calibration;
|
|
12
|
+
// calls logged only because they were deferred are used for training, never for certifying.
|
|
13
|
+
//
|
|
14
|
+
// Two live checks can switch to guarded:
|
|
15
|
+
// audit a CUSUM test on the random sample finds overall agreement below target: about 2% false
|
|
16
|
+
// alarms over a model's life when agreement sits exactly at target, fewer above it
|
|
17
|
+
// deferred on deferred calls (answered by your API anyway, so free to check), the model's guess
|
|
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. It usually trips before the audit check.
|
|
20
|
+
//
|
|
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
|
+
|
|
24
|
+
import { compile } from './compile.js';
|
|
25
|
+
import { logger } from './log.js';
|
|
26
|
+
import { wilson } from './stats.js';
|
|
27
|
+
import { memoryStore } from './store.js';
|
|
28
|
+
|
|
29
|
+
const GUARD_Z = 3; // strict, because the guard is re-checked on every observation
|
|
30
|
+
const CUSUM_ALTS = [2, 4, 8]; // error rates the audit check watches for, as multiples of 1 − target
|
|
31
|
+
const CUSUM_H = 9;
|
|
32
|
+
const DEFERRED_WINDOW = 400;
|
|
33
|
+
const DEFERRED_MIN = 100;
|
|
34
|
+
const DEFERRED_SLACK = 0.05;
|
|
35
|
+
const LABEL_WINDOW = 200; // deferred calls per label the model leans toward
|
|
36
|
+
const LABEL_MIN = 20;
|
|
37
|
+
const LABEL_Z = 4; // stricter still: one check per label
|
|
38
|
+
const LABEL_SLACK = 0.1; // room for the model's confidence being somewhat off on one label
|
|
39
|
+
const LABEL_KEYS = ['choice', 'label', 'decision', 'answer'];
|
|
40
|
+
const PROBS_KEYS = ['probs', 'probabilities'];
|
|
41
|
+
const OPTIONS = ['target', 'delta', 'audit', 'labels', 'model', 'store', 'choice', 'respond', 'group', 'fallback', 'log', 'onEvent', 'autoCompile', 'compile', 'compiler', 'random'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} [name] names the decision in logs; in Node it also names its folder in .tinydecide/
|
|
45
|
+
* @param {(input: any) => any} api your existing decision call
|
|
46
|
+
* @param {object} [options] see README
|
|
47
|
+
*/
|
|
48
|
+
export function shadow(name, api, options) {
|
|
49
|
+
if (typeof name === 'function') [name, api, options] = [undefined, name, api];
|
|
50
|
+
options ??= {};
|
|
51
|
+
validate(name, api, options);
|
|
52
|
+
const o = {
|
|
53
|
+
target: 0.98, delta: 0.05, audit: 0.02, labels: undefined, model: null, store: null,
|
|
54
|
+
choice: defaultChoice, respond: undefined, group: undefined, fallback: false, log: true,
|
|
55
|
+
onEvent: () => {}, compile: {}, compiler: compile, random: Math.random,
|
|
56
|
+
...options,
|
|
57
|
+
};
|
|
58
|
+
o.labels ??= o.model?.labels; // a model you pass in fixes the label order
|
|
59
|
+
const auto = o.autoCompile === false ? null : { min: 2000, growth: 2, maxEvery: 20_000, window: 50_000, ...o.autoCompile };
|
|
60
|
+
const store = o.store ?? memoryStore();
|
|
61
|
+
const say = logger(name, o.log);
|
|
62
|
+
|
|
63
|
+
let model = null;
|
|
64
|
+
let mode = 'learning';
|
|
65
|
+
let shape = null; // how your API's responses look, so local answers come back the same way
|
|
66
|
+
let incident = null; // { t } while guarded
|
|
67
|
+
let epoch = 0; // bumps on every guard trip; a compile that started in an older epoch is discarded
|
|
68
|
+
let compiling = null;
|
|
69
|
+
let running = null; // AbortController of the compile in flight, so a guard trip can stop it
|
|
70
|
+
let closed = false;
|
|
71
|
+
let unreadable = false;
|
|
72
|
+
// The compile schedule: answers available, answers since the last compile, and rows it used.
|
|
73
|
+
let available = 0, fresh = 0, basis = 0, sinceIncident = 0;
|
|
74
|
+
const ring = new Uint8Array(500); // recent audits, for stats and the guard message
|
|
75
|
+
let ringN = 0, ringAgree = 0, ringAt = 0;
|
|
76
|
+
const alpha = 1 - o.target;
|
|
77
|
+
const steps = CUSUM_ALTS.map((m) => { const p = Math.min(0.5, m * alpha); return [Math.log((1 - p) / (1 - alpha)), Math.log(p / alpha)]; });
|
|
78
|
+
const cusum = CUSUM_ALTS.map(() => 0);
|
|
79
|
+
const dring = new Uint8Array(DEFERRED_WINDOW);
|
|
80
|
+
let dN = 0, dAgree = 0, dAt = 0;
|
|
81
|
+
const byLabel = new Map(); // label the model leans toward → its deferred calls: agreed, and confidence
|
|
82
|
+
const n = { calls: 0, local: 0, api: 0, fallback: 0, audits: 0, auditDisagree: 0, compiles: 0, errors: 0 };
|
|
83
|
+
|
|
84
|
+
const emit = (e) => {
|
|
85
|
+
e = { t: Date.now(), ...e };
|
|
86
|
+
say(e);
|
|
87
|
+
try { o.onEvent(e); } catch {}
|
|
88
|
+
};
|
|
89
|
+
const fail = (where, err) => { n.errors++; emit({ type: 'error', where, error: String(err?.message ?? err) }); };
|
|
90
|
+
const NOPE = Symbol('failed');
|
|
91
|
+
/** Runs fn; on a throw (or rejection) reports it and returns NOPE, so the caller can route around it. */
|
|
92
|
+
function safely(where, fn) {
|
|
93
|
+
try {
|
|
94
|
+
const v = fn();
|
|
95
|
+
if (v && typeof v.then === 'function') return v.then((x) => x, (err) => { fail(where, err); return NOPE; });
|
|
96
|
+
return v;
|
|
97
|
+
} catch (err) { fail(where, err); return NOPE; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Startup: resume from the store when it has a model or a guarded state ──────────────────────────
|
|
101
|
+
|
|
102
|
+
function adopt(saved, state) {
|
|
103
|
+
available = store.size ?? 0;
|
|
104
|
+
if (saved && saved !== NOPE) {
|
|
105
|
+
model = saved;
|
|
106
|
+
shape = saved.meta?.response ?? null;
|
|
107
|
+
basis = saved.meta?.rows?.total ?? 0;
|
|
108
|
+
mode = 'local';
|
|
109
|
+
}
|
|
110
|
+
if (state && state !== NOPE && state.guardedSince) {
|
|
111
|
+
mode = 'guarded';
|
|
112
|
+
incident = { t: state.guardedSince };
|
|
113
|
+
basis = 0;
|
|
114
|
+
}
|
|
115
|
+
if (mode === 'local') emit({ type: 'start', mode, share: model.meta?.expected?.share ?? 0, target: model.meta?.certificate?.target ?? o.target });
|
|
116
|
+
else if (mode === 'guarded') emit({ type: 'start', mode, since: incident.t });
|
|
117
|
+
else if (auto) emit({ type: 'start', mode, min: auto.min, logged: available });
|
|
118
|
+
}
|
|
119
|
+
const saved = o.model ?? safely('loading the saved model', () => store.loadModel?.() ?? null);
|
|
120
|
+
const state = safely('loading the saved state', () => store.loadState?.() ?? null);
|
|
121
|
+
const ready = [saved, state].some((v) => typeof v?.then === 'function')
|
|
122
|
+
? Promise.all([saved, state]).then(([m, s]) => adopt(m, s))
|
|
123
|
+
: Promise.resolve(adopt(saved, state));
|
|
124
|
+
ready.then(() => maybeCompile());
|
|
125
|
+
|
|
126
|
+
// ── Live checks ────────────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function observe(agree) {
|
|
129
|
+
if (ringN === ring.length) ringAgree -= ring[ringAt]; else ringN++;
|
|
130
|
+
ring[ringAt] = agree ? 1 : 0;
|
|
131
|
+
ringAgree += ring[ringAt];
|
|
132
|
+
ringAt = (ringAt + 1) % ring.length;
|
|
133
|
+
if (mode !== 'local') return;
|
|
134
|
+
let alarm = false;
|
|
135
|
+
for (let j = 0; j < cusum.length; j++) {
|
|
136
|
+
cusum[j] = Math.max(0, cusum[j] + steps[j][agree ? 0 : 1]);
|
|
137
|
+
if (cusum[j] >= CUSUM_H) alarm = true;
|
|
138
|
+
}
|
|
139
|
+
if (alarm) trip('drift', { slice: 'audit', agreement: ringAgree / ringN, sampled: ringN, target: o.target });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function observeDeferred(guess, confidence, agree) {
|
|
143
|
+
if (dN === dring.length) dAgree -= dring[dAt]; else dN++;
|
|
144
|
+
dring[dAt] = agree ? 1 : 0;
|
|
145
|
+
dAgree += dring[dAt];
|
|
146
|
+
dAt = (dAt + 1) % dring.length;
|
|
147
|
+
const base = model?.meta?.expected?.deferred;
|
|
148
|
+
if (mode !== 'local' || !base) return;
|
|
149
|
+
if (dN >= DEFERRED_MIN && base.n >= 50) {
|
|
150
|
+
const upper = wilson(dAgree, dN, GUARD_Z)[1];
|
|
151
|
+
if (upper < base.agreement - DEFERRED_SLACK) return trip('drift', { slice: 'deferred', agreement: dAgree / dN, baseline: base.agreement, sampled: dN, upper });
|
|
152
|
+
}
|
|
153
|
+
// Per label: the model's calibrated confidence says how often your API should agree with its
|
|
154
|
+
// guess. Far fewer agreements than that, beyond chance and slack, means your API changed.
|
|
155
|
+
let r = byLabel.get(guess);
|
|
156
|
+
if (!r) byLabel.set(guess, (r = { hit: new Uint8Array(LABEL_WINDOW), p: new Float64Array(LABEL_WINDOW), n: 0, at: 0 }));
|
|
157
|
+
r.hit[r.at] = agree ? 1 : 0;
|
|
158
|
+
r.p[r.at] = confidence;
|
|
159
|
+
r.at = (r.at + 1) % LABEL_WINDOW;
|
|
160
|
+
if (r.n < LABEL_WINDOW) r.n++;
|
|
161
|
+
if (r.n < LABEL_MIN) return;
|
|
162
|
+
let seen = 0, expected = 0, variance = 0;
|
|
163
|
+
for (let k = 0; k < r.n; k++) { seen += r.hit[k]; expected += r.p[k]; variance += r.p[k] * (1 - r.p[k]); }
|
|
164
|
+
const ratio = Math.min(1, base.ratio?.[guess] ?? 1); // how well confidence predicted agreement on calibration
|
|
165
|
+
expected *= ratio;
|
|
166
|
+
variance *= ratio;
|
|
167
|
+
if (seen < expected - LABEL_SLACK * r.n - LABEL_Z * Math.sqrt(variance)) {
|
|
168
|
+
trip('drift', { slice: 'deferred', label: guess, agreement: seen / r.n, baseline: expected / r.n, sampled: r.n });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resetChecks() { ringN = ringAgree = ringAt = 0; dN = dAgree = dAt = 0; byLabel.clear(); cusum.fill(0); }
|
|
173
|
+
|
|
174
|
+
function trip(reason, detail) {
|
|
175
|
+
if (mode === 'guarded') return;
|
|
176
|
+
mode = 'guarded';
|
|
177
|
+
epoch++;
|
|
178
|
+
running?.abort();
|
|
179
|
+
incident = { t: Date.now() };
|
|
180
|
+
basis = fresh = sinceIncident = 0;
|
|
181
|
+
resetChecks();
|
|
182
|
+
persist('saving the guarded state', () => store.saveState?.({ guardedSince: incident.t }));
|
|
183
|
+
emit({ type: 'guard', reason, ...detail });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Calls ──────────────────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async function detailed(input) {
|
|
189
|
+
n.calls++;
|
|
190
|
+
const t0 = performance.now();
|
|
191
|
+
const sample = mode !== 'local' || o.random() < o.audit;
|
|
192
|
+
let d = null;
|
|
193
|
+
// Until your API has given one readable response, its shape is unknown, so it answers.
|
|
194
|
+
if (mode === 'local' && model && (shape || o.respond)) {
|
|
195
|
+
d = safely('the local model', () => model.decide(input, { probs: wantsProbs() }));
|
|
196
|
+
if (d === NOPE) d = null;
|
|
197
|
+
if (d?.local) {
|
|
198
|
+
const value = safely('respond', () => answer(d));
|
|
199
|
+
if (value !== NOPE) {
|
|
200
|
+
n.local++;
|
|
201
|
+
if (sample) audit(input, d.choice);
|
|
202
|
+
return { value, source: 'local', choice: d.choice, confidence: d.confidence, ms: performance.now() - t0 };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let resp;
|
|
207
|
+
try {
|
|
208
|
+
resp = await api(input);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (!o.fallback || !model) throw err;
|
|
211
|
+
d ??= safely('the local model', () => model.decide(input, { probs: wantsProbs() }));
|
|
212
|
+
const value = d && d !== NOPE ? safely('respond', () => answer(d)) : NOPE;
|
|
213
|
+
if (value === NOPE) throw err;
|
|
214
|
+
n.fallback++;
|
|
215
|
+
return { value, source: 'fallback', choice: d.choice, confidence: d.confidence, ms: performance.now() - t0 };
|
|
216
|
+
}
|
|
217
|
+
n.api++;
|
|
218
|
+
const choice = record(input, resp, sample);
|
|
219
|
+
if (mode === 'local' && d && choice != null) observeDeferred(d.choice, d.confidence, d.choice === choice);
|
|
220
|
+
if (sample && mode === 'local') observe(true);
|
|
221
|
+
return { value: resp, source: 'api', choice, confidence: null, ms: performance.now() - t0 };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function audit(input, localChoice) {
|
|
225
|
+
n.audits++;
|
|
226
|
+
Promise.resolve().then(() => api(input)).then((resp) => {
|
|
227
|
+
const choice = record(input, resp, true);
|
|
228
|
+
if (choice == null) return;
|
|
229
|
+
const agree = choice === localChoice;
|
|
230
|
+
if (!agree) n.auditDisagree++;
|
|
231
|
+
emit({ type: 'audit', agree, local: localChoice, api: choice });
|
|
232
|
+
if (mode === 'local') observe(agree);
|
|
233
|
+
}, () => {}); // a failed audit is skipped
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Logs one answer from your API. Returns the label, or null when none could be read. */
|
|
237
|
+
function record(input, resp, sample) {
|
|
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
|
+
if (choice == null) {
|
|
242
|
+
if (!unreadable) { unreadable = true; emit({ type: 'unreadable', preview: preview(resp) }); }
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
shape ??= shapeOf(resp); // learned from a response whose label could be read
|
|
246
|
+
// A label nobody has seen yet joins the label set, so the next compile can learn it.
|
|
247
|
+
if (o.labels && !o.labels.includes(choice)) o.labels = [...o.labels, choice];
|
|
248
|
+
if (model && !model.labels.includes(choice)) trip('schema', { label: choice });
|
|
249
|
+
const rec = { t: Date.now(), input, choice };
|
|
250
|
+
if (read.probs) rec.probs = read.probs;
|
|
251
|
+
if (o.group) {
|
|
252
|
+
const g = safely('group', () => o.group(input));
|
|
253
|
+
if (g != null && g !== NOPE) rec.group = String(g);
|
|
254
|
+
}
|
|
255
|
+
if (!sample) rec.sample = false;
|
|
256
|
+
if (safely('saving an answer', () => store.append(rec)) !== NOPE) {
|
|
257
|
+
available++; fresh++;
|
|
258
|
+
if (mode === 'guarded') sinceIncident++;
|
|
259
|
+
maybeCompile();
|
|
260
|
+
}
|
|
261
|
+
return choice;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const wantsProbs = () => !o.respond && shape?.probsKey != null;
|
|
265
|
+
|
|
266
|
+
function answer(d) {
|
|
267
|
+
if (o.respond) return o.respond(d, shape);
|
|
268
|
+
if (!shape || shape.kind === 'string') return 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 };
|
|
271
|
+
if (shape.probsKey && d.probs) out[shape.probsKey] = shape.probsArray ? model.labels.map((l) => d.probs[l]) : d.probs;
|
|
272
|
+
if (shape.confidence) out.confidence = d.confidence;
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Compiles ───────────────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function maybeCompile() {
|
|
279
|
+
if (!auto || compiling || closed) return;
|
|
280
|
+
const pool = mode === 'guarded' ? sinceIncident : available;
|
|
281
|
+
if (pool < auto.min) return;
|
|
282
|
+
const gap = basis ? Math.min(auto.maxEvery, Math.max(auto.min, basis * (auto.growth - 1))) : 0;
|
|
283
|
+
if (fresh >= gap) recompile({ background: true }).catch((err) => fail('compile', err));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function recompile({ background = false } = {}) {
|
|
287
|
+
if (compiling) return compiling;
|
|
288
|
+
if (closed) return null;
|
|
289
|
+
compiling = (async () => {
|
|
290
|
+
await ready;
|
|
291
|
+
const started = epoch;
|
|
292
|
+
const recovering = incident != null;
|
|
293
|
+
fresh = 0;
|
|
294
|
+
let rows = await store.load();
|
|
295
|
+
if (incident) rows = rows.filter((r) => r.t >= incident.t);
|
|
296
|
+
rows = rows.slice(-(auto?.window ?? 50_000));
|
|
297
|
+
emit({ type: 'compile:start', rows: rows.length });
|
|
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);
|
|
301
|
+
const ctrl = (running = new AbortController());
|
|
302
|
+
let result = null;
|
|
303
|
+
try {
|
|
304
|
+
result = await o.compiler(rows, {
|
|
305
|
+
...o.compile, target: o.target, delta: o.delta, audit: o.audit, labels, signal: ctrl.signal, background,
|
|
306
|
+
});
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err?.name !== 'AbortError') throw err;
|
|
309
|
+
} finally {
|
|
310
|
+
running = null;
|
|
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);
|
|
313
|
+
}
|
|
314
|
+
if (!result || epoch !== started || closed) {
|
|
315
|
+
// The guard tripped while this compile ran, so its rows straddle the change. Never install it.
|
|
316
|
+
emit({ type: 'discard', reason: closed ? 'closed' : 'guard tripped during compile' });
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const { model: next, report } = result;
|
|
320
|
+
emit({ type: 'compile', report, recovering });
|
|
321
|
+
if (report.calib.share > 0) {
|
|
322
|
+
next.meta.response = shape;
|
|
323
|
+
model = next;
|
|
324
|
+
mode = 'local';
|
|
325
|
+
incident = null;
|
|
326
|
+
resetChecks();
|
|
327
|
+
emit({ type: 'install', share: report.calib.share, verdict: report.verdict, model: next, report });
|
|
328
|
+
await persist('saving the model', () => store.saveModel?.(next, report));
|
|
329
|
+
await persist('saving the state', () => store.saveState?.(null));
|
|
330
|
+
}
|
|
331
|
+
return report;
|
|
332
|
+
})();
|
|
333
|
+
try { return await compiling; } finally { compiling = null; maybeCompile(); }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function persist(where, fn) {
|
|
337
|
+
const v = safely(where, fn);
|
|
338
|
+
return v && typeof v.then === 'function' ? v : Promise.resolve(v);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── The decide function ────────────────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
const decide = async (input) => (await detailed(input)).value;
|
|
344
|
+
decide.detailed = detailed;
|
|
345
|
+
decide.local = (input) => (model ? model.decide(input) : null);
|
|
346
|
+
decide.compile = () => recompile();
|
|
347
|
+
decide.ready = ready;
|
|
348
|
+
decide.close = async () => {
|
|
349
|
+
closed = true;
|
|
350
|
+
running?.abort();
|
|
351
|
+
try { await compiling; } catch {}
|
|
352
|
+
await persist('closing the store', () => store.close?.());
|
|
353
|
+
};
|
|
354
|
+
decide.stats = () => ({
|
|
355
|
+
name: name ?? null, mode, ...n, logged: store.size ?? available,
|
|
356
|
+
share: n.calls ? n.local / n.calls : 0,
|
|
357
|
+
apiCallsSaved: n.local - n.audits,
|
|
358
|
+
live: { sampled: ringN, agreement: ringN ? ringAgree / ringN : null, ci: ringN ? wilson(ringAgree, ringN) : null },
|
|
359
|
+
deferred: { checked: dN, agreement: dN ? dAgree / dN : null, baseline: model?.meta?.expected?.deferred?.agreement ?? null },
|
|
360
|
+
model: model ? { labels: model.labels.length, cutoff: model.cutoff, expectedShare: model.meta?.expected?.share ?? null, created: model.meta?.created ?? null } : null,
|
|
361
|
+
});
|
|
362
|
+
Object.defineProperty(decide, 'name', { value: name ?? 'decide' });
|
|
363
|
+
Object.defineProperty(decide, 'model', { get: () => model });
|
|
364
|
+
Object.defineProperty(decide, 'mode', { get: () => mode });
|
|
365
|
+
if (Symbol.asyncDispose) decide[Symbol.asyncDispose] = decide.close;
|
|
366
|
+
return decide;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Reads { choice, probs } from common response shapes: a label string, or { choice | label | decision | answer, probs }. */
|
|
370
|
+
export function defaultChoice(resp, labels) {
|
|
371
|
+
if (typeof resp === 'string') return { choice: resp };
|
|
372
|
+
if (!resp || typeof resp !== 'object') return { choice: null };
|
|
373
|
+
let choice = resp.choice ?? resp.label ?? resp.decision ?? resp.answer ?? null;
|
|
374
|
+
if (typeof choice === 'number' && labels) choice = labels[choice];
|
|
375
|
+
let probs = resp.probs ?? resp.probabilities ?? null;
|
|
376
|
+
if (Array.isArray(probs)) probs = labels ? Object.fromEntries(labels.map((l, k) => [l, probs[k]])) : null;
|
|
377
|
+
return { choice: choice == null ? null : String(choice), probs };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The parts of a response local answers mirror: the label's key and type, and probs and confidence if present. */
|
|
381
|
+
function shapeOf(resp) {
|
|
382
|
+
if (typeof resp === 'string') return { kind: 'string' };
|
|
383
|
+
if (!resp || typeof resp !== 'object') return null;
|
|
384
|
+
const key = LABEL_KEYS.find((k) => resp[k] != null) ?? 'choice';
|
|
385
|
+
const probsKey = PROBS_KEYS.find((k) => resp[k] != null) ?? null;
|
|
386
|
+
return { kind: 'object', key, numeric: typeof resp[key] === 'number', probsKey, probsArray: Array.isArray(resp[probsKey]), confidence: 'confidence' in resp };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function preview(resp) {
|
|
390
|
+
let s;
|
|
391
|
+
try { s = JSON.stringify(resp); } catch { s = String(resp); }
|
|
392
|
+
s ??= String(resp);
|
|
393
|
+
return s.length > 120 ? `${s.slice(0, 117)}...` : s;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
397
|
+
|
|
398
|
+
export function validate(name, api, options) {
|
|
399
|
+
const fail = (msg) => { throw new TypeError(`tinydecide: ${msg}`); };
|
|
400
|
+
if (name !== undefined && (typeof name !== 'string' || !NAME.test(name))) {
|
|
401
|
+
fail(`the name must be 1–64 letters, digits, dots, dashes or underscores, like "support-intent"; got ${JSON.stringify(name)}`);
|
|
402
|
+
}
|
|
403
|
+
if (typeof api !== 'function') fail(`shadow(${name ? `"${name}", ` : ''}api) needs your API call as a function, like (text) => classify(text); got ${typeof api}`);
|
|
404
|
+
if (typeof options !== 'object') fail(`options must be an object; got ${typeof options}`);
|
|
405
|
+
for (const k of Object.keys(options)) {
|
|
406
|
+
if (k === 'dir') fail('"dir" needs a named decision in Node, like shadow("support", api, { dir })');
|
|
407
|
+
if (!OPTIONS.includes(k)) fail(`unknown option "${k}". Options: ${OPTIONS.join(', ')}`);
|
|
408
|
+
}
|
|
409
|
+
const unit = (k, lo, hi) => {
|
|
410
|
+
const v = options[k];
|
|
411
|
+
if (v !== undefined && !(typeof v === 'number' && v > lo && v < hi)) fail(`${k} must be a number between ${lo} and ${hi}; got ${v}`);
|
|
412
|
+
};
|
|
413
|
+
unit('target', 0, 1); unit('delta', 0, 1);
|
|
414
|
+
if (options.audit !== undefined && !(options.audit >= 0 && options.audit <= 1)) fail(`audit must be a rate from 0 to 1; got ${options.audit}`);
|
|
415
|
+
}
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Binomial tails, Wilson intervals, and Learn-Then-Test cutoff certification.
|
|
2
|
+
|
|
3
|
+
/** log P(X ≤ k) for X ~ Binomial(n, p), stable for large n. */
|
|
4
|
+
export function logBinomCdf(k, n, p) {
|
|
5
|
+
if (k < 0) return -Infinity;
|
|
6
|
+
if (k >= n || p <= 0) return 0;
|
|
7
|
+
if (p >= 1) return -Infinity;
|
|
8
|
+
const lp = Math.log(p), lq = Math.log1p(-p);
|
|
9
|
+
let term = n * lq, acc = term;
|
|
10
|
+
for (let i = 0; i < k; i++) {
|
|
11
|
+
term += Math.log((n - i) / (i + 1)) + lp - lq;
|
|
12
|
+
acc = acc > term ? acc + Math.log1p(Math.exp(term - acc)) : term + Math.log1p(Math.exp(acc - term));
|
|
13
|
+
}
|
|
14
|
+
return Math.min(0, acc);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function binomCdf(k, n, p) { return Math.exp(logBinomCdf(k, n, p)); }
|
|
18
|
+
|
|
19
|
+
/** Wilson score interval for x successes in n trials. */
|
|
20
|
+
export function wilson(x, n, z = 1.959963984540054) {
|
|
21
|
+
if (n === 0) return [0, 1];
|
|
22
|
+
const ph = x / n, z2 = z * z, den = 1 + z2 / n;
|
|
23
|
+
const mid = (ph + z2 / (2 * n)) / den;
|
|
24
|
+
const half = (z * Math.sqrt((ph * (1 - ph)) / n + z2 / (4 * n * n))) / den;
|
|
25
|
+
return [Math.max(0, mid - half), Math.min(1, mid + half)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Learn-Then-Test with fixed-sequence testing (Angelopoulos et al., 2021).
|
|
30
|
+
* Walks the cutoffs from strictest to loosest and keeps the loosest one whose risk is certified ≤ alpha
|
|
31
|
+
* at level delta; it stops at the first cutoff that fails. The grid must not be chosen from the
|
|
32
|
+
* calibration rows themselves.
|
|
33
|
+
*
|
|
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
|
+
*
|
|
37
|
+
* @param {Float64Array|number[]} score per calibration row; answered locally when score ≥ cutoff
|
|
38
|
+
* @param {Uint8Array|boolean[]} wrong per calibration row; the local answer differs from the API's
|
|
39
|
+
* @param {number[]} grid candidate cutoffs, any order
|
|
40
|
+
* @returns {{ cutoff: number, local: number, errors: number, pValue: number, tested: number }}
|
|
41
|
+
*/
|
|
42
|
+
export function certify(score, wrong, grid, { alpha, delta = 0.05, guarantee = 'overall' }) {
|
|
43
|
+
const n = score.length;
|
|
44
|
+
const order = Array.from(score.keys()).sort((i, j) => score[j] - score[i]);
|
|
45
|
+
const cuts = [...new Set(grid)].sort((a, b) => b - a);
|
|
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;
|
|
51
|
+
let best = { cutoff: Infinity, local: 0, errors: 0, pValue: 0, tested: 0 };
|
|
52
|
+
let i = 0, local = 0, errors = 0;
|
|
53
|
+
for (let c = 0; c < cuts.length; c++) {
|
|
54
|
+
const cut = cuts[c];
|
|
55
|
+
while (i < n && score[order[i]] >= cut) { local++; errors += wrong[order[i]] ? 1 : 0; i++; }
|
|
56
|
+
const trials = guarantee === 'local' ? local : n;
|
|
57
|
+
if (trials < floor) continue;
|
|
58
|
+
const pValue = binomCdf(errors, trials, alpha);
|
|
59
|
+
if (pValue > delta) return { ...best, tested: c + 1 };
|
|
60
|
+
best = { cutoff: cut, local, errors, pValue, tested: c + 1 };
|
|
61
|
+
}
|
|
62
|
+
return best;
|
|
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
|
+
|
|
75
|
+
/** Smallest calibration size at which an error-free sample can certify alpha at level delta. */
|
|
76
|
+
export function minCalibration(alpha, delta = 0.05) {
|
|
77
|
+
return Math.ceil(Math.log(delta) / Math.log1p(-alpha));
|
|
78
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Where shadow() keeps your API's answers, the installed model, and whether it is guarded.
|
|
2
|
+
//
|
|
3
|
+
// A store needs append(record), load() → recent records (oldest first) and size. The model and
|
|
4
|
+
// state methods are optional; without them a restart starts from scratch, which is always safe.
|
|
5
|
+
// The file store lives in ./node.js so this module stays browser-safe.
|
|
6
|
+
|
|
7
|
+
/** Keeps everything in memory: the default in browsers and workers. */
|
|
8
|
+
export function memoryStore({ limit = 100_000, records = [] } = {}) {
|
|
9
|
+
const rows = records.slice(-limit);
|
|
10
|
+
let model = null, state = null;
|
|
11
|
+
return {
|
|
12
|
+
append(record) { rows.push(record); if (rows.length > limit * 1.25) rows.splice(0, rows.length - limit); },
|
|
13
|
+
load() { return rows.slice(-limit); },
|
|
14
|
+
get size() { return rows.length; },
|
|
15
|
+
get records() { return rows; },
|
|
16
|
+
loadModel: () => model,
|
|
17
|
+
saveModel(m) { model = m; },
|
|
18
|
+
loadState: () => state,
|
|
19
|
+
saveState(s) { state = s; },
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/train.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// fastText-style trainer: mean-pooled hashed features → linear head, plain SGD with linear decay,
|
|
2
|
+
// distilling your API's soft labels (one-hot when it gives only a label).
|
|
3
|
+
|
|
4
|
+
import { U32Map, rng } from './hash.js';
|
|
5
|
+
|
|
6
|
+
export const RECIPE = Object.freeze({ d: 32, lr: 0.5, minCount: 2, maxFeatures: 200_000, steps: 200_000, minEpochs: 5, maxEpochs: 25 });
|
|
7
|
+
|
|
8
|
+
export const tick = makeTick();
|
|
9
|
+
|
|
10
|
+
// Yield to the event loop between chunks of work. Browsers clamp nested setTimeout to 4 ms, so use a
|
|
11
|
+
// MessageChannel there; Node has setImmediate.
|
|
12
|
+
function makeTick() {
|
|
13
|
+
if (typeof setImmediate === 'function') return () => new Promise((r) => setImmediate(r));
|
|
14
|
+
if (typeof MessageChannel === 'function') {
|
|
15
|
+
const ch = new MessageChannel(), queue = [];
|
|
16
|
+
ch.port1.onmessage = () => queue.shift()?.();
|
|
17
|
+
return () => new Promise((r) => { queue.push(r); ch.port2.postMessage(0); });
|
|
18
|
+
}
|
|
19
|
+
return () => new Promise((r) => setTimeout(r, 0));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ w: Uint32Array, c: Uint32Array, t: Float32Array }[]} rows features and target distribution; remapped in place
|
|
24
|
+
* @param {number} K
|
|
25
|
+
* @returns {Promise<{ d, keys: Uint32Array, E: Float32Array, W: Float32Array, b: Float32Array, epochs: number }>}
|
|
26
|
+
*/
|
|
27
|
+
export async function train(rows, K, opts = {}) {
|
|
28
|
+
const { d, lr, minCount, maxFeatures, steps, minEpochs, maxEpochs } = { ...RECIPE, ...opts };
|
|
29
|
+
const seed = opts.seed ?? 1;
|
|
30
|
+
const onProgress = opts.onProgress ?? (() => {});
|
|
31
|
+
const yieldMs = opts.yieldMs ?? 30;
|
|
32
|
+
|
|
33
|
+
// Vocabulary: features seen at least minCount times, the most frequent maxFeatures of them.
|
|
34
|
+
const counts = new U32Map(1 << 16);
|
|
35
|
+
for (const r of rows) { for (const f of r.w) counts.inc(f); for (const f of r.c) counts.inc(f); }
|
|
36
|
+
let kept = [];
|
|
37
|
+
counts.forEach((key, n) => { if (n >= minCount) kept.push([key, n]); });
|
|
38
|
+
if (kept.length > maxFeatures) kept = kept.sort((a, b) => b[1] - a[1] || a[0] - b[0]).slice(0, maxFeatures);
|
|
39
|
+
const keys = Uint32Array.from(kept.map((e) => e[0])).sort();
|
|
40
|
+
const index = new U32Map(keys.length);
|
|
41
|
+
for (let i = 0; i < keys.length; i++) index.set(keys[i], i);
|
|
42
|
+
const ids = (feats) => {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const f of feats) { const r = index.get(f); if (r >= 0) out.push(r); }
|
|
45
|
+
return Int32Array.from(out);
|
|
46
|
+
};
|
|
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;
|
|
52
|
+
|
|
53
|
+
const V = keys.length;
|
|
54
|
+
const E = new Float32Array(V * d);
|
|
55
|
+
const W = new Float32Array(K * d);
|
|
56
|
+
const b = new Float32Array(K);
|
|
57
|
+
const rand = rng(seed);
|
|
58
|
+
const a = Math.sqrt(6 / (d + K));
|
|
59
|
+
for (let i = 0; i < W.length; i++) W[i] = (rand() * 2 - 1) * a;
|
|
60
|
+
// Start the bias at the API's average distribution.
|
|
61
|
+
const prior = new Float64Array(K);
|
|
62
|
+
for (const r of data) for (let k = 0; k < K; k++) prior[k] += r.t[k];
|
|
63
|
+
for (let k = 0; k < K; k++) b[k] = Math.log((prior[k] + 1) / (data.length + K));
|
|
64
|
+
|
|
65
|
+
const N = data.length;
|
|
66
|
+
const epochs = Math.max(minEpochs, Math.min(maxEpochs, Math.round(steps / Math.max(1, N))));
|
|
67
|
+
const total = epochs * N;
|
|
68
|
+
const h = new Float32Array(d), hw = new Float32Array(d), hc = new Float32Array(d), gh = new Float32Array(d);
|
|
69
|
+
const p = new Float32Array(K);
|
|
70
|
+
const order = Int32Array.from({ length: N }, (_, i) => i);
|
|
71
|
+
let step = 0, last = performance.now();
|
|
72
|
+
|
|
73
|
+
for (let ep = 0; ep < epochs; ep++) {
|
|
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; }
|
|
75
|
+
for (let s = 0; s < N; s++, step++) {
|
|
76
|
+
const ex = data[order[s]];
|
|
77
|
+
const rate = lr * (1 - step / total);
|
|
78
|
+
const nw = ex.w.length, nc = ex.c.length;
|
|
79
|
+
if (nw + nc === 0) continue;
|
|
80
|
+
meanInto(hw, E, ex.w, d); meanInto(hc, E, ex.c, d);
|
|
81
|
+
for (let j = 0; j < d; j++) h[j] = hw[j] + hc[j];
|
|
82
|
+
let m = -Infinity;
|
|
83
|
+
for (let k = 0; k < K; k++) {
|
|
84
|
+
let z = b[k];
|
|
85
|
+
const o = k * d;
|
|
86
|
+
for (let j = 0; j < d; j++) z += W[o + j] * h[j];
|
|
87
|
+
p[k] = z; if (z > m) m = z;
|
|
88
|
+
}
|
|
89
|
+
let sum = 0;
|
|
90
|
+
for (let k = 0; k < K; k++) sum += p[k] = Math.exp(p[k] - m);
|
|
91
|
+
gh.fill(0);
|
|
92
|
+
for (let k = 0; k < K; k++) {
|
|
93
|
+
const g = p[k] / sum - ex.t[k];
|
|
94
|
+
if (g === 0) continue;
|
|
95
|
+
const o = k * d;
|
|
96
|
+
for (let j = 0; j < d; j++) { gh[j] += W[o + j] * g; W[o + j] -= rate * g * h[j]; }
|
|
97
|
+
b[k] -= rate * g;
|
|
98
|
+
}
|
|
99
|
+
if (nw) scatter(E, ex.w, gh, -rate / nw, d);
|
|
100
|
+
if (nc) scatter(E, ex.c, gh, -rate / nc, d);
|
|
101
|
+
if ((step & 255) === 0 && performance.now() - last > yieldMs) {
|
|
102
|
+
onProgress({ phase: 'train', done: step / total });
|
|
103
|
+
await tick();
|
|
104
|
+
if (opts.signal?.aborted) throw abortError();
|
|
105
|
+
last = performance.now();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
onProgress({ phase: 'train', done: 1 });
|
|
110
|
+
return { d, keys, E, W, b, epochs };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function meanInto(out, E, ids, d) {
|
|
114
|
+
out.fill(0);
|
|
115
|
+
const n = ids.length;
|
|
116
|
+
if (!n) return;
|
|
117
|
+
for (let i = 0; i < n; i++) { const o = ids[i] * d; for (let j = 0; j < d; j++) out[j] += E[o + j]; }
|
|
118
|
+
for (let j = 0; j < d; j++) out[j] /= n;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scatter(E, ids, g, scale, d) {
|
|
122
|
+
for (let i = 0; i < ids.length; i++) { const o = ids[i] * d; for (let j = 0; j < d; j++) E[o + j] += scale * g[j]; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function abortError() {
|
|
126
|
+
const err = new Error('compile aborted');
|
|
127
|
+
err.name = 'AbortError';
|
|
128
|
+
return err;
|
|
129
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Runs one compile off the main thread and sends the model back as bytes.
|
|
2
|
+
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
4
|
+
import { compile } from './compile.js';
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const { model, report } = await compile(workerData.rows, workerData.options);
|
|
8
|
+
const bytes = model.toBytes();
|
|
9
|
+
parentPort.postMessage({ bytes, report }, [bytes.buffer]);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
parentPort.postMessage({ error: { name: err?.name ?? 'Error', message: String(err?.message ?? err) } });
|
|
12
|
+
}
|