jev-web 0.3.3 → 0.4.1
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/README.md +413 -201
- package/docs/demo-negation.png +0 -0
- package/docs/demo-typed.png +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +60 -55
- package/src/answers.mjs +36 -36
- package/src/bekko.mjs +391 -0
- package/src/config.mjs +65 -65
- package/src/encode.mjs +83 -83
- package/src/index.mjs +83 -42
- package/src/laya.mjs +630 -625
- package/src/questions.mjs +39 -39
- package/src/registry.mjs +129 -102
- package/src/session.mjs +151 -151
- package/src/strands.mjs +535 -0
package/src/strands.mjs
ADDED
|
@@ -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
|
+
}
|