jev-web 0.7.1 → 0.8.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/bekko.mjs CHANGED
@@ -1,440 +1,440 @@
1
- /**
2
- * bekko.mjs — Bekko System One v0 family (Ettin reranker cross-encoder).
3
- *
4
- * Mirrors the reference browser runtime (hotchpotch/bekko-system-one, MIT,
5
- * browser/src/core.js + browser/src/decision.js):
6
- *
7
- * query: [cls] Instruction: {instruction}\nState: {state} [sep]
8
- * (instruction and state split the query budget 50/50, with
9
- * unused capacity spilling over to the other side)
10
- * candidates: "Candidate: {id}: {description}" (+ [sep]), one per option
11
- * graph: prefix_ids, prefix_mask, doc_ids, doc_mask, owners
12
- * -> logits [N, T] (one column per task: choice, noul, score)
13
- * decode: softmax over the task column's candidate logits.
14
- *
15
- * The browser export used here is hotchpotch/bekko-system-one-v0-17m
16
- * (onnx_browser/, pinned by revision) — 29 MB, the smallest of the family.
17
- * Larger siblings: v0-68m and v0-400m.
18
- */
19
-
20
- import { normalizeQuestions } from "./questions.mjs";
21
- import { fetchCachedBytes } from "./laya.mjs";
22
-
23
- export const BEKKO_DEFAULT_MODEL = "hotchpotch/bekko-system-one-v0-17m";
24
- export const BEKKO_DEFAULT_REVISION = "b886a1f9b91f4e8368d7830080d52d955e2c8dfa";
25
- export const BEKKO_DEFAULT_SUBDIR = "onnx_browser";
26
- export const BEKKO_68M_MODEL = "hotchpotch/bekko-system-one-v0-68m";
27
- export const BEKKO_68M_REVISION = "ab7685f23e5edbc1acb12ced4f2c4e12591efa69";
28
- export const BEKKO_400M_MODEL = "hotchpotch/bekko-system-one-v0-400m";
29
- export const BEKKO_400M_REVISION = "1960df5602bd93cc8d336fdebd9fb68a30926e13";
30
-
31
- /** Named size variants with their pinned revisions and download sizes. */
32
- export const BEKKO_VARIANTS = {
33
- "17m": {
34
- model: BEKKO_DEFAULT_MODEL,
35
- revision: BEKKO_DEFAULT_REVISION,
36
- bytes: 29_000_000,
37
- description: "17m, 29 MB browser ONNX",
38
- },
39
- "68m": {
40
- model: BEKKO_68M_MODEL,
41
- revision: BEKKO_68M_REVISION,
42
- bytes: 196_342_909,
43
- description: "68m, 196 MB browser ONNX",
44
- },
45
- "400m": {
46
- model: BEKKO_400M_MODEL,
47
- revision: BEKKO_400M_REVISION,
48
- bytes: 1_426_199_724,
49
- description: "400m, 1.43 GB browser ONNX",
50
- },
51
- };
52
-
53
- // Candidate batching heuristic from the reference runtime: bound the
54
- // candidate-attention growth per forward; not a total RAM limit.
55
- export const BEKKO_ATTENTION_BUDGET = 1_048_576;
56
-
57
- export const BEKKO_DEFAULT_YES = "Yes, the condition in the question holds.";
58
- export const BEKKO_DEFAULT_NO = "No, the condition in the question does not hold.";
59
- // Rendered on both sides of a noul so the two candidates read like the
60
- // reference runtime's defaults.
61
- export const BEKKO_NOUL_DESCRIPTIONS = { true: BEKKO_DEFAULT_YES, false: BEKKO_DEFAULT_NO };
62
-
63
- /** Flatten a state or instruction into text, stably. */
64
- export function renderBekkoContent(content) {
65
- if (typeof content === "string") return content.trim();
66
- return JSON.stringify(content);
67
- }
68
-
69
- /**
70
- * Render one normalized question to a Bekko request (mirrors decision.js
71
- * renderDecision): the task, the instruction, the JSON-encoded state and the
72
- * candidate list. Also returns the metadata jev-web needs to map the answer
73
- * back: `labels` (answer keys in candidate order) and `kind`.
74
- */
75
- export function renderBekkoRequest(question, stateText) {
76
- const instruction = renderBekkoContent(question.instructions);
77
- const kind = question.type;
78
- if (kind === "choice") {
79
- const criteria = question.criteria ?? {};
80
- const candidates = question.options.map((label) => {
81
- const desc = String(criteria[label] ?? label);
82
- return { id: label, text: `Candidate: ${label}: ${desc}` };
83
- });
84
- return {
85
- task: "choice",
86
- instruction,
87
- state: JSON.stringify(stateText),
88
- layout: "instruction_state",
89
- candidates,
90
- labels: [...question.options],
91
- kind,
92
- };
93
- }
94
- if (kind === "noul") {
95
- const criteria = question.criteria ?? {};
96
- const yesDesc = String(criteria.true ?? criteria.yes ?? BEKKO_NOUL_DESCRIPTIONS.true);
97
- const noDesc = String(criteria.false ?? criteria.no ?? BEKKO_NOUL_DESCRIPTIONS.false);
98
- return {
99
- task: "noul",
100
- instruction,
101
- state: JSON.stringify({ noul: { yes: yesDesc, no: noDesc }, state: stateText }),
102
- layout: "instruction_state",
103
- candidates: [
104
- { id: "true", text: `Candidate: true: ${yesDesc}` },
105
- { id: "false", text: `Candidate: false: ${noDesc}` },
106
- ],
107
- // answer keys parallel to candidates: candidate 0 ("true") is the
108
- // question's "yes" option, candidate 1 ("false") is the "no" option.
109
- labels: [question.options[1], question.options[0]],
110
- kind,
111
- };
112
- }
113
- if (kind === "score") {
114
- const candidates = question.options.map((rubric, i) => ({
115
- id: String(i),
116
- text: `Candidate: ${i}: ${rubric}`,
117
- value: i,
118
- }));
119
- return {
120
- task: "score",
121
- instruction,
122
- state: JSON.stringify(stateText),
123
- layout: "instruction_state",
124
- candidates,
125
- labels: question.options.map((_, i) => String(i)),
126
- kind,
127
- };
128
- }
129
- throw new TypeError(`bekko: unsupported question type ${question.type}`);
130
- }
131
-
132
- /**
133
- * Tokenise a Bekko request (mirrors core.js tokenize): the shared query
134
- * prefix plus one tokenised candidate per option. Returns the raw id arrays
135
- * and whether the instruction/state were truncated to the query budget.
136
- */
137
- export function tokenizeBekkoRequest(request, encode, manifest, system = "") {
138
- const max = manifest.query_length;
139
- const systemIds = encode(system.trim() ? `${system}\n\n` : "").slice(0, max);
140
- const instructionIds = encode(request.instruction).slice(0, max);
141
- const stateIds = encode(request.state).slice(0, max);
142
- const im = encode("Instruction: ");
143
- const sm = encode("State: ");
144
- const sep = encode("\n");
145
- const budget = max - 2 - systemIds.length - im.length - sm.length - sep.length;
146
- if (budget < 2) {
147
- throw new RangeError("bekko: system prompt leaves fewer than two content tokens");
148
- }
149
- let ni = Math.min(instructionIds.length, Math.ceil(budget / 2));
150
- let ns = Math.min(stateIds.length, Math.floor(budget / 2));
151
- ni += Math.min(instructionIds.length - ni, budget - ni - ns);
152
- ns += Math.min(stateIds.length - ns, budget - ni - ns);
153
- const truncated = ni < instructionIds.length || ns < stateIds.length;
154
- const ins = [...im, ...instructionIds.slice(0, ni)];
155
- const ctx = [...sm, ...stateIds.slice(0, ns)];
156
- const body = request.layout === "state_instruction"
157
- ? [...ctx, ...sep, ...ins]
158
- : [...ins, ...sep, ...ctx];
159
- return {
160
- prefixIds: [manifest.cls_token_id, ...systemIds, ...body, manifest.sep_token_id],
161
- docIds: request.candidates.map(
162
- (c) => [...encode(c.text).slice(0, manifest.document_length - 1), manifest.sep_token_id],
163
- ),
164
- truncated,
165
- };
166
- }
167
-
168
- /**
169
- * Build the ONNX feeds for one question (mirrors core.js feedsFor): the
170
- * shared prefix plus a batch of candidate documents.
171
- */
172
- export function collateBekkoDocs(prefixIds, docIds, manifest, ort) {
173
- const pad = BigInt(manifest.pad_token_id);
174
- const width = Math.max(...docIds.map((row) => row.length));
175
- const ids = new BigInt64Array(docIds.length * width).fill(pad);
176
- const mask = new Uint8Array(ids.length);
177
- docIds.forEach((row, i) => row.forEach((id, j) => {
178
- ids[i * width + j] = BigInt(id);
179
- mask[i * width + j] = 1;
180
- }));
181
- const pIds = new BigInt64Array(prefixIds.length);
182
- const pMask = new Uint8Array(prefixIds.length);
183
- prefixIds.forEach((id, j) => { pIds[j] = BigInt(id); pMask[j] = 1; });
184
- return {
185
- prefix_ids: new ort.Tensor("int64", pIds, [1, prefixIds.length]),
186
- prefix_mask: new ort.Tensor("bool", pMask, [1, prefixIds.length]),
187
- doc_ids: new ort.Tensor("int64", ids, [docIds.length, width]),
188
- doc_mask: new ort.Tensor("bool", mask, [docIds.length, width]),
189
- owners: new ort.Tensor("int64", new BigInt64Array(docIds.length), [docIds.length]),
190
- };
191
- }
192
-
193
- /** Softmax over candidate logits (mirrors core.js interpret). */
194
- export function bekkoSoftmax(logits) {
195
- if (!Array.isArray(logits) || logits.length === 0) {
196
- throw new TypeError("bekkoSoftmax: logits must be a non-empty array");
197
- }
198
- const max = Math.max(...logits);
199
- const exps = logits.map((x) => Math.exp(Number(x) - max));
200
- const sum = exps.reduce((a, b) => a + b, 0);
201
- return exps.map((e) => e / sum);
202
- }
203
-
204
- /**
205
- * Decode one question's task-column logits into a jev-web answer
206
- * (mirrors core.js interpret, mapped onto the shared answer shapes).
207
- *
208
- * @param {{logits:ArrayLike<number>, task:string, request:object}} spec
209
- */
210
- export function bekkoAnswerFromLogits({ logits, task, request }) {
211
- const probs = bekkoSoftmax(Array.from(logits, Number));
212
- if (probs.length !== request.candidates.length) {
213
- throw new TypeError(
214
- `bekko: got ${probs.length} logits for ${request.candidates.length} candidates`);
215
- }
216
- const probabilities = Object.fromEntries(request.labels.map((label, i) => [label, probs[i]]));
217
- const best = probs.indexOf(Math.max(...probs));
218
-
219
- if (task === "choice") {
220
- return {
221
- type: "choice",
222
- choice: request.labels[best],
223
- index: best,
224
- probabilities,
225
- confidence: Math.max(...probs),
226
- };
227
- }
228
- if (task === "noul") {
229
- const yes = probs[request.candidates.findIndex((c) => c.id === "true")];
230
- // labels are [yesOption, noOption] (candidate order); re-key in the
231
- // question's option order for the shared answer contract.
232
- const [yesLabel, noLabel] = request.labels;
233
- return {
234
- type: "noul",
235
- noul: yes,
236
- probabilities: { [noLabel]: 1 - yes, [yesLabel]: yes },
237
- confidence: Math.max(yes, 1 - yes),
238
- };
239
- }
240
- // score: expected value over the numeric candidate values.
241
- const values = request.candidates.map((c) => c.value);
242
- const score = probs.reduce((acc, p, i) => acc + p * values[i], 0);
243
- const lo = Math.min(...values);
244
- const hi = Math.max(...values);
245
- return {
246
- type: "score",
247
- score,
248
- level: best,
249
- normalizedScore: hi === lo ? 0 : (score - lo) / (hi - lo),
250
- probabilities,
251
- confidence: Math.max(...probs),
252
- };
253
- }
254
-
255
- const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
256
-
257
- const hfUrl = (model, revision, ...parts) =>
258
- `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${parts.join("/")}`;
259
-
260
- /**
261
- * Create a Bekko System One decider.
262
- *
263
- * @param {object} [opts]
264
- * model/revision/subdir — pinned browser ONNX export (or your own mirror)
265
- * manifestUrl/modelUrl — override the resolved file URLs
266
- * ort — injected onnxruntime-web (lazy import otherwise)
267
- * transformers — injected @huggingface/transformers (tokenizer only)
268
- * tokenizerModel/tokenizerRevision — override the tokenizer source
269
- * wasmPaths — ORT wasm/loader directory (self-hosted builds)
270
- * device — "auto" | "wasm" | "webgpu"
271
- * sessionFactory — (url, sessionOptions) => Promise<InferenceSession>
272
- * attentionBudget — candidate batching budget (reference: 1M pairs)
273
- */
274
- export async function createBekkoDecider({
275
- model = BEKKO_DEFAULT_MODEL,
276
- revision = BEKKO_DEFAULT_REVISION,
277
- variant = null,
278
- subdir = BEKKO_DEFAULT_SUBDIR,
279
- manifestUrl = null,
280
- modelUrl = null,
281
- ort = null,
282
- transformers = null,
283
- fetchImpl = fetch,
284
- tokenizerModel = null,
285
- tokenizerRevision = null,
286
- wasmPaths = null,
287
- device = "auto",
288
- sessionFactory = null,
289
- onProgress = null,
290
- system = "",
291
- attentionBudget = BEKKO_ATTENTION_BUDGET,
292
- scope = globalThis,
293
- } = {}) {
294
- // Named size shortcut: {variant: "400m"} sets model/revision.
295
- if (variant) {
296
- const v = BEKKO_VARIANTS[variant];
297
- if (!v) throw new TypeError(`bekko: unknown variant "${variant}" (17m, 68m, 400m)`);
298
- model = v.model;
299
- revision = v.revision;
300
- }
301
- const ortMod = ort ?? (await import("onnxruntime-web"));
302
- const tf = transformers ?? (await import("@huggingface/transformers"));
303
- if (wasmPaths) {
304
- try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
305
- }
306
-
307
- const resolvedDevice = device !== "auto"
308
- ? device
309
- : (scope?.navigator?.gpu ? "webgpu" : "wasm");
310
- const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
311
-
312
- const report = (phase) => (p) =>
313
- onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
314
-
315
- const manifestRes = await fetchImpl(manifestUrl ?? hfUrl(model, revision, subdir, "manifest.json"));
316
- if (!manifestRes.ok) {
317
- throw new Error(`bekko: manifest fetch failed (${manifestRes.status})`);
318
- }
319
- const manifest = await manifestRes.json();
320
- const taskColumns = manifest.tasks;
321
-
322
- /**
323
- * Load the tokenizer. The bekko repos keep tokenizer.json under
324
- * onnx_browser/, not the repo root, so AutoTokenizer.from_pretrained(repo)
325
- * finds nothing there. Load the subdir's tokenizer.json directly and
326
- * construct the base tokenizer (special tokens come from the manifest).
327
- * An explicit tokenizerModel still goes through from_pretrained.
328
- */
329
- async function loadBekkoTokenizer() {
330
- if (tokenizerModel) {
331
- return tf.AutoTokenizer.from_pretrained(tokenizerModel, {
332
- revision: tokenizerRevision,
333
- progress_callback: report("tokenizer"),
334
- });
335
- }
336
- const tokRes = await fetchImpl(hfUrl(model, revision, subdir, "tokenizer.json"));
337
- if (!tokRes.ok) {
338
- throw new Error(`bekko: tokenizer fetch failed (${tokRes.status})`);
339
- }
340
- report("tokenizer")({ file: "tokenizer.json" });
341
- return new tf.PreTrainedTokenizer(await tokRes.json(), {});
342
- }
343
-
344
- const tok = await loadBekkoTokenizer();
345
- const encode = (text) =>
346
- Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
347
-
348
- const url = modelUrl ?? hfUrl(model, revision, subdir, manifest.model_file ?? "model.onnx");
349
- const createSession = sessionFactory
350
- ?? (async (u, opts) => {
351
- const modelBytes = await fetchCachedBytes(u, {
352
- fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
353
- });
354
- return ortMod.InferenceSession.create(modelBytes, opts);
355
- });
356
- let session;
357
- let activeDevice = resolvedDevice;
358
- try {
359
- session = await createSession(url, {
360
- executionProviders,
361
- graphOptimizationLevel: "all",
362
- });
363
- } catch (e) {
364
- if (resolvedDevice === "webgpu") {
365
- onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
366
- session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
367
- activeDevice = "wasm";
368
- } else {
369
- throw e;
370
- }
371
- }
372
-
373
- return {
374
- info: {
375
- family: "bekko",
376
- model,
377
- revision,
378
- device: activeDevice,
379
- tasks: [...taskColumns],
380
- queryLength: manifest.query_length,
381
- documentLength: manifest.document_length,
382
- },
383
- async decide(state, questions) {
384
- const t0 = now();
385
- const normalized = normalizeQuestions(questions);
386
- const stateText = renderBekkoContent(state);
387
- const answers = [];
388
- const prompts = [];
389
- let truncated = false;
390
- let length = 0;
391
- let runMs = 0;
392
-
393
- for (const q of normalized) {
394
- const request = renderBekkoRequest(q, stateText);
395
- const column = taskColumns.indexOf(request.task);
396
- if (column < 0) throw new Error(`bekko: task ${request.task} absent from this model`);
397
- const tokens = tokenizeBekkoRequest(request, encode, manifest, system);
398
- truncated = truncated || tokens.truncated;
399
- length += tokens.prefixIds.length + tokens.docIds.reduce((a, d) => a + d.length, 0);
400
- prompts.push({
401
- query: request.instruction,
402
- candidates: request.candidates.map((c) => c.text),
403
- });
404
-
405
- // Batch candidates by the reference attention budget.
406
- const docLen = Math.max(...tokens.docIds.map((d) => d.length));
407
- const attentionPairs = docLen * (tokens.prefixIds.length + docLen);
408
- const batchSize = Math.max(1, Math.floor(attentionBudget / attentionPairs));
409
- const logits = [];
410
- for (let offset = 0; offset < tokens.docIds.length; offset += batchSize) {
411
- const docs = tokens.docIds.slice(offset, offset + batchSize);
412
- const feeds = collateBekkoDocs(tokens.prefixIds, docs, manifest, ortMod);
413
- const tRun = now();
414
- const out = await session.run(feeds);
415
- runMs += now() - tRun;
416
- const outLogits = out.logits ?? out[Object.keys(out)[0]];
417
- const T = taskColumns.length;
418
- if (outLogits.dims.length !== 2 || outLogits.dims[0] !== docs.length || outLogits.dims[1] !== T) {
419
- throw new Error("bekko: invalid model logits shape");
420
- }
421
- for (let r = 0; r < docs.length; r++) {
422
- logits.push(Number(outLogits.data[r * T + column]));
423
- }
424
- }
425
- answers.push(bekkoAnswerFromLogits({ logits, task: request.task, request }));
426
- }
427
-
428
- return {
429
- answers,
430
- truncated,
431
- length,
432
- prompts,
433
- timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
434
- };
435
- },
436
- async dispose() {
437
- try { await session.release?.(); } catch { /* optional */ }
438
- },
439
- };
1
+ /**
2
+ * bekko.mjs — Bekko System One v0 family (Ettin reranker cross-encoder).
3
+ *
4
+ * Mirrors the reference browser runtime (hotchpotch/bekko-system-one, MIT,
5
+ * browser/src/core.js + browser/src/decision.js):
6
+ *
7
+ * query: [cls] Instruction: {instruction}\nState: {state} [sep]
8
+ * (instruction and state split the query budget 50/50, with
9
+ * unused capacity spilling over to the other side)
10
+ * candidates: "Candidate: {id}: {description}" (+ [sep]), one per option
11
+ * graph: prefix_ids, prefix_mask, doc_ids, doc_mask, owners
12
+ * -> logits [N, T] (one column per task: choice, noul, score)
13
+ * decode: softmax over the task column's candidate logits.
14
+ *
15
+ * The browser export used here is hotchpotch/bekko-system-one-v0-17m
16
+ * (onnx_browser/, pinned by revision) — 29 MB, the smallest of the family.
17
+ * Larger siblings: v0-68m and v0-400m.
18
+ */
19
+
20
+ import { normalizeQuestions } from "./questions.mjs";
21
+ import { fetchCachedBytes } from "./laya.mjs";
22
+
23
+ export const BEKKO_DEFAULT_MODEL = "hotchpotch/bekko-system-one-v0-17m";
24
+ export const BEKKO_DEFAULT_REVISION = "b886a1f9b91f4e8368d7830080d52d955e2c8dfa";
25
+ export const BEKKO_DEFAULT_SUBDIR = "onnx_browser";
26
+ export const BEKKO_68M_MODEL = "hotchpotch/bekko-system-one-v0-68m";
27
+ export const BEKKO_68M_REVISION = "ab7685f23e5edbc1acb12ced4f2c4e12591efa69";
28
+ export const BEKKO_400M_MODEL = "hotchpotch/bekko-system-one-v0-400m";
29
+ export const BEKKO_400M_REVISION = "1960df5602bd93cc8d336fdebd9fb68a30926e13";
30
+
31
+ /** Named size variants with their pinned revisions and download sizes. */
32
+ export const BEKKO_VARIANTS = {
33
+ "17m": {
34
+ model: BEKKO_DEFAULT_MODEL,
35
+ revision: BEKKO_DEFAULT_REVISION,
36
+ bytes: 29_000_000,
37
+ description: "17m, 29 MB browser ONNX",
38
+ },
39
+ "68m": {
40
+ model: BEKKO_68M_MODEL,
41
+ revision: BEKKO_68M_REVISION,
42
+ bytes: 196_342_909,
43
+ description: "68m, 196 MB browser ONNX",
44
+ },
45
+ "400m": {
46
+ model: BEKKO_400M_MODEL,
47
+ revision: BEKKO_400M_REVISION,
48
+ bytes: 1_426_199_724,
49
+ description: "400m, 1.43 GB browser ONNX",
50
+ },
51
+ };
52
+
53
+ // Candidate batching heuristic from the reference runtime: bound the
54
+ // candidate-attention growth per forward; not a total RAM limit.
55
+ export const BEKKO_ATTENTION_BUDGET = 1_048_576;
56
+
57
+ export const BEKKO_DEFAULT_YES = "Yes, the condition in the question holds.";
58
+ export const BEKKO_DEFAULT_NO = "No, the condition in the question does not hold.";
59
+ // Rendered on both sides of a noul so the two candidates read like the
60
+ // reference runtime's defaults.
61
+ export const BEKKO_NOUL_DESCRIPTIONS = { true: BEKKO_DEFAULT_YES, false: BEKKO_DEFAULT_NO };
62
+
63
+ /** Flatten a state or instruction into text, stably. */
64
+ export function renderBekkoContent(content) {
65
+ if (typeof content === "string") return content.trim();
66
+ return JSON.stringify(content);
67
+ }
68
+
69
+ /**
70
+ * Render one normalized question to a Bekko request (mirrors decision.js
71
+ * renderDecision): the task, the instruction, the JSON-encoded state and the
72
+ * candidate list. Also returns the metadata jev-web needs to map the answer
73
+ * back: `labels` (answer keys in candidate order) and `kind`.
74
+ */
75
+ export function renderBekkoRequest(question, stateText) {
76
+ const instruction = renderBekkoContent(question.instructions);
77
+ const kind = question.type;
78
+ if (kind === "choice") {
79
+ const criteria = question.criteria ?? {};
80
+ const candidates = question.options.map((label) => {
81
+ const desc = String(criteria[label] ?? label);
82
+ return { id: label, text: `Candidate: ${label}: ${desc}` };
83
+ });
84
+ return {
85
+ task: "choice",
86
+ instruction,
87
+ state: JSON.stringify(stateText),
88
+ layout: "instruction_state",
89
+ candidates,
90
+ labels: [...question.options],
91
+ kind,
92
+ };
93
+ }
94
+ if (kind === "noul") {
95
+ const criteria = question.criteria ?? {};
96
+ const yesDesc = String(criteria.true ?? criteria.yes ?? BEKKO_NOUL_DESCRIPTIONS.true);
97
+ const noDesc = String(criteria.false ?? criteria.no ?? BEKKO_NOUL_DESCRIPTIONS.false);
98
+ return {
99
+ task: "noul",
100
+ instruction,
101
+ state: JSON.stringify({ noul: { yes: yesDesc, no: noDesc }, state: stateText }),
102
+ layout: "instruction_state",
103
+ candidates: [
104
+ { id: "true", text: `Candidate: true: ${yesDesc}` },
105
+ { id: "false", text: `Candidate: false: ${noDesc}` },
106
+ ],
107
+ // answer keys parallel to candidates: candidate 0 ("true") is the
108
+ // question's "yes" option, candidate 1 ("false") is the "no" option.
109
+ labels: [question.options[1], question.options[0]],
110
+ kind,
111
+ };
112
+ }
113
+ if (kind === "score") {
114
+ const candidates = question.options.map((rubric, i) => ({
115
+ id: String(i),
116
+ text: `Candidate: ${i}: ${rubric}`,
117
+ value: i,
118
+ }));
119
+ return {
120
+ task: "score",
121
+ instruction,
122
+ state: JSON.stringify(stateText),
123
+ layout: "instruction_state",
124
+ candidates,
125
+ labels: question.options.map((_, i) => String(i)),
126
+ kind,
127
+ };
128
+ }
129
+ throw new TypeError(`bekko: unsupported question type ${question.type}`);
130
+ }
131
+
132
+ /**
133
+ * Tokenise a Bekko request (mirrors core.js tokenize): the shared query
134
+ * prefix plus one tokenised candidate per option. Returns the raw id arrays
135
+ * and whether the instruction/state were truncated to the query budget.
136
+ */
137
+ export function tokenizeBekkoRequest(request, encode, manifest, system = "") {
138
+ const max = manifest.query_length;
139
+ const systemIds = encode(system.trim() ? `${system}\n\n` : "").slice(0, max);
140
+ const instructionIds = encode(request.instruction).slice(0, max);
141
+ const stateIds = encode(request.state).slice(0, max);
142
+ const im = encode("Instruction: ");
143
+ const sm = encode("State: ");
144
+ const sep = encode("\n");
145
+ const budget = max - 2 - systemIds.length - im.length - sm.length - sep.length;
146
+ if (budget < 2) {
147
+ throw new RangeError("bekko: system prompt leaves fewer than two content tokens");
148
+ }
149
+ let ni = Math.min(instructionIds.length, Math.ceil(budget / 2));
150
+ let ns = Math.min(stateIds.length, Math.floor(budget / 2));
151
+ ni += Math.min(instructionIds.length - ni, budget - ni - ns);
152
+ ns += Math.min(stateIds.length - ns, budget - ni - ns);
153
+ const truncated = ni < instructionIds.length || ns < stateIds.length;
154
+ const ins = [...im, ...instructionIds.slice(0, ni)];
155
+ const ctx = [...sm, ...stateIds.slice(0, ns)];
156
+ const body = request.layout === "state_instruction"
157
+ ? [...ctx, ...sep, ...ins]
158
+ : [...ins, ...sep, ...ctx];
159
+ return {
160
+ prefixIds: [manifest.cls_token_id, ...systemIds, ...body, manifest.sep_token_id],
161
+ docIds: request.candidates.map(
162
+ (c) => [...encode(c.text).slice(0, manifest.document_length - 1), manifest.sep_token_id],
163
+ ),
164
+ truncated,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Build the ONNX feeds for one question (mirrors core.js feedsFor): the
170
+ * shared prefix plus a batch of candidate documents.
171
+ */
172
+ export function collateBekkoDocs(prefixIds, docIds, manifest, ort) {
173
+ const pad = BigInt(manifest.pad_token_id);
174
+ const width = Math.max(...docIds.map((row) => row.length));
175
+ const ids = new BigInt64Array(docIds.length * width).fill(pad);
176
+ const mask = new Uint8Array(ids.length);
177
+ docIds.forEach((row, i) => row.forEach((id, j) => {
178
+ ids[i * width + j] = BigInt(id);
179
+ mask[i * width + j] = 1;
180
+ }));
181
+ const pIds = new BigInt64Array(prefixIds.length);
182
+ const pMask = new Uint8Array(prefixIds.length);
183
+ prefixIds.forEach((id, j) => { pIds[j] = BigInt(id); pMask[j] = 1; });
184
+ return {
185
+ prefix_ids: new ort.Tensor("int64", pIds, [1, prefixIds.length]),
186
+ prefix_mask: new ort.Tensor("bool", pMask, [1, prefixIds.length]),
187
+ doc_ids: new ort.Tensor("int64", ids, [docIds.length, width]),
188
+ doc_mask: new ort.Tensor("bool", mask, [docIds.length, width]),
189
+ owners: new ort.Tensor("int64", new BigInt64Array(docIds.length), [docIds.length]),
190
+ };
191
+ }
192
+
193
+ /** Softmax over candidate logits (mirrors core.js interpret). */
194
+ export function bekkoSoftmax(logits) {
195
+ if (!Array.isArray(logits) || logits.length === 0) {
196
+ throw new TypeError("bekkoSoftmax: logits must be a non-empty array");
197
+ }
198
+ const max = Math.max(...logits);
199
+ const exps = logits.map((x) => Math.exp(Number(x) - max));
200
+ const sum = exps.reduce((a, b) => a + b, 0);
201
+ return exps.map((e) => e / sum);
202
+ }
203
+
204
+ /**
205
+ * Decode one question's task-column logits into a jev-web answer
206
+ * (mirrors core.js interpret, mapped onto the shared answer shapes).
207
+ *
208
+ * @param {{logits:ArrayLike<number>, task:string, request:object}} spec
209
+ */
210
+ export function bekkoAnswerFromLogits({ logits, task, request }) {
211
+ const probs = bekkoSoftmax(Array.from(logits, Number));
212
+ if (probs.length !== request.candidates.length) {
213
+ throw new TypeError(
214
+ `bekko: got ${probs.length} logits for ${request.candidates.length} candidates`);
215
+ }
216
+ const probabilities = Object.fromEntries(request.labels.map((label, i) => [label, probs[i]]));
217
+ const best = probs.indexOf(Math.max(...probs));
218
+
219
+ if (task === "choice") {
220
+ return {
221
+ type: "choice",
222
+ choice: request.labels[best],
223
+ index: best,
224
+ probabilities,
225
+ confidence: Math.max(...probs),
226
+ };
227
+ }
228
+ if (task === "noul") {
229
+ const yes = probs[request.candidates.findIndex((c) => c.id === "true")];
230
+ // labels are [yesOption, noOption] (candidate order); re-key in the
231
+ // question's option order for the shared answer contract.
232
+ const [yesLabel, noLabel] = request.labels;
233
+ return {
234
+ type: "noul",
235
+ noul: yes,
236
+ probabilities: { [noLabel]: 1 - yes, [yesLabel]: yes },
237
+ confidence: Math.max(yes, 1 - yes),
238
+ };
239
+ }
240
+ // score: expected value over the numeric candidate values.
241
+ const values = request.candidates.map((c) => c.value);
242
+ const score = probs.reduce((acc, p, i) => acc + p * values[i], 0);
243
+ const lo = Math.min(...values);
244
+ const hi = Math.max(...values);
245
+ return {
246
+ type: "score",
247
+ score,
248
+ level: best,
249
+ normalizedScore: hi === lo ? 0 : (score - lo) / (hi - lo),
250
+ probabilities,
251
+ confidence: Math.max(...probs),
252
+ };
253
+ }
254
+
255
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
256
+
257
+ const hfUrl = (model, revision, ...parts) =>
258
+ `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${parts.join("/")}`;
259
+
260
+ /**
261
+ * Create a Bekko System One decider.
262
+ *
263
+ * @param {object} [opts]
264
+ * model/revision/subdir — pinned browser ONNX export (or your own mirror)
265
+ * manifestUrl/modelUrl — override the resolved file URLs
266
+ * ort — injected onnxruntime-web (lazy import otherwise)
267
+ * transformers — injected @huggingface/transformers (tokenizer only)
268
+ * tokenizerModel/tokenizerRevision — override the tokenizer source
269
+ * wasmPaths — ORT wasm/loader directory (self-hosted builds)
270
+ * device — "auto" | "wasm" | "webgpu"
271
+ * sessionFactory — (url, sessionOptions) => Promise<InferenceSession>
272
+ * attentionBudget — candidate batching budget (reference: 1M pairs)
273
+ */
274
+ export async function createBekkoDecider({
275
+ model = BEKKO_DEFAULT_MODEL,
276
+ revision = BEKKO_DEFAULT_REVISION,
277
+ variant = null,
278
+ subdir = BEKKO_DEFAULT_SUBDIR,
279
+ manifestUrl = null,
280
+ modelUrl = null,
281
+ ort = null,
282
+ transformers = null,
283
+ fetchImpl = fetch,
284
+ tokenizerModel = null,
285
+ tokenizerRevision = null,
286
+ wasmPaths = null,
287
+ device = "auto",
288
+ sessionFactory = null,
289
+ onProgress = null,
290
+ system = "",
291
+ attentionBudget = BEKKO_ATTENTION_BUDGET,
292
+ scope = globalThis,
293
+ } = {}) {
294
+ // Named size shortcut: {variant: "400m"} sets model/revision.
295
+ if (variant) {
296
+ const v = BEKKO_VARIANTS[variant];
297
+ if (!v) throw new TypeError(`bekko: unknown variant "${variant}" (17m, 68m, 400m)`);
298
+ model = v.model;
299
+ revision = v.revision;
300
+ }
301
+ const ortMod = ort ?? (await import("onnxruntime-web"));
302
+ const tf = transformers ?? (await import("@huggingface/transformers"));
303
+ if (wasmPaths) {
304
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
305
+ }
306
+
307
+ const resolvedDevice = device !== "auto"
308
+ ? device
309
+ : (scope?.navigator?.gpu ? "webgpu" : "wasm");
310
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
311
+
312
+ const report = (phase) => (p) =>
313
+ onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
314
+
315
+ const manifestRes = await fetchImpl(manifestUrl ?? hfUrl(model, revision, subdir, "manifest.json"));
316
+ if (!manifestRes.ok) {
317
+ throw new Error(`bekko: manifest fetch failed (${manifestRes.status})`);
318
+ }
319
+ const manifest = await manifestRes.json();
320
+ const taskColumns = manifest.tasks;
321
+
322
+ /**
323
+ * Load the tokenizer. The bekko repos keep tokenizer.json under
324
+ * onnx_browser/, not the repo root, so AutoTokenizer.from_pretrained(repo)
325
+ * finds nothing there. Load the subdir's tokenizer.json directly and
326
+ * construct the base tokenizer (special tokens come from the manifest).
327
+ * An explicit tokenizerModel still goes through from_pretrained.
328
+ */
329
+ async function loadBekkoTokenizer() {
330
+ if (tokenizerModel) {
331
+ return tf.AutoTokenizer.from_pretrained(tokenizerModel, {
332
+ revision: tokenizerRevision,
333
+ progress_callback: report("tokenizer"),
334
+ });
335
+ }
336
+ const tokRes = await fetchImpl(hfUrl(model, revision, subdir, "tokenizer.json"));
337
+ if (!tokRes.ok) {
338
+ throw new Error(`bekko: tokenizer fetch failed (${tokRes.status})`);
339
+ }
340
+ report("tokenizer")({ file: "tokenizer.json" });
341
+ return new tf.PreTrainedTokenizer(await tokRes.json(), {});
342
+ }
343
+
344
+ const tok = await loadBekkoTokenizer();
345
+ const encode = (text) =>
346
+ Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
347
+
348
+ const url = modelUrl ?? hfUrl(model, revision, subdir, manifest.model_file ?? "model.onnx");
349
+ const createSession = sessionFactory
350
+ ?? (async (u, opts) => {
351
+ const modelBytes = await fetchCachedBytes(u, {
352
+ fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
353
+ });
354
+ return ortMod.InferenceSession.create(modelBytes, opts);
355
+ });
356
+ let session;
357
+ let activeDevice = resolvedDevice;
358
+ try {
359
+ session = await createSession(url, {
360
+ executionProviders,
361
+ graphOptimizationLevel: "all",
362
+ });
363
+ } catch (e) {
364
+ if (resolvedDevice === "webgpu") {
365
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
366
+ session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
367
+ activeDevice = "wasm";
368
+ } else {
369
+ throw e;
370
+ }
371
+ }
372
+
373
+ return {
374
+ info: {
375
+ family: "bekko",
376
+ model,
377
+ revision,
378
+ device: activeDevice,
379
+ tasks: [...taskColumns],
380
+ queryLength: manifest.query_length,
381
+ documentLength: manifest.document_length,
382
+ },
383
+ async decide(state, questions) {
384
+ const t0 = now();
385
+ const normalized = normalizeQuestions(questions);
386
+ const stateText = renderBekkoContent(state);
387
+ const answers = [];
388
+ const prompts = [];
389
+ let truncated = false;
390
+ let length = 0;
391
+ let runMs = 0;
392
+
393
+ for (const q of normalized) {
394
+ const request = renderBekkoRequest(q, stateText);
395
+ const column = taskColumns.indexOf(request.task);
396
+ if (column < 0) throw new Error(`bekko: task ${request.task} absent from this model`);
397
+ const tokens = tokenizeBekkoRequest(request, encode, manifest, system);
398
+ truncated = truncated || tokens.truncated;
399
+ length += tokens.prefixIds.length + tokens.docIds.reduce((a, d) => a + d.length, 0);
400
+ prompts.push({
401
+ query: request.instruction,
402
+ candidates: request.candidates.map((c) => c.text),
403
+ });
404
+
405
+ // Batch candidates by the reference attention budget.
406
+ const docLen = Math.max(...tokens.docIds.map((d) => d.length));
407
+ const attentionPairs = docLen * (tokens.prefixIds.length + docLen);
408
+ const batchSize = Math.max(1, Math.floor(attentionBudget / attentionPairs));
409
+ const logits = [];
410
+ for (let offset = 0; offset < tokens.docIds.length; offset += batchSize) {
411
+ const docs = tokens.docIds.slice(offset, offset + batchSize);
412
+ const feeds = collateBekkoDocs(tokens.prefixIds, docs, manifest, ortMod);
413
+ const tRun = now();
414
+ const out = await session.run(feeds);
415
+ runMs += now() - tRun;
416
+ const outLogits = out.logits ?? out[Object.keys(out)[0]];
417
+ const T = taskColumns.length;
418
+ if (outLogits.dims.length !== 2 || outLogits.dims[0] !== docs.length || outLogits.dims[1] !== T) {
419
+ throw new Error("bekko: invalid model logits shape");
420
+ }
421
+ for (let r = 0; r < docs.length; r++) {
422
+ logits.push(Number(outLogits.data[r * T + column]));
423
+ }
424
+ }
425
+ answers.push(bekkoAnswerFromLogits({ logits, task: request.task, request }));
426
+ }
427
+
428
+ return {
429
+ answers,
430
+ truncated,
431
+ length,
432
+ prompts,
433
+ timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
434
+ };
435
+ },
436
+ async dispose() {
437
+ try { await session.release?.(); } catch { /* optional */ }
438
+ },
439
+ };
440
440
  }