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