jev-web 0.4.1 → 0.5.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/README.md CHANGED
@@ -12,8 +12,8 @@ cached.
12
12
 
13
13
  Three model families ship today — open-jev (fused DeBERTa graph), Laya
14
14
  (ModernBERT encoder + typed head), Strands Decider 2B (Qwen torso + pointer
15
- head), and Bekko System One v0 (Ettin cross-encoder) — behind one registry:
16
- `createDecisionRuntime({ family, fallback })`.
15
+ head), Bekko System One v0 (Ettin cross-encoder), and Decision 2.0 (Qwen3 +
16
+ candidate head) — behind one registry: `createDecisionRuntime({ family, fallback })`.
17
17
 
18
18
  This is a generic runtime, not a classifier for any particular domain. You bring
19
19
  the state text and the questions; the model answers by choosing among the options
@@ -225,7 +225,7 @@ tries the requested family and then any fallbacks, returning the shared contract
225
225
  ```js
226
226
  import { createDecisionRuntime, listDecisionFamilies } from "jev-web";
227
227
 
228
- listDecisionFamilies(); // ["open-jev", "laya", "strands-decider", "bekko"]
228
+ listDecisionFamilies(); // ["open-jev", "laya", "strands-decider", "bekko", "decision2"]
229
229
  const runtime = await createDecisionRuntime({
230
230
  family: "strands-decider",
231
231
  fallback: ["laya", "open-jev"],
@@ -366,6 +366,45 @@ Lower-level helpers are exported for custom runtimes:
366
366
  `renderBekkoContent`, `renderBekkoRequest`, `tokenizeBekkoRequest`,
367
367
  `collateBekkoDocs`, `bekkoSoftmax`, `bekkoAnswerFromLogits`.
368
368
 
369
+ ## Decision 2.0 family (Qwen3 + candidate head)
370
+
371
+ `createDecision2Decider(options)` runs
372
+ [vLLM Semantic Router Decision 2.0](https://huggingface.co/collections/vllm-sr/decision-20),
373
+ a Qwen3 fine-tune with a candidate head: one prompt per question, each option
374
+ rendered as a `{"description": …, "key": …}` JSON segment. One ONNX graph
375
+ (`input_ids, attention_mask, answer_pos, option_pos → logits [B, K]`), one
376
+ forward pass for the whole batch:
377
+
378
+ ```js
379
+ import { createDecision2Decider } from "jev-web";
380
+
381
+ const decider = await createDecision2Decider({
382
+ // defaults to onnx-community/Decision-2.0-Kai-0.6B-ONNX @ pinned revision,
383
+ // q8 (onnx/model_quantized.onnx, 0.61 GB)
384
+ onProgress: (p) => console.log(p.phase, p.loaded, p.total),
385
+ });
386
+ const { answers } = await decider.decide(stateText, questions);
387
+ ```
388
+
389
+ Protocol implemented here (mirrors the reference `decision2` runtime):
390
+
391
+ - segments `Context:` / `Task type:` / `Question:` / `Options:` / one
392
+ `<option>` JSON segment per option / the `Decision:` suffix, each tokenized
393
+ separately so `option_pos` (last token of each `</option>`) and `answer_pos`
394
+ (last token) are exact — validated 11/11 against the reference fixtures;
395
+ - choice questions accept `criteria` as `{label: description}` (empty
396
+ descriptions encode as `null`); noul slots are fixed `false`/`true`;
397
+ - score questions add the per-level `score_bias` from `config.json` before the
398
+ softmax (temperature 1).
399
+
400
+ Larger siblings are available via the exported model/revision constants:
401
+ `DECISION2_EOS_MODEL` / `DECISION2_EOS_REVISION` (0.8B) and
402
+ `DECISION2_SOL_MODEL` / `DECISION2_SOL_REVISION` (2B).
403
+
404
+ Lower-level helpers are exported for custom runtimes:
405
+ `renderDecision2Content`, `renderDecision2Options`, `buildDecision2Segments`,
406
+ `tokenizeDecision2Segments`, `decision2Softmax`, `decision2AnswerFromLogits`.
407
+
369
408
  ## Worker / thread
370
409
 
371
410
  `jev-web` does not bundle a worker, so hosts can create one the way their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-web",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Run open-jev-, Laya- and Strands-Decider-shaped typed-decision models in the browser: one state plus typed questions in, calibrated probabilities out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,351 @@
1
+ /**
2
+ * decision2.mjs — vLLM Semantic Router Decision 2.0 family.
3
+ *
4
+ * Decision 2.0 is a Qwen3 fine-tune with a candidate head: one prompt per
5
+ * question, built from segments (each tokenized separately so the readout
6
+ * positions are exact):
7
+ *
8
+ * Context:\n{state}\n\n
9
+ * Task type: {noul|choice|score}\n
10
+ * Question:\n{instructions}\n
11
+ * Options:\n
12
+ * <option>\n{"description":…,"key":"…"}\n</option>\n (per option)
13
+ * \nSelect the single option best supported by the context and instructions.\n
14
+ * Decision:
15
+ *
16
+ * Graph: input_ids [B,L], attention_mask [B,L], answer_pos [B], option_pos
17
+ * [B,K] -> logits [B,K]. option_pos is the last token of each </option>;
18
+ * answer_pos is the last token of the prompt. Score questions add the
19
+ * per-level score_bias from config.json, then softmax (temperature 1).
20
+ *
21
+ * Reference: onnx-community/Decision-2.0-Kai-0.6B-ONNX (conversion/README).
22
+ */
23
+
24
+ import { normalizeQuestions } from "./questions.mjs";
25
+ import { fetchCachedBytes } from "./laya.mjs";
26
+
27
+ export const DECISION2_DEFAULT_MODEL = "onnx-community/Decision-2.0-Kai-0.6B-ONNX";
28
+ export const DECISION2_DEFAULT_REVISION = "13ab55013ab1";
29
+ export const DECISION2_DEFAULT_FILE = "onnx/model_quantized.onnx";
30
+ export const DECISION2_MAX_INPUT_TOKENS = 8192;
31
+
32
+ // Larger siblings (same protocol, same onnx/ layout).
33
+ export const DECISION2_EOS_MODEL = "onnx-community/Decision-2.0-Eos-0.8B-ONNX";
34
+ export const DECISION2_EOS_REVISION = "6369be38417e";
35
+ export const DECISION2_SOL_MODEL = "onnx-community/Decision-2.0-Sol-2B-ONNX";
36
+ export const DECISION2_SOL_REVISION = "5b3478e05ce3";
37
+
38
+ export const DECISION2_NOUL_DESCRIPTIONS = { false: "No", true: "Yes" };
39
+ const DECISION_SUFFIX = "Select the single option best supported by the context and instructions.";
40
+
41
+ /** Flatten a state or instruction into text, stably (object keys sorted). */
42
+ export function renderDecision2Content(content) {
43
+ if (typeof content === "string") return content;
44
+ return JSON.stringify(sortKeys(content));
45
+ }
46
+
47
+ /** Recursively sort object keys for deterministic serialization. */
48
+ function sortKeys(value) {
49
+ if (Array.isArray(value)) return value.map(sortKeys);
50
+ if (value !== null && typeof value === "object") {
51
+ return Object.fromEntries(
52
+ Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /**
58
+ * Build the option list for one question: [{key, description}]. Descriptions
59
+ * are null when empty (matching the reference encoding).
60
+ */
61
+ export function renderDecision2Options(question) {
62
+ if (question.type === "choice") {
63
+ const criteria = question.criteria ?? {};
64
+ return question.options.map((label) => {
65
+ const d = criteria[label];
66
+ return { key: label, description: d == null || String(d).trim() === "" ? null : String(d) };
67
+ });
68
+ }
69
+ if (question.type === "noul") {
70
+ const criteria = question.criteria ?? {};
71
+ const falseDesc = criteria.false ?? criteria.no ?? DECISION2_NOUL_DESCRIPTIONS.false;
72
+ const trueDesc = criteria.true ?? criteria.yes ?? DECISION2_NOUL_DESCRIPTIONS.true;
73
+ return [
74
+ { key: "false", description: String(falseDesc) },
75
+ { key: "true", description: String(trueDesc) },
76
+ ];
77
+ }
78
+ if (question.type === "score") {
79
+ return question.options.map((rubric, i) => ({ key: String(i), description: String(rubric) }));
80
+ }
81
+ throw new TypeError(`decision2: unsupported question type ${question.type}`);
82
+ }
83
+
84
+ /**
85
+ * Build the prompt segments for one question. Each segment is tokenized
86
+ * separately; the returned `optionEnds` marks which segment index ends each
87
+ * option (for option_pos), and the prompt ends with the Decision: segment.
88
+ */
89
+ export function buildDecision2Segments(question, stateText) {
90
+ const options = renderDecision2Options(question);
91
+ const segments = [
92
+ `Context:\n${stateText}\n\n`,
93
+ `Task type: ${question.type}\n`,
94
+ `Question:\n${renderDecision2Content(question.instructions)}\n`,
95
+ `Options:`,
96
+ ];
97
+ const optionEnds = [];
98
+ for (const opt of options) {
99
+ // Sorted keys, compact JSON, matching the reference.
100
+ const payload = JSON.stringify({ description: opt.description, key: opt.key });
101
+ segments.push(`\n<option>\n${payload}\n</option>`);
102
+ optionEnds.push(segments.length - 1);
103
+ }
104
+ segments.push(`\n\n${DECISION_SUFFIX}\n`);
105
+ segments.push(`Decision:`);
106
+ return { segments, optionEnds, keys: options.map((o) => o.key) };
107
+ }
108
+
109
+ /**
110
+ * Tokenize segments separately and concatenate, recording the absolute
111
+ * position of each option's last token (option_pos) and the prompt's last
112
+ * token (answer_pos).
113
+ */
114
+ export function tokenizeDecision2Segments(segments, optionEnds, encode, maxLength) {
115
+ const ids = [];
116
+ const optionPos = [];
117
+ for (let s = 0; s < segments.length; s++) {
118
+ const segIds = encode(segments[s]);
119
+ const start = ids.length;
120
+ ids.push(...segIds);
121
+ if (optionEnds.includes(s)) {
122
+ optionPos.push(start + segIds.length - 1);
123
+ }
124
+ if (ids.length > maxLength) {
125
+ throw new RangeError(
126
+ `decision2: prompt too long (${ids.length} > ${maxLength} tokens)`);
127
+ }
128
+ }
129
+ return { ids, optionPos, answerPos: ids.length - 1 };
130
+ }
131
+
132
+ /** Softmax with temperature (temperature 1 for Decision 2.0). */
133
+ export function decision2Softmax(logits) {
134
+ const max = Math.max(...logits);
135
+ const exps = logits.map((x) => Math.exp(Number(x) - max));
136
+ const sum = exps.reduce((a, b) => a + b, 0);
137
+ return exps.map((e) => e / sum);
138
+ }
139
+
140
+ /**
141
+ * Decode one question's logits into a jev-web answer. Score questions add
142
+ * the per-level bias before the softmax.
143
+ */
144
+ export function decision2AnswerFromLogits({ logits, question, keys, scoreBias }) {
145
+ const adjusted = logits.map(Number);
146
+ if (question.type === "score" && scoreBias) {
147
+ const bias = scoreBias[String(keys.length)];
148
+ if (bias) {
149
+ if (bias.length !== adjusted.length) {
150
+ throw new TypeError(
151
+ `decision2: score_bias length ${bias.length} != ${adjusted.length} options`);
152
+ }
153
+ for (let i = 0; i < adjusted.length; i++) adjusted[i] += bias[i];
154
+ }
155
+ }
156
+ const probs = decision2Softmax(adjusted);
157
+ const best = probs.indexOf(Math.max(...probs));
158
+
159
+ if (question.type === "choice") {
160
+ const probabilities = Object.fromEntries(keys.map((k, i) => [k, probs[i]]));
161
+ return {
162
+ type: "choice", choice: keys[best], index: best,
163
+ probabilities, confidence: Math.max(...probs),
164
+ };
165
+ }
166
+ if (question.type === "noul") {
167
+ const yesIdx = keys.indexOf("true");
168
+ const yes = probs[yesIdx];
169
+ const [noLabel, yesLabel] = question.options;
170
+ return {
171
+ type: "noul", noul: yes,
172
+ probabilities: { [noLabel]: 1 - yes, [yesLabel]: yes },
173
+ confidence: Math.max(yes, 1 - yes),
174
+ };
175
+ }
176
+ // score
177
+ const values = keys.map((k) => Number(k));
178
+ const score = probs.reduce((acc, p, i) => acc + p * values[i], 0);
179
+ const probabilities = Object.fromEntries(keys.map((k, i) => [k, probs[i]]));
180
+ return {
181
+ type: "score", score, level: best,
182
+ legend: Object.fromEntries(question.options.map((r, i) => [String(i), r])),
183
+ probabilities, confidence: Math.max(...probs),
184
+ };
185
+ }
186
+
187
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
188
+ const hfUrl = (model, revision, ...parts) =>
189
+ `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${parts.join("/")}`;
190
+
191
+ /**
192
+ * Create a Decision 2.0 decider.
193
+ *
194
+ * @param {object} [opts]
195
+ * model/revision/file — pinned ONNX export (or your own mirror)
196
+ * configUrl — override for config.json (carries decision2.score_bias)
197
+ * ort, transformers, fetchImpl, wasmPaths, device, sessionFactory — as in
198
+ * the other families
199
+ * maxLength — token budget (default 8192)
200
+ */
201
+ export async function createDecision2Decider({
202
+ model = DECISION2_DEFAULT_MODEL,
203
+ revision = DECISION2_DEFAULT_REVISION,
204
+ file = DECISION2_DEFAULT_FILE,
205
+ configUrl = null,
206
+ ort = null,
207
+ transformers = null,
208
+ fetchImpl = fetch,
209
+ wasmPaths = null,
210
+ device = "auto",
211
+ sessionFactory = null,
212
+ onProgress = null,
213
+ maxLength = DECISION2_MAX_INPUT_TOKENS,
214
+ scope = globalThis,
215
+ } = {}) {
216
+ const ortMod = ort ?? (await import("onnxruntime-web"));
217
+ const tf = transformers ?? (await import("@huggingface/transformers"));
218
+ if (wasmPaths) {
219
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
220
+ }
221
+
222
+ const resolvedDevice = device !== "auto"
223
+ ? device
224
+ : (scope?.navigator?.gpu ? "webgpu" : "wasm");
225
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
226
+
227
+ const configRes = await fetchImpl(configUrl ?? hfUrl(model, revision, "config.json"));
228
+ if (!configRes.ok) throw new Error(`decision2: config fetch failed (${configRes.status})`);
229
+ const config = await configRes.json();
230
+ const scoreBias = config?.decision2?.score_bias ?? {};
231
+
232
+ const tok = await tf.AutoTokenizer.from_pretrained(model, {
233
+ revision,
234
+ progress_callback: (p) => onProgress?.({ phase: "tokenizer", ...p }),
235
+ });
236
+ const encode = (text) =>
237
+ Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
238
+
239
+ const url = hfUrl(model, revision, file);
240
+ const createSession = sessionFactory
241
+ ?? (async (u, opts) => {
242
+ const modelBytes = await fetchCachedBytes(u, {
243
+ fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
244
+ });
245
+ return ortMod.InferenceSession.create(modelBytes, opts);
246
+ });
247
+
248
+ let session;
249
+ let activeDevice = resolvedDevice;
250
+ try {
251
+ session = await createSession(url, { executionProviders, graphOptimizationLevel: "all" });
252
+ } catch (e) {
253
+ if (resolvedDevice === "webgpu") {
254
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
255
+ session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
256
+ activeDevice = "wasm";
257
+ } else {
258
+ throw e;
259
+ }
260
+ }
261
+
262
+ // Probe external data (the q8 file ships a .onnx_data sidecar).
263
+ try {
264
+ const probe = await fetchImpl(url + "_data", { method: "HEAD" });
265
+ if (!probe.ok) {
266
+ const probe2 = await fetchImpl(url.replace(/\.onnx$/, ".onnx_data"), { method: "HEAD" });
267
+ void probe2;
268
+ }
269
+ } catch { /* optional */ }
270
+
271
+ return {
272
+ info: {
273
+ family: "decision2",
274
+ model, revision, device: activeDevice,
275
+ maxLength,
276
+ },
277
+ async decide(state, questions) {
278
+ const t0 = now();
279
+ const normalized = normalizeQuestions(questions);
280
+ const stateText = renderDecision2Content(state);
281
+ const B = normalized.length;
282
+ const K = Math.max(...normalized.map((q) => renderDecision2Options(q).length));
283
+
284
+ const allIds = [];
285
+ const answerPos = [];
286
+ const optionPos = [];
287
+ const metas = [];
288
+ let truncated = false;
289
+ let length = 0;
290
+
291
+ for (const q of normalized) {
292
+ const { segments, optionEnds, keys } = buildDecision2Segments(q, stateText);
293
+ const { ids, optionPos: oPos, answerPos: aPos } =
294
+ tokenizeDecision2Segments(segments, optionEnds, encode, maxLength);
295
+ allIds.push(ids);
296
+ answerPos.push(aPos);
297
+ // Pad option positions with 0 (matches the strands convention).
298
+ const padded = [...oPos];
299
+ while (padded.length < K) padded.push(0);
300
+ optionPos.push(padded);
301
+ metas.push({ question: q, keys });
302
+ length += ids.length;
303
+ }
304
+
305
+ // Pad to a rectangular batch.
306
+ const L = Math.max(...allIds.map((r) => r.length));
307
+ const inputIds = new BigInt64Array(B * L);
308
+ const attentionMask = new BigInt64Array(B * L);
309
+ for (let b = 0; b < B; b++) {
310
+ allIds[b].forEach((id, i) => {
311
+ inputIds[b * L + i] = BigInt(id);
312
+ attentionMask[b * L + i] = 1n;
313
+ });
314
+ }
315
+ const feeds = {
316
+ input_ids: new ortMod.Tensor("int64", inputIds, [B, L]),
317
+ attention_mask: new ortMod.Tensor("int64", attentionMask, [B, L]),
318
+ answer_pos: new ortMod.Tensor("int64", BigInt64Array.from(answerPos.map(BigInt)), [B]),
319
+ option_pos: new ortMod.Tensor(
320
+ "int64",
321
+ BigInt64Array.from(optionPos.flat().map(BigInt)),
322
+ [B, K]),
323
+ };
324
+
325
+ const tRun = now();
326
+ const out = await session.run(feeds);
327
+ const runMs = now() - tRun;
328
+ const logits = out.logits ?? out[Object.keys(out)[0]];
329
+ if (logits.dims.length !== 2 || logits.dims[0] !== B || logits.dims[1] !== K) {
330
+ throw new Error(
331
+ `decision2: invalid logits shape [${logits.dims}] for batch ${B}x${K}`);
332
+ }
333
+
334
+ const answers = metas.map(({ question, keys }, b) => {
335
+ const row = Array.from(
336
+ { length: keys.length },
337
+ (_, k) => Number(logits.data[b * K + k]));
338
+ return decision2AnswerFromLogits({ logits: row, question, keys, scoreBias });
339
+ });
340
+
341
+ return {
342
+ answers, truncated, length,
343
+ prompts: metas.map(({ question, keys }) => ({ question: question.instructions, keys })),
344
+ timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
345
+ };
346
+ },
347
+ async dispose() {
348
+ try { await session.release?.(); } catch { /* optional */ }
349
+ },
350
+ };
351
+ }
package/src/index.mjs CHANGED
@@ -69,6 +69,24 @@ export {
69
69
  BEKKO_DEFAULT_NO,
70
70
  BEKKO_NOUL_DESCRIPTIONS,
71
71
  } from "./bekko.mjs";
72
+ export {
73
+ createDecision2Decider,
74
+ renderDecision2Content,
75
+ renderDecision2Options,
76
+ buildDecision2Segments,
77
+ tokenizeDecision2Segments,
78
+ decision2Softmax,
79
+ decision2AnswerFromLogits,
80
+ DECISION2_DEFAULT_MODEL,
81
+ DECISION2_DEFAULT_REVISION,
82
+ DECISION2_DEFAULT_FILE,
83
+ DECISION2_EOS_MODEL,
84
+ DECISION2_EOS_REVISION,
85
+ DECISION2_SOL_MODEL,
86
+ DECISION2_SOL_REVISION,
87
+ DECISION2_MAX_INPUT_TOKENS,
88
+ DECISION2_NOUL_DESCRIPTIONS,
89
+ } from "./decision2.mjs";
72
90
  export {
73
91
  DEFAULT_MODEL,
74
92
  DEFAULT_REVISION,
package/src/registry.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * registry.mjs — multi-family runtime for JEV-style typed-decision models.
3
3
  *
4
- * jev-web ships three families today (open-jev's fused DeBERTa graph, Laya's
5
- * ModernBERT encoder + typed head, Strands Decider 2B's Qwen torso + pointer
6
- * head, and Bekko System One v0's cross-encoder) and any third party can add
7
- * its own without touching core:
4
+ * jev-web ships three families today — open-jev (fused DeBERTa graph), Laya
5
+ * (ModernBERT encoder + typed head), Strands Decider 2B (Qwen torso + pointer
6
+ * head), Bekko System One v0 (cross-encoder), and Decision 2.0 (Qwen3 +
7
+ * candidate head) — and any third party can add its own without touching
8
+ * core:
8
9
  *
9
10
  * registerDecisionFamily("my-model", {
10
11
  * defaults: { model: "org/my-typed-decision-ONNX", revision: "main" },
@@ -38,6 +39,11 @@ import {
38
39
  BEKKO_DEFAULT_MODEL,
39
40
  BEKKO_DEFAULT_REVISION,
40
41
  } from "./bekko.mjs";
42
+ import {
43
+ createDecision2Decider,
44
+ DECISION2_DEFAULT_MODEL,
45
+ DECISION2_DEFAULT_REVISION,
46
+ } from "./decision2.mjs";
41
47
 
42
48
  const families = new Map();
43
49
 
@@ -94,6 +100,14 @@ registerDecisionFamily("bekko", {
94
100
  create: createBekkoDecider,
95
101
  });
96
102
 
103
+ registerDecisionFamily("decision2", {
104
+ defaults: {
105
+ model: DECISION2_DEFAULT_MODEL,
106
+ revision: DECISION2_DEFAULT_REVISION,
107
+ },
108
+ create: createDecision2Decider,
109
+ });
110
+
97
111
  /**
98
112
  * Create a decision runtime, preferring `family` and falling back in order.
99
113
  * @param {{family?:string, fallback?:string[], onFallback?:Function}} [opts]