tinydecide 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/shadow.js ADDED
@@ -0,0 +1,412 @@
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 — the fastest sign the API changed
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.
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
+ const now = () => globalThis.performance?.now() ?? Date.now();
43
+
44
+ /**
45
+ * @param {string} [name] names the decision in logs; in Node it also names its folder in .tinydecide/
46
+ * @param {(input: any) => any} api your existing decision call
47
+ * @param {object} [options] see README
48
+ */
49
+ export function shadow(name, api, options) {
50
+ if (typeof name === 'function') [name, api, options] = [undefined, name, api];
51
+ options ??= {};
52
+ validate(name, api, options);
53
+ const o = {
54
+ target: 0.98, delta: 0.05, audit: 0.02, labels: undefined, model: null, store: null,
55
+ choice: defaultChoice, respond: undefined, group: undefined, fallback: false, log: true,
56
+ onEvent: () => {}, compile: {}, compiler: compile, random: Math.random,
57
+ ...options,
58
+ };
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 = now();
191
+ const sample = mode !== 'local' || o.random() < o.audit;
192
+ let d = null;
193
+ // Until one response from your API has been seen, 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: 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: 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: 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
+ }, () => {}); // your API failing is your app's business; the audit is simply 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
+ shape ??= shapeOf(resp);
239
+ const read = safely('choice', () => o.choice(resp, model?.labels ?? o.labels));
240
+ const choice = read === NOPE ? null : read?.choice;
241
+ if (choice == null) {
242
+ if (!unreadable) { unreadable = true; emit({ type: 'unreadable', preview: preview(resp) }); }
243
+ return null;
244
+ }
245
+ // A label nobody has seen yet joins the label set, so the next compile can learn it.
246
+ if (o.labels && !o.labels.includes(choice)) o.labels = [...o.labels, choice];
247
+ if (model && !model.labels.includes(choice)) trip('schema', { label: choice });
248
+ const rec = { t: Date.now(), input, choice };
249
+ if (read.probs) rec.probs = read.probs;
250
+ if (o.group) {
251
+ const g = safely('group', () => o.group(input));
252
+ if (g != null && g !== NOPE) rec.group = String(g);
253
+ }
254
+ if (!sample) rec.sample = false;
255
+ if (safely('saving an answer', () => store.append(rec)) !== NOPE) {
256
+ available++; fresh++;
257
+ if (mode === 'guarded') sinceIncident++;
258
+ maybeCompile();
259
+ }
260
+ return choice;
261
+ }
262
+
263
+ const wantsProbs = () => !o.respond && shape?.probsKey != null;
264
+
265
+ function answer(d) {
266
+ if (o.respond) return o.respond(d, shape);
267
+ if (!shape || shape.kind === 'string') return d.choice;
268
+ const out = { [shape.key]: shape.numeric ? d.index : d.choice };
269
+ if (shape.probsKey && d.probs) out[shape.probsKey] = shape.probsArray ? model.labels.map((l) => d.probs[l]) : d.probs;
270
+ if (shape.confidence) out.confidence = d.confidence;
271
+ return out;
272
+ }
273
+
274
+ // ── Compiles ───────────────────────────────────────────────────────────────────────────────────────
275
+
276
+ function maybeCompile() {
277
+ if (!auto || compiling || closed) return;
278
+ const pool = mode === 'guarded' ? sinceIncident : available;
279
+ if (pool < auto.min) return;
280
+ const gap = basis ? Math.min(auto.maxEvery, Math.max(auto.min, basis * (auto.growth - 1))) : 0;
281
+ if (fresh >= gap) recompile({ background: true }).catch((err) => fail('compile', err));
282
+ }
283
+
284
+ async function recompile({ background = false } = {}) {
285
+ if (compiling) return compiling;
286
+ if (closed) return null;
287
+ compiling = (async () => {
288
+ await ready;
289
+ const started = epoch;
290
+ const recovering = incident != null;
291
+ fresh = 0;
292
+ let rows = await store.load();
293
+ if (incident) rows = rows.filter((r) => r.t >= incident.t);
294
+ rows = rows.slice(-(auto?.window ?? 50_000));
295
+ emit({ type: 'compile:start', rows: rows.length });
296
+ n.compiles++;
297
+ const ctrl = (running = new AbortController());
298
+ let result = null;
299
+ try {
300
+ result = await o.compiler(rows, {
301
+ target: o.target, delta: o.delta, audit: o.audit, labels: o.labels, ...o.compile, signal: ctrl.signal, background,
302
+ });
303
+ } catch (err) {
304
+ if (err?.name !== 'AbortError') throw err;
305
+ } finally {
306
+ 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;
309
+ }
310
+ if (!result || epoch !== started || closed) {
311
+ // The guard tripped while this compile ran, so its rows straddle the change. Never install it.
312
+ emit({ type: 'discard', reason: closed ? 'closed' : 'guard tripped during compile' });
313
+ return null;
314
+ }
315
+ const { model: next, report } = result;
316
+ emit({ type: 'compile', report, recovering });
317
+ if (report.calib.share > 0) {
318
+ next.meta.response = shape;
319
+ model = next;
320
+ mode = 'local';
321
+ incident = null;
322
+ resetChecks();
323
+ emit({ type: 'install', share: report.calib.share, verdict: report.verdict, model: next, report });
324
+ await persist('saving the model', () => store.saveModel?.(next, report));
325
+ await persist('saving the state', () => store.saveState?.(null));
326
+ }
327
+ return report;
328
+ })();
329
+ try { return await compiling; } finally { compiling = null; maybeCompile(); }
330
+ }
331
+
332
+ function persist(where, fn) {
333
+ const v = safely(where, fn);
334
+ return v && typeof v.then === 'function' ? v : Promise.resolve(v);
335
+ }
336
+
337
+ // ── The decide function ────────────────────────────────────────────────────────────────────────────
338
+
339
+ const decide = async (input) => (await detailed(input)).value;
340
+ decide.detailed = detailed;
341
+ decide.local = (input) => (model ? model.decide(input) : null);
342
+ decide.compile = () => recompile();
343
+ decide.ready = ready;
344
+ decide.close = async () => {
345
+ closed = true;
346
+ running?.abort();
347
+ try { await compiling; } catch {}
348
+ await persist('closing the store', () => store.close?.());
349
+ };
350
+ decide.stats = () => ({
351
+ name: name ?? null, mode, ...n, logged: available,
352
+ share: n.calls ? n.local / n.calls : 0,
353
+ apiCallsSaved: n.local - n.audits,
354
+ live: { sampled: ringN, agreement: ringN ? ringAgree / ringN : null, ci: ringN ? wilson(ringAgree, ringN) : null },
355
+ deferred: { checked: dN, agreement: dN ? dAgree / dN : null, baseline: model?.meta?.expected?.deferred?.agreement ?? null },
356
+ model: model ? { labels: model.labels.length, cutoff: model.cutoff, expectedShare: model.meta?.expected?.share ?? null, created: model.meta?.created ?? null } : null,
357
+ });
358
+ Object.defineProperty(decide, 'name', { value: name ?? 'decide' });
359
+ Object.defineProperty(decide, 'model', { get: () => model });
360
+ Object.defineProperty(decide, 'mode', { get: () => mode });
361
+ if (Symbol.asyncDispose) decide[Symbol.asyncDispose] = decide.close;
362
+ return decide;
363
+ }
364
+
365
+ /** Reads { choice, probs } from common response shapes: a label string, or { choice | label | decision | answer, probs }. */
366
+ export function defaultChoice(resp, labels) {
367
+ if (typeof resp === 'string') return { choice: resp };
368
+ if (!resp || typeof resp !== 'object') return { choice: null };
369
+ let choice = resp.choice ?? resp.label ?? resp.decision ?? resp.answer ?? null;
370
+ if (typeof choice === 'number' && labels) choice = labels[choice];
371
+ let probs = resp.probs ?? resp.probabilities ?? null;
372
+ if (Array.isArray(probs)) probs = labels ? Object.fromEntries(labels.map((l, k) => [l, probs[k]])) : null;
373
+ return { choice: choice == null ? null : String(choice), probs };
374
+ }
375
+
376
+ /** The parts of a response local answers mirror: the label's key and type, and probs and confidence if present. */
377
+ function shapeOf(resp) {
378
+ if (typeof resp === 'string') return { kind: 'string' };
379
+ if (!resp || typeof resp !== 'object') return null;
380
+ const key = LABEL_KEYS.find((k) => resp[k] != null) ?? 'choice';
381
+ const probsKey = PROBS_KEYS.find((k) => resp[k] != null) ?? null;
382
+ return { kind: 'object', key, numeric: typeof resp[key] === 'number', probsKey, probsArray: Array.isArray(resp[probsKey]), confidence: 'confidence' in resp };
383
+ }
384
+
385
+ function preview(resp) {
386
+ let s;
387
+ try { s = JSON.stringify(resp); } catch { s = String(resp); }
388
+ s ??= String(resp);
389
+ return s.length > 120 ? `${s.slice(0, 117)}...` : s;
390
+ }
391
+
392
+ export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
393
+
394
+ function validate(name, api, options) {
395
+ const fail = (msg) => { throw new TypeError(`tinydecide: ${msg}`); };
396
+ if (name !== undefined && (typeof name !== 'string' || !NAME.test(name))) {
397
+ fail(`the name must be 1–64 letters, digits, dots, dashes or underscores, like "support-intent"; got ${JSON.stringify(name)}`);
398
+ }
399
+ if (typeof api !== 'function') fail(`shadow(${name ? `"${name}", ` : ''}api) needs your API call as a function, like (text) => classify(text); got ${typeof api}`);
400
+ if (typeof options !== 'object') fail(`options must be an object; got ${typeof options}`);
401
+ 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`);
404
+ if (!OPTIONS.includes(k)) fail(`unknown option "${k}". Options: ${OPTIONS.join(', ')}`);
405
+ }
406
+ const unit = (k, lo, hi) => {
407
+ const v = options[k];
408
+ if (v !== undefined && !(typeof v === 'number' && v > lo && v < hi)) fail(`${k} must be a number between ${lo} and ${hi}; got ${v}`);
409
+ };
410
+ unit('target', 0, 1); unit('delta', 0, 1);
411
+ if (options.audit !== undefined && !(options.audit >= 0 && options.audit <= 1)) fail(`audit must be a rate from 0 to 1; got ${options.audit}`);
412
+ }
package/src/stats.js ADDED
@@ -0,0 +1,67 @@
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 is every calibration row
35
+ * guarantee 'local' : risk = P(wrong | answered locally) — n is the rows the cutoff answers
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, 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;
50
+ let best = { cutoff: Infinity, local: 0, errors: 0, pValue: 0, tested: 0 };
51
+ let i = 0, local = 0, errors = 0;
52
+ for (let c = 0; c < cuts.length; c++) {
53
+ const cut = cuts[c];
54
+ while (i < n && score[order[i]] >= cut) { local++; errors += wrong[order[i]] ? 1 : 0; i++; }
55
+ const trials = guarantee === 'local' ? local : n;
56
+ if (trials < floor) continue;
57
+ const pValue = binomCdf(errors, trials, alpha);
58
+ if (pValue > delta) return { ...best, tested: c + 1 };
59
+ best = { cutoff: cut, local, errors, pValue, tested: c + 1 };
60
+ }
61
+ return best;
62
+ }
63
+
64
+ /** Smallest calibration size at which an error-free sample can certify alpha at level delta. */
65
+ export function minCalibration(alpha, delta = 0.05) {
66
+ return Math.ceil(Math.log(delta) / Math.log1p(-alpha));
67
+ }
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,126 @@
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
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
10
+
11
+ // Yield to the event loop between chunks of work. Browsers clamp nested setTimeout to 4 ms, so use a
12
+ // MessageChannel there; Node has setImmediate.
13
+ function makeTick() {
14
+ if (typeof setImmediate === 'function') return () => new Promise((r) => setImmediate(r));
15
+ if (typeof MessageChannel === 'function') {
16
+ const ch = new MessageChannel(), queue = [];
17
+ ch.port1.onmessage = () => queue.shift()?.();
18
+ return () => new Promise((r) => { queue.push(r); ch.port2.postMessage(0); });
19
+ }
20
+ return () => new Promise((r) => setTimeout(r, 0));
21
+ }
22
+
23
+ /**
24
+ * @param {{ w: Uint32Array, c: Uint32Array, t: Float32Array }[]} rows features and target distribution
25
+ * @param {number} K
26
+ * @returns {Promise<{ d, keys: Uint32Array, E: Float32Array, W: Float32Array, b: Float32Array, epochs: number }>}
27
+ */
28
+ export async function train(rows, K, opts = {}) {
29
+ const { d, lr, minCount, maxFeatures, steps, minEpochs, maxEpochs } = { ...RECIPE, ...opts };
30
+ const seed = opts.seed ?? 1;
31
+ const onProgress = opts.onProgress ?? (() => {});
32
+ const yieldMs = opts.yieldMs ?? 30;
33
+
34
+ // Vocabulary: features seen at least minCount times, the most frequent maxFeatures of them.
35
+ const counts = new U32Map(1 << 16);
36
+ for (const r of rows) { for (const f of r.w) counts.inc(f); for (const f of r.c) counts.inc(f); }
37
+ let kept = [];
38
+ counts.forEach((key, n) => { if (n >= minCount) kept.push([key, n]); });
39
+ if (kept.length > maxFeatures) kept = kept.sort((a, b) => b[1] - a[1] || a[0] - b[0]).slice(0, maxFeatures);
40
+ const keys = Uint32Array.from(kept.map((e) => e[0])).sort();
41
+ const index = new U32Map(keys.length);
42
+ for (let i = 0; i < keys.length; i++) index.set(keys[i], i);
43
+ const ids = (feats) => {
44
+ const out = [];
45
+ for (const f of feats) { const r = index.get(f); if (r >= 0) out.push(r); }
46
+ return Int32Array.from(out);
47
+ };
48
+ const data = rows.map((r) => ({ w: ids(r.w), c: ids(r.c), t: r.t }));
49
+
50
+ const V = keys.length;
51
+ const E = new Float32Array(V * d);
52
+ const W = new Float32Array(K * d);
53
+ const b = new Float32Array(K);
54
+ const rand = rng(seed);
55
+ const a = Math.sqrt(6 / (d + K));
56
+ for (let i = 0; i < W.length; i++) W[i] = (rand() * 2 - 1) * a;
57
+ // Start the bias at the API's average distribution.
58
+ const prior = new Float64Array(K);
59
+ for (const r of data) for (let k = 0; k < K; k++) prior[k] += r.t[k];
60
+ for (let k = 0; k < K; k++) b[k] = Math.log((prior[k] + 1) / (data.length + K));
61
+
62
+ const N = data.length;
63
+ const epochs = Math.max(minEpochs, Math.min(maxEpochs, Math.round(steps / Math.max(1, N))));
64
+ const total = epochs * N;
65
+ const h = new Float32Array(d), hw = new Float32Array(d), hc = new Float32Array(d), gh = new Float32Array(d);
66
+ const p = new Float32Array(K);
67
+ const order = Int32Array.from({ length: N }, (_, i) => i);
68
+ let step = 0, last = now();
69
+
70
+ for (let ep = 0; ep < epochs; ep++) {
71
+ 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; }
72
+ for (let s = 0; s < N; s++, step++) {
73
+ const ex = data[order[s]];
74
+ const rate = lr * (1 - step / total);
75
+ const nw = ex.w.length, nc = ex.c.length;
76
+ if (nw + nc === 0) continue;
77
+ meanInto(hw, E, ex.w, d); meanInto(hc, E, ex.c, d);
78
+ for (let j = 0; j < d; j++) h[j] = hw[j] + hc[j];
79
+ let m = -Infinity;
80
+ for (let k = 0; k < K; k++) {
81
+ let z = b[k];
82
+ const o = k * d;
83
+ for (let j = 0; j < d; j++) z += W[o + j] * h[j];
84
+ p[k] = z; if (z > m) m = z;
85
+ }
86
+ let sum = 0;
87
+ for (let k = 0; k < K; k++) sum += p[k] = Math.exp(p[k] - m);
88
+ gh.fill(0);
89
+ for (let k = 0; k < K; k++) {
90
+ const g = p[k] / sum - ex.t[k];
91
+ if (g === 0) continue;
92
+ const o = k * d;
93
+ for (let j = 0; j < d; j++) { gh[j] += W[o + j] * g; W[o + j] -= rate * g * h[j]; }
94
+ b[k] -= rate * g;
95
+ }
96
+ if (nw) scatter(E, ex.w, gh, -rate / nw, d);
97
+ if (nc) scatter(E, ex.c, gh, -rate / nc, d);
98
+ if ((step & 255) === 0 && now() - last > yieldMs) {
99
+ onProgress({ phase: 'train', done: step / total });
100
+ await tick();
101
+ if (opts.signal?.aborted) throw abortError();
102
+ last = now();
103
+ }
104
+ }
105
+ }
106
+ onProgress({ phase: 'train', done: 1 });
107
+ return { d, keys, E, W, b, epochs };
108
+ }
109
+
110
+ function meanInto(out, E, ids, d) {
111
+ out.fill(0);
112
+ const n = ids.length;
113
+ if (!n) return;
114
+ for (let i = 0; i < n; i++) { const o = ids[i] * d; for (let j = 0; j < d; j++) out[j] += E[o + j]; }
115
+ for (let j = 0; j < d; j++) out[j] /= n;
116
+ }
117
+
118
+ function scatter(E, ids, g, scale, d) {
119
+ 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]; }
120
+ }
121
+
122
+ export function abortError() {
123
+ const err = new Error('compile aborted');
124
+ err.name = 'AbortError';
125
+ return err;
126
+ }
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
+ }