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.
@@ -0,0 +1,535 @@
1
+ /**
2
+ * strands.mjs — Strands Decider 2B family (Qwen3.5-2B torso + pointer head).
3
+ *
4
+ * Mirrors the reference Python runtime (`strands-decider`, Apache-2.0,
5
+ * github.com/strands-labs/strands-decider):
6
+ *
7
+ * prompt: <state>\n{state}\n</state>\n
8
+ * <question type="{kind}">\n{header}\n{instructions}\n<options>\n
9
+ * 1. {label} — {description}\n…\n</options>\n</question>\n<answer>
10
+ * graph: input_ids, attention_mask, answer_pos, option_pos -> logits [B, K]
11
+ * (torso with LoRA merged; pointer head fused in-graph: LayerNorm ->
12
+ * q/k linears -> scaled dot of the answer-position state vs each
13
+ * option's last token)
14
+ * decode: per-kind temperature (noul / choice / score) then softmax;
15
+ * score confidence is ordinal (spread-based), not max-probability.
16
+ *
17
+ * The browser export used here is the validated onnx-community build
18
+ * (onnx-community/strands-decider-2B-hobson-v19-ONNX, pinned by revision):
19
+ * q8 `onnx/model_quantized.onnx` by default, q4f16 `onnx/model_q4f16.onnx`
20
+ * as the smaller opt-in.
21
+ */
22
+
23
+ import { normalizeQuestions } from "./questions.mjs";
24
+ import { softmaxWithTemperature } from "./answers.mjs";
25
+ import { fetchCachedBytes, fetchOnnxExternalData } from "./laya.mjs";
26
+
27
+ export const STRANDS_DEFAULT_MODEL = "onnx-community/strands-decider-2B-hobson-v19-ONNX";
28
+ export const STRANDS_DEFAULT_REVISION = "31a83e0244b26d939ae33f87189d6c8204003e53";
29
+ export const STRANDS_DEFAULT_FILE = "onnx/model_quantized.onnx";
30
+ export const STRANDS_Q4F16_FILE = "onnx/model_q4f16.onnx";
31
+
32
+ /** Reference calibration from the checkpoint's hobson_config.json. */
33
+ export const STRANDS_TEMPERATURE = 0.9627721607677362;
34
+ export const STRANDS_TEMPERATURE_BY_KIND = {
35
+ noul: 0.9107136998460428,
36
+ choice: 0.734189596436441,
37
+ score: 1.32780942142348,
38
+ };
39
+ export const STRANDS_ORDINAL_SMOOTHING = 0.1;
40
+ export const STRANDS_DEFAULT_MAX_LEN = 4096;
41
+ // Largest share of the window the question may claim before the state is squeezed.
42
+ export const STRANDS_MAX_QUESTION_FRACTION = 0.75;
43
+
44
+ export const STRANDS_KIND = { choice: "choice", score: "score", noul: "noul" };
45
+
46
+ // Rendered on both sides of a noul so the two slots read like any other option list.
47
+ const NOUL_DEFAULT_CRITERIA = {
48
+ false: "the statement does not hold for this state",
49
+ true: "the statement holds for this state",
50
+ };
51
+
52
+ const KIND_HEADERS = {
53
+ noul: "Decide whether the statement is true of the state.",
54
+ choice: "Select exactly one option.",
55
+ score: "Rate the state against the ordered levels below (lowest first).",
56
+ };
57
+
58
+ /**
59
+ * Flatten a state or instruction into text, stably (mirrors render_content:
60
+ * dicts/lists become indented JSON so the same state always tokenises
61
+ * identically).
62
+ */
63
+ export function renderStrandsContent(content) {
64
+ if (typeof content === "string") return content.trim();
65
+ return JSON.stringify(content, null, 2);
66
+ }
67
+
68
+ function optionBlock(pairs) {
69
+ // Number options from 1 so slot k <-> the line reading `k+1.`.
70
+ // Returns each line's (start, end) character span within the block.
71
+ const lines = [];
72
+ const spans = [];
73
+ let cursor = 0;
74
+ pairs.forEach(([name, desc], i) => {
75
+ const flat = String(desc ?? "").split(/\s+/).join(" ");
76
+ const line = `${i + 1}. ${name}` + (flat ? ` \u2014 ${flat}` : "");
77
+ lines.push(line);
78
+ spans.push([cursor, cursor + line.length]);
79
+ cursor += line.length + 1; // the joining newline
80
+ });
81
+ return { text: lines.join("\n"), spans };
82
+ }
83
+
84
+ /**
85
+ * Render one normalized question to the exact prompt the head was trained on.
86
+ * @returns {{text:string, chunks:string[], slotLabels:string[],
87
+ * slotDescriptions:string[], kind:string, optionSpans:[number,number][]}}
88
+ *
89
+ * `chunks` is the question text split at pre-tokeniser piece boundaries
90
+ * ([prefix, line_0+"\n", …, line_{k-1}+"\n", suffix]) so tokenising each chunk
91
+ * separately and concatenating gives exactly the full-text tokenisation, with
92
+ * exact option-token positions and no offset mapping needed.
93
+ */
94
+ export function renderStrandsQuestion(question) {
95
+ const instructions = renderStrandsContent(question.instructions);
96
+ let pairs;
97
+ let slotLabels;
98
+ let slotDescriptions;
99
+ let kind;
100
+ if (question.type === "noul") {
101
+ // The reference fixes noul slot labels to false/true; `criteria` only
102
+ // overrides the rubric descriptions (mirrors prompting.NOUL_DEFAULT_CRITERIA).
103
+ // `question.options` still names the answer keys, not the prompt.
104
+ const crit = { ...NOUL_DEFAULT_CRITERIA, ...(question.criteria ?? {}) };
105
+ pairs = [
106
+ ["false", crit.false],
107
+ ["true", crit.true],
108
+ ];
109
+ slotLabels = ["false", "true"];
110
+ slotDescriptions = [crit.false, crit.true];
111
+ kind = "noul";
112
+ } else if (question.type === "choice") {
113
+ // `criteria` as {label: description} mirrors the reference schema and the
114
+ // typed-decisions fixtures; plain `options` keep working with empty rubrics.
115
+ if (question.criteria && typeof question.criteria === "object" && !Array.isArray(question.criteria)) {
116
+ pairs = Object.entries(question.criteria).map(([k, v]) => [String(k), String(v ?? "")]);
117
+ } else {
118
+ pairs = question.options.map((o) => [o, ""]);
119
+ }
120
+ slotLabels = pairs.map(([k]) => k);
121
+ slotDescriptions = pairs.map(([, v]) => v);
122
+ kind = "choice";
123
+ } else if (question.type === "score") {
124
+ // Level index is the label; the rubric text is the description.
125
+ pairs = question.options.map((desc, i) => [String(i), desc]);
126
+ slotLabels = question.options.map((_, i) => String(i));
127
+ slotDescriptions = [...question.options];
128
+ kind = "score";
129
+ } else {
130
+ throw new TypeError(`strands: unsupported question type ${question.type}`);
131
+ }
132
+
133
+ const header = KIND_HEADERS[kind];
134
+ const block = optionBlock(pairs);
135
+ const prefix = `<question type="${kind}">\n${header}\n${instructions}\n<options>\n`;
136
+ const suffix = `</options>\n</question>\n<answer>`;
137
+ const text = prefix + block.text + "\n" + suffix;
138
+ const base = prefix.length;
139
+ // Every option chunk carries its trailing newline, so no pre-tokeniser piece
140
+ // can span a chunk boundary and concatenating the chunk encodings reproduces
141
+ // the full-text tokenisation exactly.
142
+ const chunks = [prefix, ...block.spans.map(([s, e]) => block.text.slice(s, e) + "\n"), suffix];
143
+ return {
144
+ text,
145
+ chunks,
146
+ slotLabels,
147
+ slotDescriptions,
148
+ kind,
149
+ optionSpans: block.spans.map(([s, e]) => [base + s, base + e]),
150
+ };
151
+ }
152
+
153
+ /** The shared prefix: everything before this point is identical across questions. */
154
+ export function renderStrandsState(state) {
155
+ return `<state>\n${renderStrandsContent(state)}\n</state>\n`;
156
+ }
157
+
158
+ /**
159
+ * Scored token index of each option line inside its chunk's tokenisation.
160
+ *
161
+ * chunkIds[i] = encode(line_i + "\n"), lineIds[i] = encode(line_i). The trailing
162
+ * "\n" is either its own token (line tokens = lineIds) or merged with the line's
163
+ * final token (the merged token's span runs past the line, so the reference
164
+ * readout excludes it and scores the token before). Both cases are detected by
165
+ * comparing the two encodings — no offset mapping needed.
166
+ *
167
+ * @returns {number[]} token index (chunk-relative) of each option's scored token
168
+ */
169
+ export function strandsOptionTokenIndices(lineIds, chunkIds) {
170
+ if (lineIds.length !== chunkIds.length) {
171
+ throw new TypeError("strandsOptionTokenIndices: lineIds and chunkIds must align");
172
+ }
173
+ return lineIds.map((line, i) => {
174
+ const chunk = chunkIds[i];
175
+ if (line.length === 0 || chunk.length === 0) {
176
+ throw new RangeError(`strands: option ${i} has no tokens left; the prompt was truncated through its option list`);
177
+ }
178
+ if (chunk.length > line.length) {
179
+ // Newline is its own trailing token(s): the line's tokens are intact.
180
+ for (let j = 0; j < line.length; j++) {
181
+ if (chunk[j] !== line[j]) {
182
+ throw new Error(`strands: option ${i} re-tokenised under its trailing newline; cannot place the readout`);
183
+ }
184
+ }
185
+ return line.length - 1;
186
+ }
187
+ // Newline merged with the line's final token(s): that token's span runs past
188
+ // the line, so the readout scores the token before it.
189
+ if (chunk.length < 2) {
190
+ throw new RangeError(`strands: option ${i} has no tokens left; the prompt was truncated through its option list`);
191
+ }
192
+ return chunk.length - 2;
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Tokenise state + questions with the reference `_fit` budget policy: the
198
+ * question gets first claim on the window (up to maxQuestionFraction), the
199
+ * state is truncated from the right into what remains, and a question longer
200
+ * than its reserve is truncated from the front, keeping the tail (options and
201
+ * the trailing `<answer>` marker, which is the pooling position).
202
+ *
203
+ * @param {{encode:(text:string, addSpecial?:boolean)=>number[], stateText:string,
204
+ * rendered:object[], maxLen?:number, maxQuestionFraction?:number}} spec
205
+ * @returns {{items:{ids:number[], optIdx:number[], kind:string}[], truncated:boolean}}
206
+ */
207
+ export function buildStrandsBatch({
208
+ encode,
209
+ stateText,
210
+ rendered,
211
+ maxLen = STRANDS_DEFAULT_MAX_LEN,
212
+ maxQuestionFraction = STRANDS_MAX_QUESTION_FRACTION,
213
+ }) {
214
+ // Questions are encoded with add_special_tokens=false throughout; the state
215
+ // alone carries the BOS, exactly like the reference runtime.
216
+ const questionChunks = rendered.map((rq) => ({
217
+ rq,
218
+ chunkIds: rq.chunks.map((c) => encode(c, false)),
219
+ }));
220
+ const longest = Math.max(...questionChunks.map((q) => q.chunkIds.flat().length));
221
+ const reserve = Math.min(longest, Math.max(1, Math.floor(maxLen * maxQuestionFraction)));
222
+ const stateBudget = Math.max(1, maxLen - reserve);
223
+ let stateIds = encode(stateText, true);
224
+ let truncated = false;
225
+ if (stateIds.length > stateBudget) {
226
+ stateIds = stateIds.slice(0, stateBudget);
227
+ truncated = true;
228
+ }
229
+
230
+ const items = questionChunks.map(({ rq, chunkIds }) => {
231
+ // Option lines are chunks 1..k (each "line\n"); locate each option's scored
232
+ // token inside its own chunk, where positions are exact by construction.
233
+ const lineChunks = chunkIds.slice(1, 1 + rq.slotLabels.length);
234
+ const lineIds = lineChunks.map((ids, i) =>
235
+ encode(rq.chunks[1 + i].slice(0, -1), false));
236
+ const inChunk = strandsOptionTokenIndices(lineIds, lineChunks);
237
+
238
+ let ids = chunkIds.flat();
239
+ let optIdx = inChunk.map((p, i) => {
240
+ let off = 0;
241
+ for (let c = 0; c < 1 + i; c++) off += chunkIds[c].length;
242
+ return off + p;
243
+ });
244
+ if (ids.length > reserve) {
245
+ const cut = ids.length - reserve;
246
+ ids = ids.slice(cut);
247
+ truncated = true;
248
+ optIdx = optIdx.map((p) => p - cut);
249
+ if (optIdx.some((p) => p < 0)) {
250
+ throw new RangeError(
251
+ "strands: the prompt was truncated through its option list; shorten the instructions");
252
+ }
253
+ }
254
+ const full = [...stateIds, ...ids];
255
+ return {
256
+ ids: full,
257
+ optIdx: optIdx.map((p) => p + stateIds.length),
258
+ kind: rq.kind,
259
+ };
260
+ });
261
+ return { items, truncated };
262
+ }
263
+
264
+ /**
265
+ * Pad a batch of Strands items into int64 tensors for the onnx-community
266
+ * graph (input_ids, attention_mask, answer_pos, option_pos).
267
+ *
268
+ * answer_pos is each row's last real token (the `<answer>` pooling position);
269
+ * option_pos clamps the -1 padding to 0 (mirrors the reference readout) —
270
+ * padded slots are never read back, each question only takes its first k logits.
271
+ */
272
+ export function collateStrandsItems(items) {
273
+ const n = items.length;
274
+ const L = Math.max(...items.map((it) => it.ids.length));
275
+ const K = Math.max(...items.map((it) => it.optIdx.length));
276
+ const inputIds = new BigInt64Array(n * L);
277
+ const attention = new BigInt64Array(n * L);
278
+ const optionPos = new BigInt64Array(n * K);
279
+ const answerPos = new BigInt64Array(n);
280
+ items.forEach((it, i) => {
281
+ it.ids.forEach((v, j) => { inputIds[i * L + j] = BigInt(v); attention[i * L + j] = 1n; });
282
+ it.optIdx.forEach((p, j) => { optionPos[i * K + j] = BigInt(Math.max(0, p)); });
283
+ answerPos[i] = BigInt(it.ids.length - 1);
284
+ });
285
+ return { n, L, K, inputIds, attention, optionPos, answerPos };
286
+ }
287
+
288
+ /** Normalised max-probability confidence (mirrors schema.derive_confidence). */
289
+ export function strandsChoiceConfidence(probabilities) {
290
+ const n = probabilities.length;
291
+ if (n <= 1) return 1;
292
+ const pMax = Math.max(...probabilities);
293
+ return Math.min(1, Math.max(0, (n * pMax - 1) / (n - 1)));
294
+ }
295
+
296
+ /**
297
+ * Ordinal confidence for score answers (mirrors schema.derive_score_confidence):
298
+ * mass on adjacent levels is agreement ("about 2.5"), not confusion — so this
299
+ * measures spread via the normalised standard deviation, with a floor
300
+ * correction for the ordinal smoothing the training targets carry.
301
+ */
302
+ export function strandsScoreConfidence(probabilities, ordinalSmoothing = STRANDS_ORDINAL_SMOOTHING) {
303
+ const n = probabilities.length;
304
+ if (n <= 1) return 1;
305
+ const total = probabilities.reduce((a, b) => a + b, 0);
306
+ if (total <= 0) return 0;
307
+ const p = probabilities.map((x) => x / total);
308
+ const mean = p.reduce((acc, pi, i) => acc + i * pi, 0);
309
+ const variance = p.reduce((acc, pi, i) => acc + pi * (i - mean) ** 2, 0);
310
+ const sigma = Math.sqrt(variance);
311
+ const sigmaMax = (n - 1) / 2;
312
+ const sigmaFloor = ordinalSmoothing > 0 ? Math.sqrt(ordinalSmoothing) : 0;
313
+ if (sigmaMax <= sigmaFloor) return sigma <= sigmaFloor ? 1 : 0;
314
+ return Math.min(1, Math.max(0, (sigmaMax - sigma) / (sigmaMax - sigmaFloor)));
315
+ }
316
+
317
+ /**
318
+ * Decode per-question logits into jev-web answers (mirrors infer._to_answer).
319
+ *
320
+ * `logits` is row-major [n, rowWidth] (one fused forward over the batch);
321
+ * each question reads its first `k` entries.
322
+ * @param {{logits:ArrayLike<number>, rowWidth:number, questions:object[],
323
+ * rendered:object[], temperaturesByKind?:object, temperature?:number,
324
+ * ordinalSmoothing?:number}} spec
325
+ */
326
+ export function strandsAnswersFromLogits({
327
+ logits,
328
+ rowWidth,
329
+ questions,
330
+ rendered,
331
+ temperaturesByKind = STRANDS_TEMPERATURE_BY_KIND,
332
+ temperature = STRANDS_TEMPERATURE,
333
+ ordinalSmoothing = STRANDS_ORDINAL_SMOOTHING,
334
+ }) {
335
+ const flat = Array.from(logits, Number);
336
+ const answers = [];
337
+ questions.forEach((q, i) => {
338
+ const rq = rendered[i];
339
+ const k = rq.slotLabels.length;
340
+ if (!Number.isInteger(rowWidth) || rowWidth < k) {
341
+ throw new TypeError(`strands: rowWidth ${rowWidth} < ${k} options`);
342
+ }
343
+ const t = Number(temperaturesByKind[rq.kind] ?? temperature);
344
+ const probs = softmaxWithTemperature(flat.slice(i * rowWidth, i * rowWidth + k), t);
345
+
346
+ if (rq.kind === "choice") {
347
+ const best = probs.indexOf(Math.max(...probs));
348
+ answers.push({
349
+ type: "choice",
350
+ choice: rq.slotLabels[best],
351
+ index: best,
352
+ probabilities: Object.fromEntries(rq.slotLabels.map((o, j) => [o, probs[j]])),
353
+ confidence: strandsChoiceConfidence(probs),
354
+ });
355
+ } else if (rq.kind === "score") {
356
+ // Slots may in principle be permuted; re-key by true level before the
357
+ // expectation so a shuffled rendering cannot score backwards.
358
+ const byLevel = Object.fromEntries(rq.slotLabels.map((lv, j) => [lv, probs[j]]));
359
+ const ordered = q.options.map((_, j) => byLevel[String(j)]);
360
+ const expected = ordered.reduce((acc, p, j) => acc + p * j, 0);
361
+ const best = ordered.indexOf(Math.max(...ordered));
362
+ answers.push({
363
+ type: "score",
364
+ score: expected,
365
+ level: best,
366
+ probabilities: Object.fromEntries(q.options.map((o, j) => [o, ordered[j]])),
367
+ confidence: strandsScoreConfidence(ordered, ordinalSmoothing),
368
+ });
369
+ } else {
370
+ const yes = probs[1];
371
+ answers.push({
372
+ type: "noul",
373
+ noul: yes,
374
+ probabilities: { [q.options[0]]: probs[0], [q.options[1]]: probs[1] },
375
+ confidence: Math.max(yes, 1 - yes),
376
+ });
377
+ }
378
+ });
379
+ return answers;
380
+ }
381
+
382
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
383
+
384
+ const hfUrl = (model, revision, file) =>
385
+ `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
386
+
387
+ /**
388
+ * Create a Strands Decider decider.
389
+ *
390
+ * @param {object} [opts]
391
+ * model/revision/modelFile — pinned browser ONNX export (or your own mirror)
392
+ * modelUrl — override the resolved ONNX URL
393
+ * ort — injected onnxruntime-web (lazy import otherwise)
394
+ * transformers — injected @huggingface/transformers (tokenizer only)
395
+ * tokenizerModel/tokenizerRevision — override the tokenizer source
396
+ * wasmPaths — ORT wasm/loader directory (self-hosted builds)
397
+ * device — "auto" | "wasm" | "webgpu"
398
+ * sessionFactory — (url, sessionOptions) => Promise<InferenceSession>;
399
+ * hosts needing custom loading override this
400
+ */
401
+ export async function createStrandsDecider({
402
+ model = STRANDS_DEFAULT_MODEL,
403
+ revision = STRANDS_DEFAULT_REVISION,
404
+ modelFile = STRANDS_DEFAULT_FILE,
405
+ modelUrl = null,
406
+ ort = null,
407
+ transformers = null,
408
+ fetchImpl = fetch,
409
+ tokenizerModel = null,
410
+ tokenizerRevision = null,
411
+ wasmPaths = null,
412
+ device = "auto",
413
+ sessionFactory = null,
414
+ onProgress = null,
415
+ maxLen = STRANDS_DEFAULT_MAX_LEN,
416
+ maxQuestionFraction = STRANDS_MAX_QUESTION_FRACTION,
417
+ temperaturesByKind = STRANDS_TEMPERATURE_BY_KIND,
418
+ temperature = STRANDS_TEMPERATURE,
419
+ ordinalSmoothing = STRANDS_ORDINAL_SMOOTHING,
420
+ scope = globalThis,
421
+ } = {}) {
422
+ const ortMod = ort ?? (await import("onnxruntime-web"));
423
+ const tf = transformers ?? (await import("@huggingface/transformers"));
424
+ if (wasmPaths) {
425
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
426
+ }
427
+
428
+ const resolvedDevice = device !== "auto"
429
+ ? device
430
+ : (scope?.navigator?.gpu ? "webgpu" : "wasm");
431
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
432
+
433
+ const report = (phase) => (p) =>
434
+ onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
435
+
436
+ const tok = await tf.AutoTokenizer.from_pretrained(tokenizerModel ?? model, {
437
+ revision: tokenizerRevision ?? (tokenizerModel ? undefined : revision),
438
+ progress_callback: report("tokenizer"),
439
+ });
440
+ const encode = (text, addSpecial = false) =>
441
+ Array.from(tok(text, { add_special_tokens: addSpecial }).input_ids.data, Number);
442
+ const decode = (ids) => {
443
+ try {
444
+ return tok.decode(Array.from(ids), { skip_special_tokens: false });
445
+ } catch {
446
+ return Array.from(ids).join(" ");
447
+ }
448
+ };
449
+
450
+ const url = modelUrl ?? hfUrl(model, revision, modelFile);
451
+ const createSession = sessionFactory
452
+ ?? (async (u, opts) => {
453
+ const modelBytes = await fetchCachedBytes(u, {
454
+ fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
455
+ });
456
+ const external = await fetchOnnxExternalData(u, {
457
+ fetchImpl, onProgress: (p) => onProgress?.(p),
458
+ });
459
+ const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
460
+ return ortMod.InferenceSession.create(modelBytes, sessionOpts);
461
+ });
462
+ let session;
463
+ let activeDevice = resolvedDevice;
464
+ try {
465
+ session = await createSession(url, {
466
+ executionProviders,
467
+ graphOptimizationLevel: "all",
468
+ });
469
+ } catch (e) {
470
+ if (resolvedDevice === "webgpu") {
471
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
472
+ session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
473
+ activeDevice = "wasm";
474
+ } else {
475
+ throw e;
476
+ }
477
+ }
478
+
479
+ const int64 = (arr, dims) => new ortMod.Tensor("int64", arr, dims);
480
+
481
+ return {
482
+ info: {
483
+ family: "strands-decider",
484
+ model,
485
+ revision,
486
+ device: activeDevice,
487
+ maxLen,
488
+ temperature,
489
+ temperaturesByKind: { ...temperaturesByKind },
490
+ ordinalSmoothing,
491
+ },
492
+ async decide(state, questions) {
493
+ const t0 = now();
494
+ const normalized = normalizeQuestions(questions);
495
+ const rendered = normalized.map(renderStrandsQuestion);
496
+ const stateText = renderStrandsState(state);
497
+ const { items, truncated } = buildStrandsBatch({
498
+ encode, stateText, rendered, maxLen, maxQuestionFraction,
499
+ });
500
+ const b = collateStrandsItems(items);
501
+
502
+ const tRun = now();
503
+ const out = await session.run({
504
+ input_ids: int64(b.inputIds, [b.n, b.L]),
505
+ attention_mask: int64(b.attention, [b.n, b.L]),
506
+ answer_pos: int64(b.answerPos, [b.n]),
507
+ option_pos: int64(b.optionPos, [b.n, b.K]),
508
+ });
509
+ const runMs = now() - tRun;
510
+ const logits = out.logits ?? out[Object.keys(out)[0]];
511
+
512
+ const answers = strandsAnswersFromLogits({
513
+ logits: logits.data,
514
+ rowWidth: b.K,
515
+ questions: normalized,
516
+ rendered,
517
+ temperaturesByKind,
518
+ temperature,
519
+ ordinalSmoothing,
520
+ });
521
+ const length = Number(b.attention.reduce((a, v) => a + v, 0n));
522
+ const prompts = items.map((it) => decode(it.ids));
523
+ return {
524
+ answers,
525
+ truncated,
526
+ length,
527
+ prompts,
528
+ timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
529
+ };
530
+ },
531
+ async dispose() {
532
+ try { await session.release?.(); } catch { /* optional */ }
533
+ },
534
+ };
535
+ }