jev-web 0.8.0 → 0.9.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/src/vega.mjs CHANGED
@@ -24,9 +24,17 @@
24
24
  * vega_config.json; split-conformal prediction sets and an abstain flag
25
25
  * come from the same energies, plus an "unbound particle" OOD signal.
26
26
  *
27
- * Two ONNX graphs (one fused torso -> pooled features, one engine -> logits +
28
- * physics), mirroring the reference runtime exactly; JS owns the protocol, the
29
- * packing, the thermal calibration and the answer decoding.
27
+ * Two ONNX graph layouts ship:
28
+ *
29
+ * fused (warsang/vega-08b-web, the default) — one fused torso that pools
30
+ * in-graph + the fp32 engine, 1.5 GB fp16 torso, base engine only;
31
+ * split (nvkudva/vega-onnx-q8) — an 8-bit embed graph, a cut decoder that
32
+ * emits the residual stream at layers 13/19 (the LM head and layers 20-23
33
+ * never run), the fp32 engine with both gated task adapters, and JS
34
+ * pooling; 840 MB, wasm-only (8-bit MatMulNBits has no WebGPU kernel).
35
+ *
36
+ * JS owns the protocol, the packing, the thermal calibration and the answer
37
+ * decoding in both.
30
38
  */
31
39
  import { normalizeQuestions } from "./questions.mjs";
32
40
  import { softmaxWithTemperature } from "./answers.mjs";
@@ -40,6 +48,37 @@ export const VEGA_DEFAULT_REVISION = "d2bd4604ed770ca614745f07f623b66e89f406e4";
40
48
  export const VEGA_TORSO_FILE = "onnx/torso_fp16.onnx";
41
49
  export const VEGA_ENGINE_FILE = "onnx/engine.onnx";
42
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Split export: nvkudva/vega-onnx-q8 (8-bit embed + cut decoder + fp32 engine)
53
+ // ---------------------------------------------------------------------------
54
+ export const VEGA_Q8_DEFAULT_MODEL = "nvkudva/vega-onnx-q8";
55
+ export const VEGA_Q8_DEFAULT_REVISION = "017c096d369ac698087f8c35a6ad8c867a7fe0f4";
56
+ export const VEGA_Q8_DEFAULT_SUBFOLDER = "v2";
57
+ export const VEGA_Q8_EMBED_FILE = "embed_q8.onnx";
58
+ export const VEGA_Q8_DECODER_FILE = "decoder_q8.onnx";
59
+ export const VEGA_Q8_ENGINE_FILE = "engine.onnx";
60
+
61
+ /**
62
+ * Named exports. `fp16` is this repo's own fused export (default); `q8` is
63
+ * nvkudva's split 8-bit export of the same checkpoint, which adds the two
64
+ * gated task adapters (public_intents / typed_decisions) and is wasm-only.
65
+ */
66
+ export const VEGA_VARIANTS = {
67
+ fp16: {
68
+ model: VEGA_DEFAULT_MODEL,
69
+ revision: VEGA_DEFAULT_REVISION,
70
+ format: "fused",
71
+ description: "fused fp16 torso + fp32 engine, 1.7 GB (the default)",
72
+ },
73
+ q8: {
74
+ model: VEGA_Q8_DEFAULT_MODEL,
75
+ revision: VEGA_Q8_DEFAULT_REVISION,
76
+ subfolder: VEGA_Q8_DEFAULT_SUBFOLDER,
77
+ format: "split",
78
+ description: "8-bit split embed + cut decoder + fp32 engine with adapters, 840 MB, wasm-only",
79
+ },
80
+ };
81
+
43
82
  /** Reference prompt template 0 (the inference surface form). */
44
83
  const VEGA_HEAD = "Observation:\n";
45
84
  const VEGA_QUESTION = "\n\nMeasurement ({t}): {ins}\nPossible outcomes:";
@@ -75,7 +114,10 @@ export const VEGA_DEFAULTS = {
75
114
  * Vega's `boolean` is the noul primitive; its slots are fixed [no, yes].
76
115
  */
77
116
  export function vegaQuestionDef(question) {
78
- const ins = renderVegaText(question.instructions);
117
+ // the reference json.dumps non-string instructions with ensure_ascii=True
118
+ const ins = typeof question.instructions === "string"
119
+ ? question.instructions
120
+ : vegaJsonDumps(question.instructions, true);
79
121
  if (question.type === "noul") {
80
122
  return { t: "boolean", ins, crit: { ...(question.criteria ?? {}) } };
81
123
  }
@@ -90,28 +132,70 @@ export function vegaQuestionDef(question) {
90
132
  return { t: "choice", ins, crit };
91
133
  }
92
134
 
93
- function flatText(v) {
94
- return v === null || v === undefined ? "" : String(v);
135
+ /**
136
+ * Python `json.dumps(x, ensure_ascii)` — ", " / ": " separators, control
137
+ * characters escaped, and optionally \uXXXX for everything above ASCII. The
138
+ * reference runtime serializes dict states, non-string instructions and
139
+ * non-string criteria values with it, so the prompt stays byte-identical to
140
+ * the Python original (JSON.stringify uses compact separators and never
141
+ * escapes non-ASCII).
142
+ */
143
+ export function vegaJsonDumps(value, ensureAscii = false) {
144
+ const esc = (s) => {
145
+ let out = '"';
146
+ for (const ch of s) {
147
+ const c = ch.codePointAt(0);
148
+ if (ch === '"') out += '\\"';
149
+ else if (ch === "\\") out += "\\\\";
150
+ else if (ch === "\n") out += "\\n";
151
+ else if (ch === "\r") out += "\\r";
152
+ else if (ch === "\t") out += "\\t";
153
+ else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0");
154
+ else if (ensureAscii && c > 0x7f) {
155
+ if (c > 0xffff) {
156
+ const u = c - 0x10000;
157
+ out += "\\u" + (0xd800 + (u >> 10)).toString(16).padStart(4, "0");
158
+ out += "\\u" + (0xdc00 + (u & 0x3ff)).toString(16).padStart(4, "0");
159
+ } else out += "\\u" + c.toString(16).padStart(4, "0");
160
+ } else out += ch;
161
+ }
162
+ return out + '"';
163
+ };
164
+ const go = (x) => {
165
+ if (x === null || x === undefined) return "null";
166
+ if (typeof x === "string") return esc(x);
167
+ if (typeof x === "boolean") return x ? "true" : "false";
168
+ if (typeof x === "number") return String(x);
169
+ if (Array.isArray(x)) return "[" + x.map(go).join(", ") + "]";
170
+ return "{" + Object.entries(x).map(([k, v]) => esc(k) + ": " + go(v)).join(", ") + "}";
171
+ };
172
+ return go(value);
173
+ }
174
+
175
+ /** json.dumps of a non-string criterion value (strings pass through). */
176
+ function vegaCritText(v) {
177
+ return typeof v === "string" ? v : vegaJsonDumps(v, false);
95
178
  }
96
179
 
180
+ /** The reference render_text: strings verbatim, anything else json.dumps. */
97
181
  export function renderVegaText(content) {
98
- return typeof content === "string" ? content : JSON.stringify(content);
182
+ return typeof content === "string" ? content : vegaJsonDumps(content, false);
99
183
  }
100
184
 
101
185
  /** Option texts in label-index order, exactly as option_texts() renders them. */
102
186
  export function vegaOptionTexts(qdef) {
103
187
  if (qdef.t === "choice") {
104
188
  return Object.entries(qdef.crit).map(([k, v]) => {
105
- const d = v === null || v === undefined ? "" : flatText(v);
189
+ const d = v === null || v === undefined ? "" : vegaCritText(v);
106
190
  return d ? `${k} - ${d}` : String(k);
107
191
  });
108
192
  }
109
193
  if (qdef.t === "score") {
110
- return qdef.crit.map((c, i) => `level ${i} - ${flatText(c)}`);
194
+ return qdef.crit.map((c, i) => `level ${i} - ${vegaCritText(c)}`);
111
195
  }
112
196
  const crit = qdef.crit ?? {};
113
- const no = crit.false === null || crit.false === undefined ? "" : flatText(crit.false);
114
- const yes = crit.true === null || crit.true === undefined ? "" : flatText(crit.true);
197
+ const no = crit.false === null || crit.false === undefined ? "" : vegaCritText(crit.false);
198
+ const yes = crit.true === null || crit.true === undefined ? "" : vegaCritText(crit.true);
115
199
  return [
116
200
  `no - ${no || "the statement does not hold"}`,
117
201
  `yes - ${yes || "the statement holds"}`,
@@ -127,6 +211,10 @@ export function vegaOptionTexts(qdef) {
127
211
  * state. Options are capped per option, and an over-long option list is
128
212
  * shrunk evenly before the instructions are (mirrors build_prompt).
129
213
  *
214
+ * `truncateState` defaults to the reference's silent truncation; the split
215
+ * export refuses the decision instead (a cut-off record is worse than none),
216
+ * so its runtime passes false and gets a RangeError.
217
+ *
130
218
  * @returns {{ids:number[], spans:{state:[number,number], question:[number,number],
131
219
  * options:[number,number][], last:number}}}
132
220
  */
@@ -139,6 +227,7 @@ export function buildVegaPrompt({
139
227
  queryFirstMaxState = VEGA_DEFAULTS.queryFirstMaxState,
140
228
  optMaxTokens = VEGA_DEFAULTS.optMaxTokens,
141
229
  insMaxTokens = VEGA_DEFAULTS.insMaxTokens,
230
+ truncateState = true,
142
231
  }) {
143
232
  const options = vegaOptionTexts(qdef);
144
233
  const enc = (s) => encode(s, false);
@@ -169,6 +258,11 @@ export function buildVegaPrompt({
169
258
  fixed += pre.length;
170
259
  }
171
260
  const room = Math.max(0, maxLen - fixed);
261
+ if (!truncateState && st.length > room) {
262
+ throw new RangeError(
263
+ `vega: state is ${st.length} tokens, only ${room} fit in maxLen=${maxLen}`
264
+ + " (this export refuses a truncated state; raise maxLen)");
265
+ }
172
266
  st = st.slice(0, room); // truncate_left = false: the head of the state is kept
173
267
  const ids = [...bos, ...pre, ...head];
174
268
  const s0 = ids.length;
@@ -294,20 +388,26 @@ export const VEGA_DEFAULT_THETA = [[0], [0], [0]];
294
388
  * Per-type thermal calibration from the checkpoint's vega_config.json:
295
389
  * log_tau = b + w . phys, tau in [0.2, 20]; probs = softmax(logits / tau).
296
390
  *
391
+ * `thetaByRow` overrides the per-type lookup with an explicit theta block per
392
+ * item (the split export calibrates each question with the routed adapter's
393
+ * block).
394
+ *
297
395
  * @param {{theta:number[][]}} calibration
298
396
  * @param {{logits:ArrayLike<number>, rowWidth:number, qtypes:number[],
299
- * phys:ArrayLike<number>, nOpts:number[]}} spec phys is [items, 4]
397
+ * phys:ArrayLike<number>, nOpts:number[], thetaByRow?:number[][][]}} spec
398
+ * phys is [items, 4]
300
399
  * @returns {number[][]} probabilities per item, in prompt order
301
400
  */
302
401
  export function vegaThermalProbabilities({
303
402
  logits, rowWidth, qtypes, phys, nOpts,
304
403
  theta = VEGA_DEFAULT_THETA,
404
+ thetaByRow = null,
305
405
  }) {
306
406
  const rows = Number(logits.length / rowWidth);
307
407
  const out = [];
308
408
  for (let b = 0; b < rows; b++) {
309
409
  const k = nOpts[b];
310
- const t = theta[qtypes[b]] ?? theta[0];
410
+ const t = thetaByRow ? (thetaByRow[b] ?? theta[0]) : (theta[qtypes[b]] ?? theta[0]);
311
411
  let logTau = t[0];
312
412
  for (let f = 0; f < 4; f++) logTau += t[1 + f] * phys[b * 4 + f];
313
413
  const tau = Math.exp(Math.min(Math.log(20), Math.max(Math.log(0.2), logTau)));
@@ -336,26 +436,31 @@ export function vegaThermalFeatures({ kinetic, nearest, stepsUsed, nOpts, maxSte
336
436
 
337
437
  /**
338
438
  * Vega answers -> jev-web DecideResult answers (additive vega fields:
339
- * predictionSet / abstain / unbound / settleSteps / energy).
439
+ * predictionSet / abstain / unbound / settleSteps / energy, and — when the
440
+ * split export routes per question — adapter / gate).
340
441
  *
341
442
  * @param {{probs:number[][], qtypes:number[], labels:string[][],
342
443
  * physics:{nearest:ArrayLike<number>, stepsUsed:ArrayLike<number>},
343
444
  * rawLogits:ArrayLike<number>, rowWidth:number,
344
- * calibration:object, alpha?:string, risk?:string}} spec
445
+ * calibration:object, alpha?:string, risk?:string,
446
+ * calibrations?:object[], adapterNames?:string[], gates?:number[][],
447
+ * routes?:number[]}} spec calibrations/gates are per row when routed
345
448
  */
346
449
  export function vegaAnswers({
347
450
  probs, qtypes, physics, rawLogits, rowWidth, calibration, questions, labels,
348
451
  alpha = VEGA_DEFAULTS.alpha, risk = VEGA_DEFAULTS.risk,
452
+ calibrations = null, adapterNames = null, gates = null, routes = null,
349
453
  }) {
350
454
  const answers = [];
351
455
  labels.forEach((optionLabels, r) => {
352
456
  const p = probs[r];
353
457
  const k = optionLabels.length;
354
458
  const qtype = VEGA_QTYPE_NAMES[qtypes[r]];
459
+ const cal = calibrations?.[r] ?? calibration;
355
460
  const conf = Math.max(...p);
356
461
  const best = p.indexOf(Math.max(...p));
357
462
  // LAC prediction set; the top option is always kept so it is never empty.
358
- const th = calibration?.conformal?.[qtype]?.[String(alpha)];
463
+ const th = cal?.conformal?.[qtype]?.[String(alpha)];
359
464
  const predictionSet = th === undefined ? null
360
465
  : optionLabels.filter((_, i) => i === best || p[i] >= Math.max(1 - th, 1e-12));
361
466
  // energy = -max raw logit over the question's real options
@@ -365,11 +470,16 @@ export function vegaAnswers({
365
470
  probabilities: Object.fromEntries(optionLabels.map((l, i) => [l, p[i]])),
366
471
  confidence: conf,
367
472
  predictionSet,
368
- abstain: conf < (calibration?.abstain_threshold?.[String(risk)] ?? 0),
369
- unbound: physics.nearest[r] > (calibration?.unbound_threshold ?? Infinity),
473
+ abstain: conf < (cal?.abstain_threshold?.[String(risk)] ?? 0),
474
+ unbound: physics.nearest[r] > (cal?.unbound_threshold ?? Infinity),
370
475
  settleSteps: physics.stepsUsed[r],
371
476
  energy: -bestLogit,
372
477
  };
478
+ if (adapterNames) {
479
+ const rt = routes?.[r] ?? -1;
480
+ a.adapter = rt >= 0 ? (adapterNames[rt] ?? null) : null;
481
+ a.gate = gates?.[r]?.length ? Math.max(...gates[r]) : 0;
482
+ }
373
483
  if (qtype === "choice") {
374
484
  a.type = "choice";
375
485
  a.choice = optionLabels[best];
@@ -396,6 +506,100 @@ export function vegaOptionLabels(questions) {
396
506
  });
397
507
  }
398
508
 
509
+ // ---------------------------------------------------------------------------
510
+ // Split export: JS-side perception (positions, past state, pooling, routing)
511
+ // ---------------------------------------------------------------------------
512
+
513
+ /**
514
+ * Adapter routing (reference route_adapters): the most open adapter if its
515
+ * gate reaches the threshold, else -1 (the base engine calibrates the answer).
516
+ */
517
+ export function vegaRoute(gates, threshold = 0.5) {
518
+ if (!gates?.length) return -1;
519
+ let best = 0;
520
+ for (let i = 1; i < gates.length; i++) if (gates[i] > gates[best]) best = i;
521
+ return gates[best] >= threshold ? best : -1;
522
+ }
523
+
524
+ /** Text M-RoPE positions: 0..L-1 on each of the three rows ([3, 1, L]). */
525
+ export function vegaTextPositions(L) {
526
+ const out = new BigInt64Array(3 * L);
527
+ for (let i = 0; i < L; i++) {
528
+ out[i] = BigInt(i);
529
+ out[L + i] = BigInt(i);
530
+ out[2 * L + i] = BigInt(i);
531
+ }
532
+ return out;
533
+ }
534
+
535
+ /**
536
+ * Zero-filled first-read state for every past_* input the cut decoder keeps
537
+ * (conv / recurrent / key-value caches). Shapes come from the graph's own
538
+ * input metadata: batch_size -> 1, any other symbol (past_sequence_length)
539
+ * -> 0.
540
+ */
541
+ export function emptyVegaPast(session, Tensor) {
542
+ const feeds = {};
543
+ for (const m of session?.inputMetadata ?? []) {
544
+ if (m?.isTensor === false || !String(m.name).startsWith("past_")) continue;
545
+ const dims = (m.shape ?? []).map(
546
+ (d) => (typeof d === "number" ? d : d === "batch_size" ? 1 : 0));
547
+ feeds[m.name] = new Tensor("float32",
548
+ new Float32Array(dims.reduce((a, b) => a * b, 1)), dims);
549
+ }
550
+ return feeds;
551
+ }
552
+
553
+ /**
554
+ * Perception._pooled_batch for one text item, over the hidden states the split
555
+ * decoder emits: the state/question/final-token span means at every feature
556
+ * layer, the option span means at the deepest option layer, and the state cut
557
+ * into up to C equal chunk means for the memory reader (states shorter than
558
+ * chunkMinTokens get none). Image states get no chunks, as in vega_vision.py.
559
+ *
560
+ * @returns {{feats:Float32Array, opts:Float32Array, chunks:Float32Array,
561
+ * cmask:Uint8Array}} feats [2, 3, d] (layer, slot, d)
562
+ */
563
+ export function poolVegaHidden({
564
+ h13, h19, spans, d,
565
+ chunks: C = VEGA_DEFAULTS.memoryChunks,
566
+ chunkMinTokens = VEGA_DEFAULTS.memoryMinTokens,
567
+ chunkTokens = VEGA_DEFAULTS.memoryChunkTokens,
568
+ withChunks = true,
569
+ }) {
570
+ const L = Math.floor(h13.length / d);
571
+ const mean = (h, a, e, out, off) => {
572
+ if (e <= a) return;
573
+ for (let t = a; t < e; t++) {
574
+ for (let j = 0; j < d; j++) out[off + j] += h[t * d + j];
575
+ }
576
+ for (let j = 0; j < d; j++) out[off + j] /= e - a;
577
+ };
578
+ const feats = new Float32Array(2 * 3 * d);
579
+ [h13, h19].forEach((h, l) => {
580
+ mean(h, spans.state[0], spans.state[1], feats, (l * 3 + 0) * d);
581
+ mean(h, spans.question[0], spans.question[1], feats, (l * 3 + 1) * d);
582
+ feats.set(h.subarray((L - 1) * d, L * d), (l * 3 + 2) * d);
583
+ });
584
+ const opts = new Float32Array(spans.options.length * d);
585
+ spans.options.forEach(([a, e], k) => mean(h19, a, e, opts, k * d));
586
+ const chunks = new Float32Array(C * d);
587
+ const cmask = new Uint8Array(C);
588
+ const [s0, s1] = spans.state;
589
+ const m = s1 - s0;
590
+ if (withChunks && m >= Math.max(chunkMinTokens, 2)) {
591
+ const k = Math.max(2, Math.min(C, Math.floor(m / Math.max(1, chunkTokens))));
592
+ // token t goes to chunk floor(t*k/m): chunk c covers [ceil(c*m/k), ceil((c+1)*m/k))
593
+ for (let c = 0; c < k; c++) {
594
+ const a = s0 + Math.ceil((c * m) / k);
595
+ const e = s0 + (c === k - 1 ? m : Math.ceil(((c + 1) * m) / k));
596
+ mean(h19, a, e, chunks, c * d);
597
+ }
598
+ cmask.fill(1, 0, k);
599
+ }
600
+ return { feats, opts, chunks, cmask };
601
+ }
602
+
399
603
  // ---------------------------------------------------------------------------
400
604
  // The decider
401
605
  // ---------------------------------------------------------------------------
@@ -405,20 +609,87 @@ const now = () => (typeof performance !== "undefined" ? performance.now() : Date
405
609
  const hfUrl = (model, revision, file) =>
406
610
  `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
407
611
 
612
+ /**
613
+ * Session opener shared by both Vega formats: graph bytes (plus an external
614
+ * data sibling when the graph has one) go through the injected fetch and the
615
+ * Cache API, then ORT creates the session; a WebGPU create failure retries on
616
+ * wasm. Hosts that load weights differently (local files, bundled, offline
617
+ * mirrors) replace the whole step with sessionFactory(url, opts).
618
+ */
619
+ function openVegaSessions({
620
+ ortMod, fetchImpl, onProgress, sessionFactory, executionProviders, device,
621
+ }) {
622
+ const create = sessionFactory
623
+ ?? (async (url, opts) => {
624
+ const bytes = await fetchCachedBytes(url, {
625
+ fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
626
+ });
627
+ const external = await fetchOnnxExternalData(url, { fetchImpl });
628
+ const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
629
+ return ortMod.InferenceSession.create(bytes, sessionOpts);
630
+ });
631
+ return async (url) => {
632
+ try {
633
+ return await create(url, { executionProviders, graphOptimizationLevel: "all" });
634
+ } catch (e) {
635
+ if (device !== "webgpu") throw e;
636
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
637
+ return create(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
638
+ }
639
+ };
640
+ }
641
+
408
642
  /**
409
643
  * Create a Vega decider.
410
644
  *
411
645
  * @param {object} [opts]
412
- * model/revision — browser export repo (torso + engine graphs, tokenizer,
413
- * vega_config.json with the calibration block)
414
- * torsoFile/engineFile — graph paths within the repo
415
- * calibrationModel — repo to read vega_config.json from (default: model;
646
+ * variant — "fp16" (default, this repo's fused export) or "q8"
647
+ * (nvkudva/vega-onnx-q8: 8-bit split embed + decoder +
648
+ * fp32 engine with adapters, wasm-only). Sets
649
+ * model/revision/subfolder/format; explicit
650
+ * model/revision/format still win.
651
+ * format — "fused" | "split" (normally derived from the variant)
652
+ * model/revision/subfolder — export repo and, for split exports, the
653
+ * version folder that holds the graphs (default "v2")
654
+ * torsoFile/engineFile — fused tograph paths within the repo
655
+ * embedFile/decoderFile — split graph names within the subfolder
656
+ * calibrationModel — repo to read the config from (default: model;
416
657
  * point it at the source checkpoint for your own export)
417
658
  * maxLen — prompt window; null = checkpoint's max_length
418
- * alpha / risk — conformal risk level and abstain risk ("0.1" default)
659
+ * alpha / risk — conformal risk level and abstain risk
660
+ * (default: the calibration block's default_risk)
419
661
  * device, wasmPaths, sessionFactory, onProgress — as in the other families
420
662
  */
421
- export async function createVegaDecider({
663
+ export async function createVegaDecider(opts = {}) {
664
+ const variant = opts.variant ?? null;
665
+ const preset = variant === null ? null : VEGA_VARIANTS[variant];
666
+ if (variant !== null && !preset) {
667
+ throw new TypeError(
668
+ `vega: unknown variant "${variant}" (${Object.keys(VEGA_VARIANTS).join(", ")})`);
669
+ }
670
+ const format = opts.format ?? preset?.format ?? "fused";
671
+ if (format !== "fused" && format !== "split") {
672
+ throw new TypeError(`vega: format must be "fused" or "split" (got ${JSON.stringify(format)})`);
673
+ }
674
+ // Like strands: a named variant replaces the *default* model/revision, so
675
+ // createDecisionRuntime({family: "vega", variant: "q8"}) works even though
676
+ // the registry passes the default target through; a deliberately different
677
+ // model/revision still wins.
678
+ const isDefaultTarget = (opts.model ?? VEGA_DEFAULT_MODEL) === VEGA_DEFAULT_MODEL
679
+ && (opts.revision ?? VEGA_DEFAULT_REVISION) === VEGA_DEFAULT_REVISION;
680
+ const usePreset = preset && isDefaultTarget;
681
+ const resolved = {
682
+ model: usePreset ? preset.model : (opts.model ?? VEGA_DEFAULT_MODEL),
683
+ revision: usePreset ? preset.revision : (opts.revision ?? VEGA_DEFAULT_REVISION),
684
+ subfolder: opts.subfolder ?? preset?.subfolder ?? null,
685
+ };
686
+ return format === "split"
687
+ ? createVegaSplitDecider(opts, resolved)
688
+ : createVegaFusedDecider({ ...opts, ...resolved });
689
+ }
690
+
691
+ /** The fused export (one torso graph + the base engine), the default. */
692
+ async function createVegaFusedDecider({
422
693
  model = VEGA_DEFAULT_MODEL,
423
694
  revision = VEGA_DEFAULT_REVISION,
424
695
  torsoFile = VEGA_TORSO_FILE,
@@ -434,7 +705,7 @@ export async function createVegaDecider({
434
705
  onProgress = null,
435
706
  maxLen = null,
436
707
  alpha = VEGA_DEFAULTS.alpha,
437
- risk = VEGA_DEFAULTS.risk,
708
+ risk = null,
438
709
  scope = globalThis,
439
710
  } = {}) {
440
711
  const ortMod = ort ?? (await import("onnxruntime-web"));
@@ -463,6 +734,7 @@ export async function createVegaDecider({
463
734
  } catch { /* fall through to the shipped defaults */ }
464
735
  const cfg = { ...VEGA_DEFAULTS, ...(config ?? {}) };
465
736
  const calibration = config?.calibration ?? {};
737
+ const effRisk = risk ?? calibration.default_risk ?? cfg.default_risk ?? VEGA_DEFAULTS.risk;
466
738
  const cfgMaxLen = config?.max_len ?? cfg.maxLen;
467
739
  const effMaxLen = Number.isFinite(maxLen) && maxLen > 0 ? maxLen : cfgMaxLen;
468
740
 
@@ -473,29 +745,12 @@ export async function createVegaDecider({
473
745
  const encode = (text, addSpecial = false) =>
474
746
  Array.from(tok(text, { add_special_tokens: addSpecial }).input_ids.data, Number);
475
747
 
476
- const createSession = sessionFactory
477
- ?? (async (u, opts) => {
478
- const bytes = await fetchCachedBytes(u, {
479
- fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
480
- });
481
- const external = await fetchOnnxExternalData(u, { fetchImpl });
482
- const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
483
- return ortMod.InferenceSession.create(bytes, sessionOpts);
484
- });
485
- const makeSession = async (file) => {
486
- try {
487
- return await createSession(hfUrl(model, revision, file), {
488
- executionProviders, graphOptimizationLevel: "all",
489
- });
490
- } catch (e) {
491
- if (resolvedDevice !== "webgpu") throw e;
492
- onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
493
- return createSession(hfUrl(model, revision, file),
494
- { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
495
- }
496
- };
497
- const torso = await makeSession(torsoFile);
498
- const engine = await makeSession(engineFile);
748
+ const open = openVegaSessions({
749
+ ortMod, fetchImpl, onProgress, sessionFactory,
750
+ executionProviders, device: resolvedDevice,
751
+ });
752
+ const torso = await open(hfUrl(model, revision, torsoFile));
753
+ const engine = await open(hfUrl(model, revision, engineFile));
499
754
 
500
755
  const int64 = (arr, dims) => new ortMod.Tensor("int64", arr, dims);
501
756
  const f32 = (arr, dims) => new ortMod.Tensor("float32", arr, dims);
@@ -504,12 +759,13 @@ export async function createVegaDecider({
504
759
  return {
505
760
  info: {
506
761
  family: "vega",
762
+ format: "fused",
507
763
  model,
508
764
  revision,
509
765
  device: resolvedDevice,
510
766
  maxLen: effMaxLen,
511
767
  alpha: String(alpha),
512
- risk: String(risk ?? cfg.default_risk ?? VEGA_DEFAULTS.risk),
768
+ risk: String(effRisk),
513
769
  calibrationSource: configSource,
514
770
  nSteps: cfg.n_steps ?? cfg.nSteps,
515
771
  config,
@@ -611,7 +867,7 @@ export async function createVegaDecider({
611
867
  probs, qtypes: batch.qtypes, labels,
612
868
  physics: { nearest, stepsUsed },
613
869
  rawLogits: logits, rowWidth: batch.K,
614
- calibration, alpha, risk,
870
+ calibration, alpha, risk: effRisk,
615
871
  });
616
872
  const decode = (ids) => {
617
873
  try {
@@ -634,3 +890,236 @@ export async function createVegaDecider({
634
890
  },
635
891
  };
636
892
  }
893
+
894
+ /**
895
+ * The split export (nvkudva/vega-onnx-q8): an 8-bit embedding graph, a cut
896
+ * decoder that stops after layer 19 and emits the residual stream at layers
897
+ * 13/19 (the LM head and layers 20-23 never run), and the fp32 engine with
898
+ * both gated task adapters. JS pools the hidden states, each question is
899
+ * calibrated with the theta block of the adapter its gate routes it to, and
900
+ * the conformal / abstain / unbound machinery reads that block's thresholds.
901
+ *
902
+ * Text only in v1: the vision encoder (image as the state) is not wired in.
903
+ * The 8-bit graphs are wasm-only (MatMulNBits has no WebGPU kernel), so
904
+ * device "auto" resolves to wasm.
905
+ */
906
+ async function createVegaSplitDecider({
907
+ embedFile = VEGA_Q8_EMBED_FILE,
908
+ decoderFile = VEGA_Q8_DECODER_FILE,
909
+ engineFile = VEGA_Q8_ENGINE_FILE,
910
+ calibrationModel = null,
911
+ calibrationRevision = null,
912
+ ort = null,
913
+ transformers = null,
914
+ fetchImpl = fetch,
915
+ wasmPaths = null,
916
+ device = "auto",
917
+ sessionFactory = null,
918
+ onProgress = null,
919
+ maxLen = null,
920
+ alpha = VEGA_DEFAULTS.alpha,
921
+ risk = null,
922
+ } = {}, { model, revision, subfolder }) {
923
+ const ortMod = ort ?? (await import("onnxruntime-web"));
924
+ const tf = transformers ?? (await import("@huggingface/transformers"));
925
+ if (wasmPaths) {
926
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
927
+ }
928
+ const resolvedDevice = device !== "auto" ? device : "wasm";
929
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
930
+
931
+ const basePath = subfolder ? `${subfolder}/` : "";
932
+ const configRepo = calibrationModel ?? model;
933
+ const configRevision = calibrationRevision ?? (calibrationModel ? "main" : revision);
934
+ const configUrl = hfUrl(configRepo, configRevision, `${basePath}vega_web.json`);
935
+ let config = null;
936
+ let configSource = null;
937
+ try {
938
+ const res = await fetchImpl(configUrl);
939
+ if (res?.ok) {
940
+ config = await res.json();
941
+ configSource = configUrl;
942
+ }
943
+ } catch { /* fall through to the shipped defaults */ }
944
+ const cfg = { ...VEGA_DEFAULTS, ...(config ?? {}) };
945
+ const calibration = config?.calibration ?? {};
946
+ const adapters = Array.isArray(config?.adapters) ? config.adapters : [];
947
+ const effRisk = risk ?? calibration.default_risk ?? cfg.default_risk ?? VEGA_DEFAULTS.risk;
948
+ const cfgMaxLen = config?.max_len ?? cfg.maxLen;
949
+ const effMaxLen = Number.isFinite(maxLen) && maxLen > 0 ? maxLen : cfgMaxLen;
950
+
951
+ // The tokenizer (Qwen3.5) ships inside the version folder, and
952
+ // transformers.js's AutoTokenizer has no subfolder option here, so build
953
+ // PreTrainedTokenizer from the two files directly -- what the export's own
954
+ // runtime does. Older transformers falls back to AutoTokenizer.
955
+ const fetchJson = async (file) => {
956
+ const url = hfUrl(configRepo, configRevision, `${basePath}${file}`);
957
+ const res = await fetchImpl(url);
958
+ if (!res?.ok) throw new Error(`vega: ${url} not found (HTTP ${res?.status ?? "no response"})`);
959
+ return res.json();
960
+ };
961
+ let tok;
962
+ if (typeof tf.PreTrainedTokenizer === "function") {
963
+ const [tokenizerJson, tokenizerConfig] = await Promise.all([
964
+ fetchJson("tokenizer.json"), fetchJson("tokenizer_config.json"),
965
+ ]);
966
+ tok = new tf.PreTrainedTokenizer(tokenizerJson, tokenizerConfig);
967
+ } else {
968
+ tok = await tf.AutoTokenizer.from_pretrained(configRepo, { revision: configRevision });
969
+ }
970
+ const encode = (text, addSpecial = false) => {
971
+ if (typeof tok.encode === "function") {
972
+ const ids = tok.encode(text, { add_special_tokens: addSpecial });
973
+ return Array.isArray(ids) ? ids : Array.from(ids?.input_ids?.data ?? ids, Number);
974
+ }
975
+ return Array.from(tok(text, { add_special_tokens: addSpecial }).input_ids.data, Number);
976
+ };
977
+ const decode = (ids) => {
978
+ try {
979
+ return tok.decode(Array.from(ids), { skip_special_tokens: false });
980
+ } catch {
981
+ return Array.from(ids).join(" ");
982
+ }
983
+ };
984
+
985
+ const open = openVegaSessions({
986
+ ortMod, fetchImpl, onProgress, sessionFactory, executionProviders,
987
+ device: resolvedDevice,
988
+ });
989
+ const embed = await open(hfUrl(model, revision, `${basePath}${embedFile}`));
990
+ const decoder = await open(hfUrl(model, revision, `${basePath}${decoderFile}`));
991
+ const engine = await open(hfUrl(model, revision, `${basePath}${engineFile}`));
992
+ const past = emptyVegaPast(decoder, ortMod.Tensor);
993
+
994
+ const int64 = (arr, dims) => new ortMod.Tensor("int64", arr, dims);
995
+ const f32 = (arr, dims) => new ortMod.Tensor("float32", arr, dims);
996
+ const bool = (arr, dims) => new ortMod.Tensor("bool", arr, dims);
997
+
998
+ return {
999
+ info: {
1000
+ family: "vega",
1001
+ format: "split",
1002
+ model,
1003
+ revision,
1004
+ subfolder: subfolder ?? null,
1005
+ device: resolvedDevice,
1006
+ maxLen: effMaxLen,
1007
+ alpha: String(alpha),
1008
+ risk: String(effRisk),
1009
+ calibrationSource: configSource,
1010
+ nSteps: cfg.n_steps ?? cfg.nSteps,
1011
+ adapters: adapters.map((a) => a.name),
1012
+ config,
1013
+ },
1014
+ async decide(state, questions) {
1015
+ const t0 = now();
1016
+ const normalized = normalizeQuestions(questions);
1017
+ const qdefs = normalized.map(vegaQuestionDef);
1018
+ const stateText = renderVegaText(state);
1019
+ const C = Number(cfg.memory_chunks ?? VEGA_DEFAULTS.memoryChunks);
1020
+ const chunkMinTokens = cfg.memory_min_tokens ?? VEGA_DEFAULTS.memoryMinTokens;
1021
+ const chunkTokens = cfg.memory_chunk_tokens ?? VEGA_DEFAULTS.memoryChunkTokens;
1022
+
1023
+ const items = qdefs.map((qdef) => {
1024
+ const { ids, spans } = buildVegaPrompt({
1025
+ encode, stateText, qdef, maxLen: effMaxLen,
1026
+ bosId: null, // the reference adds no BOS for Qwen, and the export never does
1027
+ queryFirstMaxState: cfg.query_first_max_state ?? cfg.queryFirstMaxState,
1028
+ optMaxTokens: cfg.opt_max_tokens ?? cfg.optMaxTokens,
1029
+ insMaxTokens: 256,
1030
+ truncateState: false, // the export refuses a state that does not fit
1031
+ });
1032
+ return { ids, spans, qtype: VEGA_QTYPE[qdef.t], options: vegaOptionTexts(qdef) };
1033
+ });
1034
+
1035
+ // One decoder pass per question (the export's own reading), pooling in JS.
1036
+ const tRun = now();
1037
+ const rows = [];
1038
+ const prompts = [];
1039
+ for (const it of items) {
1040
+ const L = it.ids.length;
1041
+ const embedOut = await embed.run({
1042
+ input_ids: int64(BigInt64Array.from(it.ids, BigInt), [1, L]),
1043
+ });
1044
+ const embeds = embedOut.inputs_embeds.data;
1045
+ const d = embeds.length / L;
1046
+ const decOut = await decoder.run({
1047
+ ...past,
1048
+ inputs_embeds: f32(embeds, [1, L, d]),
1049
+ attention_mask: int64(new BigInt64Array(L).fill(1n), [1, L]),
1050
+ position_ids: int64(vegaTextPositions(L), [3, 1, L]),
1051
+ });
1052
+ const pooled = poolVegaHidden({
1053
+ h13: decOut.h13.data, h19: decOut.h19.data, spans: it.spans, d,
1054
+ chunks: C, chunkMinTokens, chunkTokens,
1055
+ });
1056
+ const K = it.options.length;
1057
+ const eng = await engine.run({
1058
+ feats: f32(pooled.feats, [1, 2, 3, d]),
1059
+ opts: f32(pooled.opts, [1, K, 1, d]),
1060
+ opt_mask: bool(new Uint8Array(K).fill(1), [1, K]),
1061
+ qtype: int64([BigInt(it.qtype)], [1]),
1062
+ chunks: f32(pooled.chunks, [1, C, d]),
1063
+ chunk_mask: bool(pooled.cmask, [1, C]),
1064
+ });
1065
+ rows.push({
1066
+ qtype: it.qtype,
1067
+ K,
1068
+ logits: Array.from(eng.logits.data).slice(0, K),
1069
+ kinetic: eng.kinetic.data[0],
1070
+ nearest: eng.nearest.data[0],
1071
+ stepsUsed: Number(eng.steps_used.data[0]),
1072
+ gates: Array.from(eng.gates.data),
1073
+ });
1074
+ prompts.push(decode(it.ids));
1075
+ }
1076
+
1077
+ const nOpts = rows.map((r) => r.K);
1078
+ const rowWidth = Math.max(...nOpts);
1079
+ const logits = new Float32Array(rows.length * rowWidth);
1080
+ rows.forEach((r, b) => logits.set(r.logits, b * rowWidth));
1081
+ const phys = vegaThermalFeatures({
1082
+ kinetic: rows.map((r) => r.kinetic),
1083
+ nearest: rows.map((r) => r.nearest),
1084
+ stepsUsed: rows.map((r) => r.stepsUsed),
1085
+ nOpts, maxSteps: cfg.n_steps ?? cfg.nSteps,
1086
+ });
1087
+ // Each question is calibrated by the adapter its gate routes it to
1088
+ // (route_adapters: the most open gate, if it reaches the threshold).
1089
+ const gateThreshold = adapters[0]?.gate_threshold ?? 0.5;
1090
+ const gates = rows.map((r) => r.gates);
1091
+ const routes = gates.map((g) => vegaRoute(g, gateThreshold));
1092
+ const calibrations = routes.map(
1093
+ (rt) => (rt >= 0 ? adapters[rt]?.calibration : null) ?? calibration);
1094
+ const thetaByRow = calibrations.map(
1095
+ (cal, b) => cal?.theta?.[rows[b].qtype] ?? VEGA_DEFAULT_THETA[rows[b].qtype]);
1096
+ const probs = vegaThermalProbabilities({
1097
+ logits, rowWidth, qtypes: rows.map((r) => r.qtype),
1098
+ phys: phys.flat(), nOpts, thetaByRow,
1099
+ });
1100
+ const runMs = now() - tRun;
1101
+ const answers = vegaAnswers({
1102
+ probs, qtypes: rows.map((r) => r.qtype), labels: vegaOptionLabels(normalized),
1103
+ physics: {
1104
+ nearest: rows.map((r) => r.nearest),
1105
+ stepsUsed: rows.map((r) => r.stepsUsed),
1106
+ },
1107
+ rawLogits: logits, rowWidth,
1108
+ calibration, calibrations, alpha, risk: effRisk,
1109
+ adapterNames: adapters.map((a) => a.name), gates, routes,
1110
+ });
1111
+ return {
1112
+ answers,
1113
+ truncated: false,
1114
+ length: items.reduce((n, it) => n + it.ids.length, 0),
1115
+ prompts,
1116
+ timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
1117
+ };
1118
+ },
1119
+ async dispose() {
1120
+ try { await embed.release?.(); } catch { /* optional */ }
1121
+ try { await decoder.release?.(); } catch { /* optional */ }
1122
+ try { await engine.release?.(); } catch { /* optional */ }
1123
+ },
1124
+ };
1125
+ }