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/LICENSE +21 -21
- package/README.md +625 -502
- package/package.json +60 -60
- package/src/answers.mjs +36 -36
- package/src/bekko.mjs +439 -439
- package/src/config.mjs +65 -65
- package/src/decision2.mjs +382 -382
- package/src/encode.mjs +83 -83
- package/src/index.mjs +137 -112
- package/src/kev.mjs +296 -296
- package/src/laya.mjs +630 -630
- package/src/questions.mjs +39 -39
- package/src/registry.mjs +169 -155
- package/src/session.mjs +151 -151
- package/src/strands.mjs +718 -535
- package/src/vega.mjs +636 -0
package/src/strands.mjs
CHANGED
|
@@ -1,535 +1,718 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* strands.mjs — Strands Decider
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
let
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
}
|