jev-web 0.7.2 → 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/strands.mjs CHANGED
@@ -1,535 +1,718 @@
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
- }
1
+ /**
2
+ * strands.mjs — Strands Decider family (Qwen3.5-2B / Gemma 4 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
+ * Calibration is **not** part of the graph: every checkpoint ships its
23
+ * temperatures in `strands_decider_config.json` (the hobson-v19 pin still
24
+ * calls it `hobson_config.json`), so `createStrandsDecider` loads that file
25
+ * and lets it override the pinned reference values. A future checkpoint is
26
+ * then a model/revision repin — no code change — provided its browser export
27
+ * ships the config (see export/strands-decider/05_manifest.py).
28
+ */
29
+
30
+ import { normalizeQuestions } from "./questions.mjs";
31
+ import { softmaxWithTemperature } from "./answers.mjs";
32
+ import { fetchCachedBytes, fetchOnnxExternalData } from "./laya.mjs";
33
+
34
+ export const STRANDS_DEFAULT_MODEL = "onnx-community/strands-decider-2B-hobson-v19-ONNX";
35
+ export const STRANDS_DEFAULT_REVISION = "31a83e0244b26d939ae33f87189d6c8204003e53";
36
+ export const STRANDS_DEFAULT_FILE = "onnx/model_quantized.onnx";
37
+ export const STRANDS_Q4F16_FILE = "onnx/model_q4f16.onnx";
38
+
39
+ /**
40
+ * Named checkpoints. Temperatures are NOT stored here: every checkpoint
41
+ * ships them in strands_decider_config.json (hobson_config.json on v19) and
42
+ * createStrandsDecider reads that file at load time — adding a checkpoint is
43
+ * an entry here plus a model/revision pin, no runtime change.
44
+ */
45
+ export const STRANDS_VARIANTS = {
46
+ "hobson-v19": {
47
+ model: STRANDS_DEFAULT_MODEL,
48
+ revision: STRANDS_DEFAULT_REVISION,
49
+ description: "2B qwen3.5 torso, 1.8 GB q8 (the default)",
50
+ },
51
+ "qwen3.5-v1": {
52
+ model: "warsang/strands-decider-2b-qwen3v1-web",
53
+ revision: "5a5c936ac501379211b64181239301c95e2675d1",
54
+ description: "2B qwen3.5 torso, 3.7 GB fp16 (exported by "
55
+ + "export/strands-decider/; quantization pending — see RESUME.md)",
56
+ },
57
+ };
58
+
59
+ /** Reference calibration from the checkpoint's hobson_config.json. */
60
+ export const STRANDS_TEMPERATURE = 0.9627721607677362;
61
+ export const STRANDS_TEMPERATURE_BY_KIND = {
62
+ noul: 0.9107136998460428,
63
+ choice: 0.734189596436441,
64
+ score: 1.32780942142348,
65
+ };
66
+ export const STRANDS_ORDINAL_SMOOTHING = 0.1;
67
+ export const STRANDS_DEFAULT_MAX_LEN = 4096;
68
+ // Largest share of the window the question may claim before the state is squeezed.
69
+ export const STRANDS_MAX_QUESTION_FRACTION = 0.75;
70
+
71
+ export const STRANDS_KIND = { choice: "choice", score: "score", noul: "noul" };
72
+
73
+ /**
74
+ * Calibration files a strands-decider checkpoint can ship, tried in order.
75
+ * hobson-v19 (the original pin) names it `hobson_config.json`; v21 and the
76
+ * `v1-2610` gemma4/qwen3.5 checkpoints renamed it to
77
+ * `strands_decider_config.json`.
78
+ */
79
+ export const STRANDS_CONFIG_FILES = [
80
+ "strands_decider_config.json",
81
+ "hobson_config.json",
82
+ ];
83
+
84
+ /**
85
+ * Read this runtime's calibration out of a checkpoint's config JSON.
86
+ *
87
+ * Fallback is field-by-field: whatever the checkpoint does not declare keeps
88
+ * the caller's value, so a partial config only overrides what it states.
89
+ * A config declaring a non-pointer head fails loud — the LayerNorm -> q/k
90
+ * readout implemented here is not interchangeable with any other head.
91
+ *
92
+ * @param {object|null} config parsed config file (or null for no config)
93
+ * @param {{temperature?:number, temperaturesByKind?:object,
94
+ * ordinalSmoothing?:number, maxLen?:number}} [fallback]
95
+ * @returns {{temperature:number|undefined, temperaturesByKind:object,
96
+ * ordinalSmoothing:number|undefined, maxLen:number|undefined}}
97
+ */
98
+ export function parseStrandsCalibration(config, fallback = {}) {
99
+ const calibration = {
100
+ temperature: fallback.temperature,
101
+ temperaturesByKind: { ...(fallback.temperaturesByKind ?? {}) },
102
+ ordinalSmoothing: fallback.ordinalSmoothing,
103
+ maxLen: fallback.maxLen,
104
+ };
105
+ if (!config || typeof config !== "object") return calibration;
106
+
107
+ const headType = config.head_type;
108
+ if (headType !== undefined && headType !== "pointer") {
109
+ throw new TypeError(
110
+ `strands: checkpoint declares head_type "${headType}"; this runtime implements only the pointer head`);
111
+ }
112
+
113
+ const temperature = Number(config.temperature);
114
+ if (Number.isFinite(temperature) && temperature > 0) {
115
+ calibration.temperature = temperature;
116
+ }
117
+ if (config.temperature_by_kind && typeof config.temperature_by_kind === "object") {
118
+ for (const kind of Object.keys(STRANDS_KIND)) {
119
+ const t = Number(config.temperature_by_kind[kind]);
120
+ if (Number.isFinite(t) && t > 0) calibration.temperaturesByKind[kind] = t;
121
+ }
122
+ }
123
+ const smoothing = Number(config.ordinal_smoothing);
124
+ if (Number.isFinite(smoothing) && smoothing >= 0) {
125
+ calibration.ordinalSmoothing = smoothing;
126
+ }
127
+ const maxLen = Number(config.max_length);
128
+ if (Number.isInteger(maxLen) && maxLen >= 512) calibration.maxLen = maxLen;
129
+ return calibration;
130
+ }
131
+
132
+ /**
133
+ * Load the checkpoint's calibration config from the model repo, falling back
134
+ * field-by-field to the pinned reference values when the repo ships none
135
+ * (the current onnx-community pin does not) or is unreachable.
136
+ *
137
+ * @param {{model:string, revision?:string|null, files?:string[],
138
+ * fetchImpl?:Function}} spec
139
+ * @returns {Promise<{calibration:object, source:string|null}>} `source` is the
140
+ * URL the config was read from, or null when the pinned defaults survived.
141
+ */
142
+ export async function loadStrandsCalibration({
143
+ model,
144
+ revision = null,
145
+ files = STRANDS_CONFIG_FILES,
146
+ fetchImpl = fetch,
147
+ }) {
148
+ for (const file of files) {
149
+ const url = hfUrl(model, revision, file);
150
+ let res;
151
+ try {
152
+ res = await fetchImpl(url);
153
+ } catch {
154
+ // Transport failure: try the next candidate name, then the pinned
155
+ // defaults — the tokenizer/model downloads will surface the outage.
156
+ continue;
157
+ }
158
+ if (!res?.ok) continue; // repo ships no calibration config
159
+ let config;
160
+ try {
161
+ config = await res.json();
162
+ } catch (e) {
163
+ throw new Error(`strands: ${url} is not valid JSON: ${e?.message ?? e}`);
164
+ }
165
+ return { calibration: parseStrandsCalibration(config), source: url };
166
+ }
167
+ return { calibration: parseStrandsCalibration(null), source: null };
168
+ }
169
+
170
+ // Rendered on both sides of a noul so the two slots read like any other option list.
171
+ const NOUL_DEFAULT_CRITERIA = {
172
+ false: "the statement does not hold for this state",
173
+ true: "the statement holds for this state",
174
+ };
175
+
176
+ const KIND_HEADERS = {
177
+ noul: "Decide whether the statement is true of the state.",
178
+ choice: "Select exactly one option.",
179
+ score: "Rate the state against the ordered levels below (lowest first).",
180
+ };
181
+
182
+ /**
183
+ * Flatten a state or instruction into text, stably (mirrors render_content:
184
+ * dicts/lists become indented JSON so the same state always tokenises
185
+ * identically).
186
+ */
187
+ export function renderStrandsContent(content) {
188
+ if (typeof content === "string") return content.trim();
189
+ return JSON.stringify(content, null, 2);
190
+ }
191
+
192
+ function optionBlock(pairs) {
193
+ // Number options from 1 so slot k <-> the line reading `k+1.`.
194
+ // Returns each line's (start, end) character span within the block.
195
+ const lines = [];
196
+ const spans = [];
197
+ let cursor = 0;
198
+ pairs.forEach(([name, desc], i) => {
199
+ const flat = String(desc ?? "").split(/\s+/).join(" ");
200
+ const line = `${i + 1}. ${name}` + (flat ? ` \u2014 ${flat}` : "");
201
+ lines.push(line);
202
+ spans.push([cursor, cursor + line.length]);
203
+ cursor += line.length + 1; // the joining newline
204
+ });
205
+ return { text: lines.join("\n"), spans };
206
+ }
207
+
208
+ /**
209
+ * Render one normalized question to the exact prompt the head was trained on.
210
+ * @returns {{text:string, chunks:string[], slotLabels:string[],
211
+ * slotDescriptions:string[], kind:string, optionSpans:[number,number][]}}
212
+ *
213
+ * `chunks` is the question text split at pre-tokeniser piece boundaries
214
+ * ([prefix, line_0+"\n", …, line_{k-1}+"\n", suffix]) so tokenising each chunk
215
+ * separately and concatenating gives exactly the full-text tokenisation, with
216
+ * exact option-token positions and no offset mapping needed.
217
+ */
218
+ export function renderStrandsQuestion(question) {
219
+ const instructions = renderStrandsContent(question.instructions);
220
+ let pairs;
221
+ let slotLabels;
222
+ let slotDescriptions;
223
+ let kind;
224
+ if (question.type === "noul") {
225
+ // The reference fixes noul slot labels to false/true; `criteria` only
226
+ // overrides the rubric descriptions (mirrors prompting.NOUL_DEFAULT_CRITERIA).
227
+ // `question.options` still names the answer keys, not the prompt.
228
+ const crit = { ...NOUL_DEFAULT_CRITERIA, ...(question.criteria ?? {}) };
229
+ pairs = [
230
+ ["false", crit.false],
231
+ ["true", crit.true],
232
+ ];
233
+ slotLabels = ["false", "true"];
234
+ slotDescriptions = [crit.false, crit.true];
235
+ kind = "noul";
236
+ } else if (question.type === "choice") {
237
+ // `criteria` as {label: description} mirrors the reference schema and the
238
+ // typed-decisions fixtures; plain `options` keep working with empty rubrics.
239
+ if (question.criteria && typeof question.criteria === "object" && !Array.isArray(question.criteria)) {
240
+ pairs = Object.entries(question.criteria).map(([k, v]) => [String(k), String(v ?? "")]);
241
+ } else {
242
+ pairs = question.options.map((o) => [o, ""]);
243
+ }
244
+ slotLabels = pairs.map(([k]) => k);
245
+ slotDescriptions = pairs.map(([, v]) => v);
246
+ kind = "choice";
247
+ } else if (question.type === "score") {
248
+ // Level index is the label; the rubric text is the description.
249
+ pairs = question.options.map((desc, i) => [String(i), desc]);
250
+ slotLabels = question.options.map((_, i) => String(i));
251
+ slotDescriptions = [...question.options];
252
+ kind = "score";
253
+ } else {
254
+ throw new TypeError(`strands: unsupported question type ${question.type}`);
255
+ }
256
+
257
+ const header = KIND_HEADERS[kind];
258
+ const block = optionBlock(pairs);
259
+ const prefix = `<question type="${kind}">\n${header}\n${instructions}\n<options>\n`;
260
+ const suffix = `</options>\n</question>\n<answer>`;
261
+ const text = prefix + block.text + "\n" + suffix;
262
+ const base = prefix.length;
263
+ // Every option chunk carries its trailing newline, so no pre-tokeniser piece
264
+ // can span a chunk boundary and concatenating the chunk encodings reproduces
265
+ // the full-text tokenisation exactly.
266
+ const chunks = [prefix, ...block.spans.map(([s, e]) => block.text.slice(s, e) + "\n"), suffix];
267
+ return {
268
+ text,
269
+ chunks,
270
+ slotLabels,
271
+ slotDescriptions,
272
+ kind,
273
+ optionSpans: block.spans.map(([s, e]) => [base + s, base + e]),
274
+ };
275
+ }
276
+
277
+ /** The shared prefix: everything before this point is identical across questions. */
278
+ export function renderStrandsState(state) {
279
+ return `<state>\n${renderStrandsContent(state)}\n</state>\n`;
280
+ }
281
+
282
+ /**
283
+ * Scored token index of each option line inside its chunk's tokenisation.
284
+ *
285
+ * chunkIds[i] = encode(line_i + "\n"), lineIds[i] = encode(line_i). The trailing
286
+ * "\n" is either its own token (line tokens = lineIds) or merged with the line's
287
+ * final token (the merged token's span runs past the line, so the reference
288
+ * readout excludes it and scores the token before). Both cases are detected by
289
+ * comparing the two encodings — no offset mapping needed.
290
+ *
291
+ * @returns {number[]} token index (chunk-relative) of each option's scored token
292
+ */
293
+ export function strandsOptionTokenIndices(lineIds, chunkIds) {
294
+ if (lineIds.length !== chunkIds.length) {
295
+ throw new TypeError("strandsOptionTokenIndices: lineIds and chunkIds must align");
296
+ }
297
+ return lineIds.map((line, i) => {
298
+ const chunk = chunkIds[i];
299
+ if (line.length === 0 || chunk.length === 0) {
300
+ throw new RangeError(`strands: option ${i} has no tokens left; the prompt was truncated through its option list`);
301
+ }
302
+ if (chunk.length > line.length) {
303
+ // Newline is its own trailing token(s): the line's tokens are intact.
304
+ for (let j = 0; j < line.length; j++) {
305
+ if (chunk[j] !== line[j]) {
306
+ throw new Error(`strands: option ${i} re-tokenised under its trailing newline; cannot place the readout`);
307
+ }
308
+ }
309
+ return line.length - 1;
310
+ }
311
+ // Newline merged with the line's final token(s): that token's span runs past
312
+ // the line, so the readout scores the token before it.
313
+ if (chunk.length < 2) {
314
+ throw new RangeError(`strands: option ${i} has no tokens left; the prompt was truncated through its option list`);
315
+ }
316
+ return chunk.length - 2;
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Tokenise state + questions with the reference `_fit` budget policy: the
322
+ * question gets first claim on the window (up to maxQuestionFraction), the
323
+ * state is truncated from the right into what remains, and a question longer
324
+ * than its reserve is truncated from the front, keeping the tail (options and
325
+ * the trailing `<answer>` marker, which is the pooling position).
326
+ *
327
+ * @param {{encode:(text:string, addSpecial?:boolean)=>number[], stateText:string,
328
+ * rendered:object[], maxLen?:number, maxQuestionFraction?:number}} spec
329
+ * @returns {{items:{ids:number[], optIdx:number[], kind:string}[], truncated:boolean}}
330
+ */
331
+ export function buildStrandsBatch({
332
+ encode,
333
+ stateText,
334
+ rendered,
335
+ maxLen = STRANDS_DEFAULT_MAX_LEN,
336
+ maxQuestionFraction = STRANDS_MAX_QUESTION_FRACTION,
337
+ }) {
338
+ // Questions are encoded with add_special_tokens=false throughout; the state
339
+ // alone carries the BOS, exactly like the reference runtime.
340
+ const questionChunks = rendered.map((rq) => ({
341
+ rq,
342
+ chunkIds: rq.chunks.map((c) => encode(c, false)),
343
+ }));
344
+ const longest = Math.max(...questionChunks.map((q) => q.chunkIds.flat().length));
345
+ const reserve = Math.min(longest, Math.max(1, Math.floor(maxLen * maxQuestionFraction)));
346
+ const stateBudget = Math.max(1, maxLen - reserve);
347
+ let stateIds = encode(stateText, true);
348
+ let truncated = false;
349
+ if (stateIds.length > stateBudget) {
350
+ stateIds = stateIds.slice(0, stateBudget);
351
+ truncated = true;
352
+ }
353
+
354
+ const items = questionChunks.map(({ rq, chunkIds }) => {
355
+ // Option lines are chunks 1..k (each "line\n"); locate each option's scored
356
+ // token inside its own chunk, where positions are exact by construction.
357
+ const lineChunks = chunkIds.slice(1, 1 + rq.slotLabels.length);
358
+ const lineIds = lineChunks.map((ids, i) =>
359
+ encode(rq.chunks[1 + i].slice(0, -1), false));
360
+ const inChunk = strandsOptionTokenIndices(lineIds, lineChunks);
361
+
362
+ let ids = chunkIds.flat();
363
+ let optIdx = inChunk.map((p, i) => {
364
+ let off = 0;
365
+ for (let c = 0; c < 1 + i; c++) off += chunkIds[c].length;
366
+ return off + p;
367
+ });
368
+ if (ids.length > reserve) {
369
+ const cut = ids.length - reserve;
370
+ ids = ids.slice(cut);
371
+ truncated = true;
372
+ optIdx = optIdx.map((p) => p - cut);
373
+ if (optIdx.some((p) => p < 0)) {
374
+ throw new RangeError(
375
+ "strands: the prompt was truncated through its option list; shorten the instructions");
376
+ }
377
+ }
378
+ const full = [...stateIds, ...ids];
379
+ return {
380
+ ids: full,
381
+ optIdx: optIdx.map((p) => p + stateIds.length),
382
+ kind: rq.kind,
383
+ };
384
+ });
385
+ return { items, truncated };
386
+ }
387
+
388
+ /**
389
+ * Pad a batch of Strands items into int64 tensors for the onnx-community
390
+ * graph (input_ids, attention_mask, answer_pos, option_pos).
391
+ *
392
+ * answer_pos is each row's last real token (the `<answer>` pooling position);
393
+ * option_pos clamps the -1 padding to 0 (mirrors the reference readout) —
394
+ * padded slots are never read back, each question only takes its first k logits.
395
+ */
396
+ export function collateStrandsItems(items) {
397
+ const n = items.length;
398
+ const L = Math.max(...items.map((it) => it.ids.length));
399
+ const K = Math.max(...items.map((it) => it.optIdx.length));
400
+ const inputIds = new BigInt64Array(n * L);
401
+ const attention = new BigInt64Array(n * L);
402
+ const optionPos = new BigInt64Array(n * K);
403
+ const answerPos = new BigInt64Array(n);
404
+ items.forEach((it, i) => {
405
+ it.ids.forEach((v, j) => { inputIds[i * L + j] = BigInt(v); attention[i * L + j] = 1n; });
406
+ it.optIdx.forEach((p, j) => { optionPos[i * K + j] = BigInt(Math.max(0, p)); });
407
+ answerPos[i] = BigInt(it.ids.length - 1);
408
+ });
409
+ return { n, L, K, inputIds, attention, optionPos, answerPos };
410
+ }
411
+
412
+ /** Normalised max-probability confidence (mirrors schema.derive_confidence). */
413
+ export function strandsChoiceConfidence(probabilities) {
414
+ const n = probabilities.length;
415
+ if (n <= 1) return 1;
416
+ const pMax = Math.max(...probabilities);
417
+ return Math.min(1, Math.max(0, (n * pMax - 1) / (n - 1)));
418
+ }
419
+
420
+ /**
421
+ * Ordinal confidence for score answers (mirrors schema.derive_score_confidence):
422
+ * mass on adjacent levels is agreement ("about 2.5"), not confusion — so this
423
+ * measures spread via the normalised standard deviation, with a floor
424
+ * correction for the ordinal smoothing the training targets carry.
425
+ */
426
+ export function strandsScoreConfidence(probabilities, ordinalSmoothing = STRANDS_ORDINAL_SMOOTHING) {
427
+ const n = probabilities.length;
428
+ if (n <= 1) return 1;
429
+ const total = probabilities.reduce((a, b) => a + b, 0);
430
+ if (total <= 0) return 0;
431
+ const p = probabilities.map((x) => x / total);
432
+ const mean = p.reduce((acc, pi, i) => acc + i * pi, 0);
433
+ const variance = p.reduce((acc, pi, i) => acc + pi * (i - mean) ** 2, 0);
434
+ const sigma = Math.sqrt(variance);
435
+ const sigmaMax = (n - 1) / 2;
436
+ const sigmaFloor = ordinalSmoothing > 0 ? Math.sqrt(ordinalSmoothing) : 0;
437
+ if (sigmaMax <= sigmaFloor) return sigma <= sigmaFloor ? 1 : 0;
438
+ return Math.min(1, Math.max(0, (sigmaMax - sigma) / (sigmaMax - sigmaFloor)));
439
+ }
440
+
441
+ /**
442
+ * Decode per-question logits into jev-web answers (mirrors infer._to_answer).
443
+ *
444
+ * `logits` is row-major [n, rowWidth] (one fused forward over the batch);
445
+ * each question reads its first `k` entries.
446
+ * @param {{logits:ArrayLike<number>, rowWidth:number, questions:object[],
447
+ * rendered:object[], temperaturesByKind?:object, temperature?:number,
448
+ * ordinalSmoothing?:number}} spec
449
+ */
450
+ export function strandsAnswersFromLogits({
451
+ logits,
452
+ rowWidth,
453
+ questions,
454
+ rendered,
455
+ temperaturesByKind = STRANDS_TEMPERATURE_BY_KIND,
456
+ temperature = STRANDS_TEMPERATURE,
457
+ ordinalSmoothing = STRANDS_ORDINAL_SMOOTHING,
458
+ }) {
459
+ const flat = Array.from(logits, Number);
460
+ const answers = [];
461
+ questions.forEach((q, i) => {
462
+ const rq = rendered[i];
463
+ const k = rq.slotLabels.length;
464
+ if (!Number.isInteger(rowWidth) || rowWidth < k) {
465
+ throw new TypeError(`strands: rowWidth ${rowWidth} < ${k} options`);
466
+ }
467
+ const t = Number(temperaturesByKind[rq.kind] ?? temperature);
468
+ const probs = softmaxWithTemperature(flat.slice(i * rowWidth, i * rowWidth + k), t);
469
+
470
+ if (rq.kind === "choice") {
471
+ const best = probs.indexOf(Math.max(...probs));
472
+ answers.push({
473
+ type: "choice",
474
+ choice: rq.slotLabels[best],
475
+ index: best,
476
+ probabilities: Object.fromEntries(rq.slotLabels.map((o, j) => [o, probs[j]])),
477
+ confidence: strandsChoiceConfidence(probs),
478
+ });
479
+ } else if (rq.kind === "score") {
480
+ // Slots may in principle be permuted; re-key by true level before the
481
+ // expectation so a shuffled rendering cannot score backwards.
482
+ const byLevel = Object.fromEntries(rq.slotLabels.map((lv, j) => [lv, probs[j]]));
483
+ const ordered = q.options.map((_, j) => byLevel[String(j)]);
484
+ const expected = ordered.reduce((acc, p, j) => acc + p * j, 0);
485
+ const best = ordered.indexOf(Math.max(...ordered));
486
+ answers.push({
487
+ type: "score",
488
+ score: expected,
489
+ level: best,
490
+ probabilities: Object.fromEntries(q.options.map((o, j) => [o, ordered[j]])),
491
+ confidence: strandsScoreConfidence(ordered, ordinalSmoothing),
492
+ });
493
+ } else {
494
+ const yes = probs[1];
495
+ answers.push({
496
+ type: "noul",
497
+ noul: yes,
498
+ probabilities: { [q.options[0]]: probs[0], [q.options[1]]: probs[1] },
499
+ confidence: Math.max(yes, 1 - yes),
500
+ });
501
+ }
502
+ });
503
+ return answers;
504
+ }
505
+
506
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
507
+
508
+ const hfUrl = (model, revision, file) =>
509
+ `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
510
+
511
+ /**
512
+ * Create a Strands Decider decider.
513
+ *
514
+ * @param {object} [opts]
515
+ * model/revision/modelFile — pinned browser ONNX export (or your own mirror)
516
+ * modelUrl — override the resolved ONNX URL
517
+ * ort — injected onnxruntime-web (lazy import otherwise)
518
+ * transformers — injected @huggingface/transformers (tokenizer only)
519
+ * tokenizerModel/tokenizerRevision — override the tokenizer source
520
+ * calibrationModel/calibrationRevision — repo to read the checkpoint's
521
+ * calibration config from; defaults to `model`, or
522
+ * the source checkpoint when passing your own export
523
+ * maxLen — tokenization window; null = checkpoint's
524
+ * `max_length`, else the pinned 4096
525
+ * temperature/temperaturesByKind/ordinalSmoothing — explicit calibration;
526
+ * each overrides the checkpoint config, which
527
+ * overrides the pinned reference values
528
+ * wasmPaths — ORT wasm/loader directory (self-hosted builds)
529
+ * device — "auto" | "wasm" | "webgpu"
530
+ * sessionFactory — (url, sessionOptions) => Promise<InferenceSession>;
531
+ * hosts needing custom loading override this
532
+ */
533
+ export async function createStrandsDecider({
534
+ model = STRANDS_DEFAULT_MODEL,
535
+ revision = STRANDS_DEFAULT_REVISION,
536
+ modelFile = STRANDS_DEFAULT_FILE,
537
+ modelUrl = null,
538
+ ort = null,
539
+ transformers = null,
540
+ fetchImpl = fetch,
541
+ tokenizerModel = null,
542
+ tokenizerRevision = null,
543
+ calibrationModel = null,
544
+ calibrationRevision = null,
545
+ variant = null,
546
+ wasmPaths = null,
547
+ device = "auto",
548
+ sessionFactory = null,
549
+ onProgress = null,
550
+ maxLen = null,
551
+ maxQuestionFraction = null,
552
+ temperaturesByKind = null,
553
+ temperature = null,
554
+ ordinalSmoothing = null,
555
+ scope = globalThis,
556
+ } = {}) {
557
+ // Named checkpoint shortcut: {variant: "qwen3.5-v1"} sets model/revision
558
+ // (and the browser graph file) for that entry of STRANDS_VARIANTS.
559
+ if (variant) {
560
+ const v = STRANDS_VARIANTS[variant];
561
+ if (!v) {
562
+ throw new TypeError(
563
+ `strands: unknown variant "${variant}" (${Object.keys(STRANDS_VARIANTS).join(", ")})`);
564
+ }
565
+ const isDefault = model === STRANDS_DEFAULT_MODEL && revision === STRANDS_DEFAULT_REVISION;
566
+ if (isDefault) {
567
+ model = v.model;
568
+ revision = v.revision ?? null;
569
+ }
570
+ }
571
+ const ortMod = ort ?? (await import("onnxruntime-web"));
572
+ const tf = transformers ?? (await import("@huggingface/transformers"));
573
+ if (wasmPaths) {
574
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
575
+ }
576
+
577
+ // Calibration resolution order: this call's explicit options, then the
578
+ // checkpoint's own config file, then the pinned reference constants.
579
+ const { calibration, source: configSource } = await loadStrandsCalibration({
580
+ model: calibrationModel ?? model,
581
+ revision: calibrationRevision ?? (calibrationModel ? "main" : revision),
582
+ fetchImpl,
583
+ });
584
+ const kindTemps = { ...calibration.temperaturesByKind };
585
+ if (temperaturesByKind && typeof temperaturesByKind === "object") {
586
+ for (const [kind, t] of Object.entries(temperaturesByKind)) {
587
+ const v = Number(t);
588
+ if (Number.isFinite(v) && v > 0) kindTemps[kind] = v;
589
+ }
590
+ }
591
+ const eff = {
592
+ maxLen: Number.isFinite(maxLen) && maxLen > 0
593
+ ? maxLen
594
+ : (calibration.maxLen ?? STRANDS_DEFAULT_MAX_LEN),
595
+ maxQuestionFraction: Number.isFinite(maxQuestionFraction) && maxQuestionFraction > 0
596
+ ? maxQuestionFraction
597
+ : STRANDS_MAX_QUESTION_FRACTION,
598
+ temperature: Number.isFinite(temperature) && temperature > 0
599
+ ? temperature
600
+ : (calibration.temperature ?? STRANDS_TEMPERATURE),
601
+ ordinalSmoothing: Number.isFinite(ordinalSmoothing) && ordinalSmoothing >= 0
602
+ ? ordinalSmoothing
603
+ : (calibration.ordinalSmoothing ?? STRANDS_ORDINAL_SMOOTHING),
604
+ temperaturesByKind: { ...STRANDS_TEMPERATURE_BY_KIND, ...kindTemps },
605
+ };
606
+
607
+ const resolvedDevice = device !== "auto"
608
+ ? device
609
+ : (scope?.navigator?.gpu ? "webgpu" : "wasm");
610
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
611
+
612
+ const report = (phase) => (p) =>
613
+ onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
614
+
615
+ const tok = await tf.AutoTokenizer.from_pretrained(tokenizerModel ?? model, {
616
+ revision: tokenizerRevision ?? (tokenizerModel ? undefined : revision),
617
+ progress_callback: report("tokenizer"),
618
+ });
619
+ const encode = (text, addSpecial = false) =>
620
+ Array.from(tok(text, { add_special_tokens: addSpecial }).input_ids.data, Number);
621
+ const decode = (ids) => {
622
+ try {
623
+ return tok.decode(Array.from(ids), { skip_special_tokens: false });
624
+ } catch {
625
+ return Array.from(ids).join(" ");
626
+ }
627
+ };
628
+
629
+ const url = modelUrl ?? hfUrl(model, revision, modelFile);
630
+ const createSession = sessionFactory
631
+ ?? (async (u, opts) => {
632
+ const modelBytes = await fetchCachedBytes(u, {
633
+ fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
634
+ });
635
+ const external = await fetchOnnxExternalData(u, {
636
+ fetchImpl, onProgress: (p) => onProgress?.(p),
637
+ });
638
+ const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
639
+ return ortMod.InferenceSession.create(modelBytes, sessionOpts);
640
+ });
641
+ let session;
642
+ let activeDevice = resolvedDevice;
643
+ try {
644
+ session = await createSession(url, {
645
+ executionProviders,
646
+ graphOptimizationLevel: "all",
647
+ });
648
+ } catch (e) {
649
+ if (resolvedDevice === "webgpu") {
650
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
651
+ session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
652
+ activeDevice = "wasm";
653
+ } else {
654
+ throw e;
655
+ }
656
+ }
657
+
658
+ const int64 = (arr, dims) => new ortMod.Tensor("int64", arr, dims);
659
+
660
+ return {
661
+ info: {
662
+ family: "strands-decider",
663
+ model,
664
+ revision,
665
+ device: activeDevice,
666
+ maxLen: eff.maxLen,
667
+ temperature: eff.temperature,
668
+ temperaturesByKind: { ...eff.temperaturesByKind },
669
+ ordinalSmoothing: eff.ordinalSmoothing,
670
+ // URL of the checkpoint config the calibration came from, or null when
671
+ // the repo shipped none and the pinned reference values survived.
672
+ configSource,
673
+ },
674
+ async decide(state, questions) {
675
+ const t0 = now();
676
+ const normalized = normalizeQuestions(questions);
677
+ const rendered = normalized.map(renderStrandsQuestion);
678
+ const stateText = renderStrandsState(state);
679
+ const { items, truncated } = buildStrandsBatch({
680
+ encode, stateText, rendered,
681
+ maxLen: eff.maxLen, maxQuestionFraction: eff.maxQuestionFraction,
682
+ });
683
+ const b = collateStrandsItems(items);
684
+
685
+ const tRun = now();
686
+ const out = await session.run({
687
+ input_ids: int64(b.inputIds, [b.n, b.L]),
688
+ attention_mask: int64(b.attention, [b.n, b.L]),
689
+ answer_pos: int64(b.answerPos, [b.n]),
690
+ option_pos: int64(b.optionPos, [b.n, b.K]),
691
+ });
692
+ const runMs = now() - tRun;
693
+ const logits = out.logits ?? out[Object.keys(out)[0]];
694
+
695
+ const answers = strandsAnswersFromLogits({
696
+ logits: logits.data,
697
+ rowWidth: b.K,
698
+ questions: normalized,
699
+ rendered,
700
+ temperaturesByKind: eff.temperaturesByKind,
701
+ temperature: eff.temperature,
702
+ ordinalSmoothing: eff.ordinalSmoothing,
703
+ });
704
+ const length = Number(b.attention.reduce((a, v) => a + v, 0n));
705
+ const prompts = items.map((it) => decode(it.ids));
706
+ return {
707
+ answers,
708
+ truncated,
709
+ length,
710
+ prompts,
711
+ timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
712
+ };
713
+ },
714
+ async dispose() {
715
+ try { await session.release?.(); } catch { /* optional */ }
716
+ },
717
+ };
718
+ }