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/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "tinydecide",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Wrap your decision API. TinyDecide learns from its answers and takes over the calls it can answer locally, in microseconds, with agreement certified.",
5
+ "keywords": [
6
+ "classification",
7
+ "classifier",
8
+ "llm",
9
+ "distillation",
10
+ "cost",
11
+ "latency",
12
+ "routing",
13
+ "conformal",
14
+ "edge",
15
+ "bun"
16
+ ],
17
+ "author": "Christian Yun",
18
+ "license": "MIT",
19
+ "homepage": "https://github.com/Christian1011/tinydecide#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Christian1011/tinydecide.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Christian1011/tinydecide/issues"
26
+ },
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./types/index.d.ts",
31
+ "browser": "./src/index.js",
32
+ "node": "./src/node.js",
33
+ "default": "./src/index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "types": "./types/index.d.ts",
38
+ "bin": {
39
+ "tinydecide": "./bin/tinydecide.js"
40
+ },
41
+ "files": [
42
+ "src",
43
+ "bin",
44
+ "types",
45
+ "examples/support-api.js"
46
+ ],
47
+ "sideEffects": false,
48
+ "engines": {
49
+ "node": ">=22"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test \"test/*.test.js\"",
53
+ "test:bun": "bun test ./test/",
54
+ "typecheck": "npx -y -p typescript@5.9.3 tsc -p test/tsconfig.json",
55
+ "bench:guard": "node bench/guard-drift.js change 10",
56
+ "prepublishOnly": "npm test"
57
+ }
58
+ }
package/src/compile.js ADDED
@@ -0,0 +1,265 @@
1
+ // compile(): trains a local model on your API's answers and certifies a cutoff.
2
+ //
3
+ // 1. Split rows into train / calib / test by group (or by normalized text), so repeats never leak.
4
+ // 2. Train on train, quantize to int8. Every later number is measured on the int8 model you ship.
5
+ // 3. On calib, certify the loosest margin cutoff that keeps agreement with the API ≥ target,
6
+ // with probability ≥ 1 − delta over the draw of calibration rows (Learn-Then-Test).
7
+ // 4. On test, report what that cutoff actually did, with Wilson intervals.
8
+
9
+ import { Featurizer, inputKey } from './features.js';
10
+ import { hashString } from './hash.js';
11
+ import { Model, quantize, softmax, topTwo } from './model.js';
12
+ import { certify, localStart, minCalibration, wilson } from './stats.js';
13
+ import { RECIPE, abortError, tick, train } from './train.js';
14
+
15
+ export const DEFAULTS = Object.freeze({ target: 0.98, delta: 0.05, guarantee: 'overall', calib: 0.25, test: 0.1, seed: 1, audit: 0.02 });
16
+
17
+ // Cutoffs on the logit margin. Fixed in advance, as Learn-Then-Test requires.
18
+ const GRID = [0, ...Array.from({ length: 600 }, (_, i) => 0.01 * 6000 ** (i / 599))];
19
+ const WHAT_IF = [0.9, 0.95, 0.97, 0.98, 0.99, 0.995];
20
+
21
+ /**
22
+ * @param {Iterable<object>} records { input, choice?, probs?, group? }; see README for accepted shapes
23
+ * @param {object} [opts]
24
+ * @returns {Promise<{ model: Model, report: object }>}
25
+ */
26
+ export async function compile(records, opts = {}) {
27
+ const o = { ...DEFAULTS, ...opts };
28
+ for (const k of ['target', 'delta']) {
29
+ if (!(typeof o[k] === 'number' && o[k] > 0 && o[k] < 1)) throw new RangeError(`${k} must be a number between 0 and 1; got ${o[k]}`);
30
+ }
31
+ if (o.guarantee !== 'overall' && o.guarantee !== 'local') throw new RangeError(`guarantee must be "overall" or "local"; got ${o.guarantee}`);
32
+ const alpha = 1 - o.target;
33
+ const t0 = performance.now();
34
+ const onProgress = o.onProgress ?? (() => {});
35
+ const pause = async () => { await tick(); if (o.signal?.aborted) throw abortError(); };
36
+
37
+ const { labels, rows, skipped } = readRecords(records, o.labels);
38
+ const K = labels.length;
39
+ if (K < 2) throw new Error(`need at least two labels, saw ${K}`);
40
+
41
+ const split = { train: [], calib: [], test: [] };
42
+ const salt = 0x7f4a7c15 ^ o.seed;
43
+ for (const r of rows) {
44
+ const u = (hashString(r.group, salt) % 100_000) / 100_000;
45
+ const part = u < o.test ? 'test' : u < o.test + o.calib ? 'calib' : 'train';
46
+ if (r.trainOnly && part !== 'train') continue; // not an iid draw of traffic, so never certify on it
47
+ split[part].push(r);
48
+ }
49
+ if (split.train.length < 2 * K) throw new Error(`only ${split.train.length} training rows for ${K} labels`);
50
+
51
+ onProgress({ phase: 'features', done: 0 });
52
+ const fz = new Featurizer();
53
+ const trainRows = [];
54
+ for (const r of split.train) {
55
+ trainRows.push({ ...fz.copy(r.input), t: r.t });
56
+ if (trainRows.length % 2000 === 0) await pause();
57
+ }
58
+ const params = await train(trainRows, K, { ...pick(o, Object.keys(RECIPE)), seed: o.seed, onProgress, yieldMs: o.yieldMs, signal: o.signal });
59
+ const trainMs = performance.now() - t0;
60
+
61
+ await pause();
62
+ const draft = quantize({ labels, ...params });
63
+ const cal = score(draft, split.calib);
64
+ await pause();
65
+ const T = fitTemperature(cal);
66
+ await pause();
67
+ const cert = certify(cal.margin, cal.wrong, GRID, { alpha, delta: o.delta, guarantee: o.guarantee });
68
+ const whatIf = WHAT_IF.map((target) => {
69
+ const c = certify(cal.margin, cal.wrong, GRID, { alpha: 1 - target, delta: o.delta, guarantee: o.guarantee });
70
+ return { target, share: cal.n ? c.local / cal.n : 0 };
71
+ });
72
+
73
+ const certificate = {
74
+ target: o.target, delta: o.delta, guarantee: o.guarantee, n: cal.n,
75
+ local: cert.local, errors: cert.errors, pValue: cert.pValue, cutoff: cert.cutoff,
76
+ };
77
+ const calShare = cal.n ? cert.local / cal.n : 0;
78
+ const calLocalAgree = cert.local ? 1 - cert.errors / cert.local : null;
79
+ // Agreement on deferred calibration rows, overall and per guessed label (as a ratio to the model's
80
+ // confidence, shrunk toward 1 when rows are few). shadow() checks both live.
81
+ let dn = 0, dAgree = 0;
82
+ const seen = new Float64Array(K), expected = new Float64Array(K);
83
+ for (let i = 0; i < cal.n; i++) {
84
+ if (cal.margin[i] >= cert.cutoff) continue;
85
+ dn++;
86
+ if (!cal.wrong[i]) dAgree++;
87
+ const p = softmax(cal.Z[i], T), top = topTwo(cal.Z[i]).top;
88
+ expected[top] += p[top];
89
+ seen[top] += cal.wrong[i] ? 0 : 1;
90
+ }
91
+ const SHRINK = 10;
92
+ const ratio = Object.fromEntries(labels.map((l, k) => [l, Math.round(((seen[k] + SHRINK) / (expected[k] + SHRINK)) * 1000) / 1000]));
93
+ const meta = {
94
+ created: new Date().toISOString(), certificate, rows: { total: rows.length, train: split.train.length, calib: cal.n, test: split.test.length },
95
+ expected: { share: calShare, localAgreement: calLocalAgree, deferred: { n: dn, agreement: dn ? dAgree / dn : null, ratio } }, audit: o.audit,
96
+ };
97
+ const model = new Model({ ...draft, T, cutoff: cert.cutoff, meta });
98
+
99
+ await pause();
100
+ const test = split.test.length ? evaluate(model, split.test, params) : null;
101
+ await pause();
102
+ const latencyUs = timeDecide(model, (split.test.length ? split.test : split.calib).slice(0, 400));
103
+
104
+ const share = test ? test.share : calShare;
105
+ const minCal = o.guarantee === 'local' ? localStart(alpha, o.delta) : minCalibration(alpha, o.delta);
106
+ const verdict =
107
+ cal.n < minCal ? 'need-more-data' : calShare >= 0.25 ? 'ship' : calShare >= 0.05 ? 'marginal' : 'not-worth-it';
108
+ const report = {
109
+ verdict,
110
+ summary: summarize({ verdict, o, calShare, cal, minCal, whatIf }),
111
+ labels, target: o.target, delta: o.delta, guarantee: o.guarantee,
112
+ rows: { ...meta.rows, skipped },
113
+ features: params.keys.length, epochs: params.epochs,
114
+ certificate,
115
+ calib: { share: calShare, localAgreement: calLocalAgree, overallAgreement: cal.n ? 1 - cert.errors / cal.n : null, rawAgreement: cal.n ? 1 - cal.errors / cal.n : null },
116
+ test,
117
+ whatIf,
118
+ savings: { apiCallsAvoided: share * (1 - o.audit), auditRate: o.audit },
119
+ temperature: T,
120
+ bytes: model.toBytes().byteLength,
121
+ latencyUs,
122
+ ms: { train: Math.round(trainMs), total: Math.round(performance.now() - t0) },
123
+ };
124
+ model.meta.verdict = verdict;
125
+ onProgress({ phase: 'done', done: 1 });
126
+ return { model, report };
127
+ }
128
+
129
+ /** Normalizes answer records into { input, group, choice, t }. */
130
+ export function readRecords(records, given) {
131
+ const list = Array.isArray(records) ? records : [...records];
132
+ let labels = given ? [...given] : null;
133
+ if (!labels) {
134
+ const seen = new Set();
135
+ let fileLabels = null;
136
+ for (const r of list) {
137
+ if (Array.isArray(r.labels)) fileLabels ??= r.labels;
138
+ const c = choiceOf(r, null);
139
+ if (typeof c === 'string') seen.add(c);
140
+ if (r.probs && !Array.isArray(r.probs)) for (const k of Object.keys(r.probs)) seen.add(k);
141
+ }
142
+ labels = fileLabels ?? [...seen].sort();
143
+ }
144
+ const at = new Map(labels.map((l, i) => [l, i]));
145
+ const K = labels.length;
146
+ const rows = [];
147
+ let skipped = 0;
148
+ for (const r of list) {
149
+ if (r.labels && !r.input && !('choice' in r) && !r.probs) continue; // a header line
150
+ const input = inputOf(r);
151
+ let t = null;
152
+ if (r.probs) {
153
+ t = new Float32Array(K);
154
+ if (Array.isArray(r.probs)) { if (r.probs.length !== K) { skipped++; continue; } r.probs.forEach((p, k) => (t[k] = Math.max(0, +p || 0))); }
155
+ else for (const [l, p] of Object.entries(r.probs)) { const k = at.get(l); if (k !== undefined) t[k] = Math.max(0, +p || 0); }
156
+ const s = t.reduce((a, b) => a + b, 0);
157
+ if (!(s > 0)) t = null; else for (let k = 0; k < K; k++) t[k] /= s;
158
+ }
159
+ let c = choiceOf(r, labels);
160
+ let y = typeof c === 'string' ? at.get(c) : undefined;
161
+ if (y === undefined && t) y = argmax(t);
162
+ if (y === undefined || input === undefined) { skipped++; continue; }
163
+ if (!t) { t = new Float32Array(K); t[y] = 1; }
164
+ rows.push({ input, group: r.group != null ? String(r.group) : inputKey(input), y, t, trainOnly: r.sample === false });
165
+ }
166
+ return { labels, rows, skipped };
167
+ }
168
+
169
+ function inputOf(r) {
170
+ if (r.input !== undefined) return r.input;
171
+ if (typeof r.a === 'string' && typeof r.b === 'string') return { a: r.a, b: r.b };
172
+ for (const k of ['text', 'state', 'content', 'message']) if (typeof r[k] === 'string') return r[k];
173
+ return undefined;
174
+ }
175
+
176
+ function choiceOf(r, labels) {
177
+ let c = r.choice ?? r.label ?? r.decision ?? r.answer;
178
+ if (c && typeof c === 'object') c = c.choice ?? c.label;
179
+ if (typeof c === 'number' && labels) c = labels[c];
180
+ return c;
181
+ }
182
+
183
+ function argmax(a) {
184
+ let m = 0;
185
+ for (let i = 1; i < a.length; i++) if (a[i] > a[m]) m = i;
186
+ return m;
187
+ }
188
+
189
+ function score(model, rows) {
190
+ const n = rows.length;
191
+ const margin = new Float64Array(n), wrong = new Uint8Array(n), Z = [], y = new Int32Array(n);
192
+ let errors = 0;
193
+ for (let i = 0; i < n; i++) {
194
+ const z = model.logits(rows[i].input);
195
+ const { top, second } = topTwo(z);
196
+ margin[i] = second < 0 ? Infinity : z[top] - z[second];
197
+ wrong[i] = top !== rows[i].y ? 1 : 0;
198
+ errors += wrong[i];
199
+ Z.push(Float32Array.from(z));
200
+ y[i] = rows[i].y;
201
+ }
202
+ return { n, margin, wrong, Z, y, errors };
203
+ }
204
+
205
+ /** Temperature that best explains the API's choices on calib. Only reported confidence uses it. */
206
+ function fitTemperature({ Z, y, n }) {
207
+ if (!n) return 1;
208
+ const nll = (T) => {
209
+ let s = 0;
210
+ for (let i = 0; i < n; i++) s -= Math.log(Math.max(softmax(Z[i], T)[y[i]], 1e-12));
211
+ return s / n;
212
+ };
213
+ let lo = Math.log(0.05), hi = Math.log(20);
214
+ const g = (Math.sqrt(5) - 1) / 2;
215
+ let a = hi - g * (hi - lo), b = lo + g * (hi - lo), fa = nll(Math.exp(a)), fb = nll(Math.exp(b));
216
+ for (let it = 0; it < 40; it++) {
217
+ if (fa < fb) { hi = b; b = a; fb = fa; a = hi - g * (hi - lo); fa = nll(Math.exp(a)); }
218
+ else { lo = a; a = b; fa = fb; b = lo + g * (hi - lo); fb = nll(Math.exp(b)); }
219
+ }
220
+ return Math.exp((lo + hi) / 2);
221
+ }
222
+
223
+ function evaluate(model, rows, params) {
224
+ const n = rows.length;
225
+ const float = new Model({ ...model, q: params.E, scales: new Float32Array(params.keys.length).fill(1) });
226
+ let local = 0, localWrong = 0, rawWrong = 0, flips = 0;
227
+ for (const r of rows) {
228
+ const d = model.decide(r.input);
229
+ if (d.index !== r.y) rawWrong++;
230
+ if (d.local) { local++; if (d.index !== r.y) localWrong++; }
231
+ if (topTwo(float.logits(r.input)).top !== d.index) flips++;
232
+ }
233
+ return {
234
+ n, share: local / n,
235
+ overallAgreement: 1 - localWrong / n, overallCI: wilson(n - localWrong, n),
236
+ localAgreement: local ? 1 - localWrong / local : null, localCI: local ? wilson(local - localWrong, local) : null,
237
+ rawAgreement: 1 - rawWrong / n, int8Flips: flips,
238
+ };
239
+ }
240
+
241
+ function timeDecide(model, rows) {
242
+ if (!rows.length) return null;
243
+ for (const r of rows) model.decide(r.input);
244
+ const t = performance.now();
245
+ let reps = 0;
246
+ while (performance.now() - t < 50) { for (const r of rows) model.decide(r.input); reps++; }
247
+ return Math.round(((performance.now() - t) * 1000) / (reps * rows.length) * 10) / 10;
248
+ }
249
+
250
+ function summarize({ verdict, o, calShare, cal, minCal, whatIf }) {
251
+ const pct = (x) => `${Math.round(x * 1000) / 10}%`;
252
+ const scope = o.guarantee === 'local' ? 'agreement on the answers it gives' : 'overall agreement with your API';
253
+ if (verdict === 'need-more-data') return `Only ${cal.n} calibration rows; certifying ${pct(o.target)} needs at least ${minCal}. Keep shadowing and compile again.`;
254
+ const head = `Answers ${pct(calShare)} of calls locally while keeping ${scope} ≥ ${pct(o.target)} (${pct(1 - o.delta)} confidence).`;
255
+ if (verdict === 'ship') return head;
256
+ const looser = whatIf.filter((w) => w.target < o.target && w.share >= Math.max(0.25, calShare * 2)).at(-1);
257
+ const hint = looser ? ` At ${pct(looser.target)} it would answer ${pct(looser.share)}.` : '';
258
+ if (verdict === 'marginal') return `${head} Savings are small.${hint}`;
259
+ return `Not worth it: only ${pct(calShare)} of calls clear the cutoff. A word-level model can't copy this decision reliably; keep calling the API.${hint}`;
260
+ }
261
+
262
+ function pick(o, keys) {
263
+ return Object.fromEntries(keys.filter((k) => o[k] !== undefined).map((k) => [k, o[k]]));
264
+ }
265
+
@@ -0,0 +1,128 @@
1
+ // Text → hashed features. Two groups are kept apart because they pool separately in the model:
2
+ // words: word unigrams, word bigrams, number shapes, and (for pairs) side + overlap features
3
+ // chars: character 2–4 grams inside each word, with < > word boundaries
4
+ // Han and kana characters are tokens of their own, so CJK text gets character unigrams and bigrams.
5
+
6
+ import { fmix, hashString } from './hash.js';
7
+
8
+ export const FEATURES_VERSION = 1;
9
+
10
+ const TOKEN = /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}]|[\p{L}\p{M}\p{N}]+|[^\s\p{L}\p{M}\p{N}]/gu;
11
+ const DIGITS = /^\p{Nd}+$/u;
12
+ const MAX_CHARS = 4000;
13
+ const MAX_TOKENS = 400;
14
+ const MAX_GRAM_TOKEN = 48;
15
+ const MIN_N = 2;
16
+ const MAX_N = 4;
17
+
18
+ const SEED = {
19
+ word: 0x1b873593, bigram: 0xcc9e2d51, char: 0x85ebca77, shape: 0x27d4eb2f,
20
+ sideA: 0x165667b1, sideB: 0xd3a2646c, shared: 0xfd7046c5, onlyA: 0xb55a4f09, onlyB: 0x6c8e9cf5, jaccard: 0x9e3779b1,
21
+ };
22
+
23
+ /** Normalizes any supported input into { text } or { a, b }. */
24
+ export function asInput(input) {
25
+ if (typeof input === 'string') return { text: input };
26
+ if (input == null) return { text: '' };
27
+ if (typeof input === 'object') {
28
+ if (typeof input.a === 'string' && typeof input.b === 'string') return { a: input.a, b: input.b };
29
+ for (const k of ['state', 'text', 'input', 'content', 'message']) if (typeof input[k] === 'string') return { text: input[k] };
30
+ }
31
+ return { text: stableString(input) };
32
+ }
33
+
34
+ /** A canonical text for an input, used for grouping and de-duplication. */
35
+ export function inputKey(input) {
36
+ const x = asInput(input);
37
+ return x.text !== undefined ? normalize(x.text) : normalize(x.a) + '␞' + normalize(x.b);
38
+ }
39
+
40
+ export function normalize(s) {
41
+ return String(s).slice(0, MAX_CHARS).normalize('NFKC').toLowerCase();
42
+ }
43
+
44
+ function tokens(text) {
45
+ const out = [];
46
+ for (const m of normalize(text).matchAll(TOKEN)) {
47
+ out.push(m[0]);
48
+ if (out.length === MAX_TOKENS) break;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Growable uint32 buffer, reused across calls so the hot path does not allocate per feature. */
54
+ class Buf {
55
+ constructor(n = 256) { this.a = new Uint32Array(n); this.n = 0; }
56
+ push(v) {
57
+ if (this.n === this.a.length) { const b = new Uint32Array(this.a.length * 2); b.set(this.a); this.a = b; }
58
+ this.a[this.n++] = v;
59
+ }
60
+ view() { return this.a.subarray(0, this.n); }
61
+ }
62
+
63
+ export class Featurizer {
64
+ constructor() { this.w = new Buf(); this.c = new Buf(); }
65
+
66
+ /** Returns { w, c } views into internal buffers; copy them if you keep them past the next call. */
67
+ run(input) {
68
+ const x = asInput(input);
69
+ this.w.n = 0; this.c.n = 0;
70
+ if (x.text !== undefined) {
71
+ this.#side(tokens(x.text), 0);
72
+ } else {
73
+ const ta = tokens(x.a), tb = tokens(x.b);
74
+ const ua = this.#side(ta, SEED.sideA), ub = this.#side(tb, SEED.sideB);
75
+ let shared = 0;
76
+ for (const h of ub) {
77
+ const inA = ua.has(h);
78
+ if (inA) shared++;
79
+ this.w.push(fmix(h ^ (inA ? SEED.shared : SEED.onlyB)));
80
+ }
81
+ for (const h of ua) if (!ub.has(h)) this.w.push(fmix(h ^ SEED.onlyA));
82
+ const union = ua.size + ub.size - shared;
83
+ const bucket = union ? Math.round((10 * shared) / union) : 10;
84
+ for (let i = 0; i < 4; i++) this.w.push(fmix(SEED.jaccard + bucket * 16 + i));
85
+ }
86
+ return { w: this.w.view(), c: this.c.view() };
87
+ }
88
+
89
+ /** Copies of the features for one input, safe to store. */
90
+ copy(input) {
91
+ const { w, c } = this.run(input);
92
+ return { w: w.slice(), c: c.slice() };
93
+ }
94
+
95
+ #side(toks, salt) {
96
+ const uni = salt ? new Set() : null; // only pairs compare their sides
97
+ let prev = 0;
98
+ for (let i = 0; i < toks.length; i++) {
99
+ const t = toks[i];
100
+ const h = hashString(t, SEED.word);
101
+ uni?.add(h);
102
+ this.w.push(salt ? fmix(h ^ salt) : h);
103
+ if (i > 0) this.w.push(fmix((Math.imul(prev, 0x9e3779b1) ^ h ^ SEED.bigram ^ salt) >>> 0));
104
+ prev = h;
105
+ if (DIGITS.test(t)) this.w.push(fmix(SEED.shape + Math.min(t.length, 12) * 31 + salt));
106
+ if (t.length > 1) this.#grams(t, salt);
107
+ }
108
+ return uni;
109
+ }
110
+
111
+ #grams(t, salt) {
112
+ const len = Math.min(t.length, MAX_GRAM_TOKEN) + 2;
113
+ const code = (j) => (j === 0 ? 60 : j === len - 1 ? 62 : t.charCodeAt(j - 1));
114
+ for (let i = 0; i < len - 1; i++) {
115
+ let h = SEED.char ^ salt;
116
+ for (let j = i, n = 1; j < len && n <= MAX_N; j++, n++) {
117
+ h = Math.imul(h ^ code(j), 0x01000193);
118
+ if (n >= MIN_N) this.c.push(fmix((h + n) >>> 0));
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ function stableString(v) {
125
+ if (v === null || typeof v !== 'object') return String(v);
126
+ if (Array.isArray(v)) return v.map(stableString).join(' ');
127
+ return Object.keys(v).sort().map((k) => `${k}: ${stableString(v[k])}`).join('\n');
128
+ }
package/src/hash.js ADDED
@@ -0,0 +1,87 @@
1
+ // 32-bit hashing shared by the featurizer, the trainer and the runtime. Everything is integer math on
2
+ // UTF-16 code units, so a feature hashes identically in every JS engine.
3
+
4
+ export const FNV_OFFSET = 0x811c9dc5;
5
+ export const FNV_PRIME = 0x01000193;
6
+
7
+ /** Murmur3 finalizer: spreads the bits of an FNV state so low bits are usable as table indexes. */
8
+ export function fmix(h) {
9
+ h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b);
10
+ h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
11
+ h ^= h >>> 16;
12
+ return h >>> 0;
13
+ }
14
+
15
+ /** FNV-1a over a string, seeded. */
16
+ export function hashString(s, seed = FNV_OFFSET) {
17
+ let h = seed;
18
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), FNV_PRIME);
19
+ return fmix(h);
20
+ }
21
+
22
+ /** Deterministic PRNG (mulberry32) so compiles are reproducible from a seed. */
23
+ export function rng(seed) {
24
+ let a = seed >>> 0;
25
+ return () => {
26
+ a = (a + 0x6d2b79f5) >>> 0;
27
+ let t = a;
28
+ t = Math.imul(t ^ (t >>> 15), t | 1);
29
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
30
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
31
+ };
32
+ }
33
+
34
+ /** Open-addressing map from uint32 keys to int32 values (-1 = absent). Keys are already well mixed. */
35
+ export class U32Map {
36
+ constructor(capacity = 1024) {
37
+ let cap = 16;
38
+ while (cap < capacity * 2) cap <<= 1;
39
+ this.mask = cap - 1;
40
+ this.keys = new Uint32Array(cap);
41
+ this.vals = new Int32Array(cap).fill(-1);
42
+ this.size = 0;
43
+ }
44
+
45
+ get(key) {
46
+ const { keys, vals, mask } = this;
47
+ for (let i = key & mask; ; i = (i + 1) & mask) {
48
+ const v = vals[i];
49
+ if (v === -1) return -1;
50
+ if (keys[i] === key) return v;
51
+ }
52
+ }
53
+
54
+ set(key, val) {
55
+ if ((this.size + 1) * 2 > this.mask + 1) this.#grow();
56
+ const { keys, vals, mask } = this;
57
+ for (let i = key & mask; ; i = (i + 1) & mask) {
58
+ if (vals[i] === -1) { keys[i] = key; vals[i] = val; this.size++; return; }
59
+ if (keys[i] === key) { vals[i] = val; return; }
60
+ }
61
+ }
62
+
63
+ /** Adds 1 to the value at key (absent counts as 0); returns the new count. */
64
+ inc(key) {
65
+ if ((this.size + 1) * 2 > this.mask + 1) this.#grow();
66
+ const { keys, vals, mask } = this;
67
+ for (let i = key & mask; ; i = (i + 1) & mask) {
68
+ if (vals[i] === -1) { keys[i] = key; vals[i] = 1; this.size++; return 1; }
69
+ if (keys[i] === key) return ++vals[i];
70
+ }
71
+ }
72
+
73
+ forEach(fn) {
74
+ const { keys, vals } = this;
75
+ for (let i = 0; i < vals.length; i++) if (vals[i] !== -1) fn(keys[i], vals[i]);
76
+ }
77
+
78
+ #grow() {
79
+ const { keys, vals } = this;
80
+ const cap = (this.mask + 1) * 2;
81
+ this.mask = cap - 1;
82
+ this.keys = new Uint32Array(cap);
83
+ this.vals = new Int32Array(cap).fill(-1);
84
+ this.size = 0;
85
+ for (let i = 0; i < vals.length; i++) if (vals[i] !== -1) this.set(keys[i], vals[i]);
86
+ }
87
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // The public API. Everything here runs in browsers, workers and edge runtimes; Node gets ./node.js.
2
+ export { shadow } from './shadow.js';
3
+ export { compile } from './compile.js';
4
+ export { Model } from './model.js';
5
+ export { memoryStore } from './store.js';
package/src/log.js ADDED
@@ -0,0 +1,55 @@
1
+ // One log line per lifecycle event (start, compile, guard, error), never per call.
2
+
3
+ const pct = (x) => `${Math.round(x * 100)}%`;
4
+ const int = (x) => x.toLocaleString('en-US');
5
+ const UNTIL = 'Every call goes to your API until a model trained on its new answers certifies.';
6
+
7
+ /** @returns {string | null} */
8
+ export function describe(e) {
9
+ switch (e.type) {
10
+ case 'start':
11
+ if (e.mode === 'guarded') return `guarded since ${new Date(e.since).toISOString()}. ${UNTIL}`;
12
+ if (e.mode === 'local') return `loaded the saved model: it answers about ${pct(e.share)} of calls locally at ≥ ${pct(e.target)} agreement with your API.`;
13
+ return `learning from your API. First compile after ${int(e.min)} answers${e.logged ? ` (${int(e.logged)} logged so far)` : ''}.`;
14
+ case 'compile': {
15
+ const r = e.report;
16
+ const from = `compiled from ${int(r.rows.total)} answers${e.recovering ? ' since the change' : ''}`;
17
+ if (r.calib.share > 0) {
18
+ return `${from}: answers ${pct(r.calib.share)} of calls locally, with ≥ ${pct(r.target)} agreement with your API certified at ${pct(1 - r.delta)} confidence. ${(r.bytes / 1e6).toFixed(2)} MB, ${r.latencyUs} µs per call.`;
19
+ }
20
+ return `${from}. Nothing certified yet, so your API keeps every call. ${r.summary}`;
21
+ }
22
+ case 'guard':
23
+ if (e.reason === 'schema') return `your API returned a label the model has never seen ("${e.label}"). ${UNTIL}`;
24
+ if (e.slice === 'deferred' && e.label) return `your API's answers changed for "${e.label}": on calls the model leaned toward "${e.label}" but left to your API, they now agree ${pct(e.agreement)} of the time where the model expected ${pct(e.baseline)}. ${UNTIL}`;
25
+ if (e.slice === 'deferred') return `your API's answers changed: on calls it answered itself, the model now agrees ${pct(e.agreement)} of the time, down from ${pct(e.baseline)}. ${UNTIL}`;
26
+ return `your API's answers changed: the model agreed on only ${pct(e.agreement)} of ${int(e.sampled)} audited calls (target ${pct(e.target)}). ${UNTIL}`;
27
+ case 'unreadable':
28
+ return `could not find a label in your API's response ${e.preview}. Return a label string or an object with a "label" field, or pass choice: (response) => label. Calls still work; they just teach nothing.`;
29
+ case 'error':
30
+ return `${e.where} failed: ${e.error}`;
31
+ default:
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {string | undefined} name
38
+ * @param {boolean | ((line: string, event: object) => void)} log
39
+ */
40
+ export function logger(name, log) {
41
+ if (!log) return () => {};
42
+ const write = typeof log === 'function' ? log : (line) => console.info(line);
43
+ const prefix = `[tinydecide]${name ? ` ${name}:` : ''} `;
44
+ let last = '', lastAt = 0;
45
+ return (e) => {
46
+ const line = describe(e);
47
+ if (!line) return;
48
+ // A failure that repeats (a full disk, a store that is down) is logged once a minute, not per call.
49
+ if (e.type === 'error') {
50
+ if (line === last && Date.now() - lastAt < 60_000) return;
51
+ last = line; lastAt = Date.now();
52
+ }
53
+ try { write(prefix + line, e); } catch {}
54
+ };
55
+ }