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/decision2.mjs
CHANGED
|
@@ -1,382 +1,382 @@
|
|
|
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
|
-
/** Named variants with their pinned revisions and context sizes. */
|
|
39
|
-
export const DECISION2_VARIANTS = {
|
|
40
|
-
kai: {
|
|
41
|
-
model: DECISION2_DEFAULT_MODEL,
|
|
42
|
-
revision: DECISION2_DEFAULT_REVISION,
|
|
43
|
-
maxLength: 8192,
|
|
44
|
-
description: "0.6B, 0.61 GB q8",
|
|
45
|
-
},
|
|
46
|
-
eos: {
|
|
47
|
-
model: DECISION2_EOS_MODEL,
|
|
48
|
-
revision: DECISION2_EOS_REVISION,
|
|
49
|
-
maxLength: 16384,
|
|
50
|
-
description: "0.8B, q8/q4f16",
|
|
51
|
-
},
|
|
52
|
-
sol: {
|
|
53
|
-
model: DECISION2_SOL_MODEL,
|
|
54
|
-
revision: DECISION2_SOL_REVISION,
|
|
55
|
-
maxLength: 16384,
|
|
56
|
-
description: "2B, q8/q4f16",
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export const DECISION2_NOUL_DESCRIPTIONS = { false: "No", true: "Yes" };
|
|
61
|
-
const DECISION_SUFFIX = "Select the single option best supported by the context and instructions.";
|
|
62
|
-
|
|
63
|
-
/** Flatten a state or instruction into text, stably (object keys sorted). */
|
|
64
|
-
export function renderDecision2Content(content) {
|
|
65
|
-
if (typeof content === "string") return content;
|
|
66
|
-
return JSON.stringify(sortKeys(content));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Recursively sort object keys for deterministic serialization. */
|
|
70
|
-
function sortKeys(value) {
|
|
71
|
-
if (Array.isArray(value)) return value.map(sortKeys);
|
|
72
|
-
if (value !== null && typeof value === "object") {
|
|
73
|
-
return Object.fromEntries(
|
|
74
|
-
Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
|
|
75
|
-
}
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Build the option list for one question: [{key, description}]. Descriptions
|
|
81
|
-
* are null when empty (matching the reference encoding).
|
|
82
|
-
*/
|
|
83
|
-
export function renderDecision2Options(question) {
|
|
84
|
-
if (question.type === "choice") {
|
|
85
|
-
const criteria = question.criteria ?? {};
|
|
86
|
-
return question.options.map((label) => {
|
|
87
|
-
const d = criteria[label];
|
|
88
|
-
return { key: label, description: d == null || String(d).trim() === "" ? null : String(d) };
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
if (question.type === "noul") {
|
|
92
|
-
const criteria = question.criteria ?? {};
|
|
93
|
-
const falseDesc = criteria.false ?? criteria.no ?? DECISION2_NOUL_DESCRIPTIONS.false;
|
|
94
|
-
const trueDesc = criteria.true ?? criteria.yes ?? DECISION2_NOUL_DESCRIPTIONS.true;
|
|
95
|
-
return [
|
|
96
|
-
{ key: "false", description: String(falseDesc) },
|
|
97
|
-
{ key: "true", description: String(trueDesc) },
|
|
98
|
-
];
|
|
99
|
-
}
|
|
100
|
-
if (question.type === "score") {
|
|
101
|
-
return question.options.map((rubric, i) => ({ key: String(i), description: String(rubric) }));
|
|
102
|
-
}
|
|
103
|
-
throw new TypeError(`decision2: unsupported question type ${question.type}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Build the prompt segments for one question. Each segment is tokenized
|
|
108
|
-
* separately; the returned `optionEnds` marks which segment index ends each
|
|
109
|
-
* option (for option_pos), and the prompt ends with the Decision: segment.
|
|
110
|
-
*/
|
|
111
|
-
export function buildDecision2Segments(question, stateText) {
|
|
112
|
-
const options = renderDecision2Options(question);
|
|
113
|
-
const segments = [
|
|
114
|
-
`Context:\n${stateText}\n\n`,
|
|
115
|
-
`Task type: ${question.type}\n`,
|
|
116
|
-
`Question:\n${renderDecision2Content(question.instructions)}\n`,
|
|
117
|
-
`Options:`,
|
|
118
|
-
];
|
|
119
|
-
const optionEnds = [];
|
|
120
|
-
for (const opt of options) {
|
|
121
|
-
// Sorted keys, compact JSON, matching the reference.
|
|
122
|
-
const payload = JSON.stringify({ description: opt.description, key: opt.key });
|
|
123
|
-
segments.push(`\n<option>\n${payload}\n</option>`);
|
|
124
|
-
optionEnds.push(segments.length - 1);
|
|
125
|
-
}
|
|
126
|
-
segments.push(`\n\n${DECISION_SUFFIX}\n`);
|
|
127
|
-
segments.push(`Decision:`);
|
|
128
|
-
return { segments, optionEnds, keys: options.map((o) => o.key) };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Tokenize segments separately and concatenate, recording the absolute
|
|
133
|
-
* position of each option's last token (option_pos) and the prompt's last
|
|
134
|
-
* token (answer_pos).
|
|
135
|
-
*/
|
|
136
|
-
export function tokenizeDecision2Segments(segments, optionEnds, encode, maxLength) {
|
|
137
|
-
const ids = [];
|
|
138
|
-
const optionPos = [];
|
|
139
|
-
for (let s = 0; s < segments.length; s++) {
|
|
140
|
-
const segIds = encode(segments[s]);
|
|
141
|
-
const start = ids.length;
|
|
142
|
-
ids.push(...segIds);
|
|
143
|
-
if (optionEnds.includes(s)) {
|
|
144
|
-
optionPos.push(start + segIds.length - 1);
|
|
145
|
-
}
|
|
146
|
-
if (ids.length > maxLength) {
|
|
147
|
-
throw new RangeError(
|
|
148
|
-
`decision2: prompt too long (${ids.length} > ${maxLength} tokens)`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return { ids, optionPos, answerPos: ids.length - 1 };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Softmax with temperature (temperature 1 for Decision 2.0). */
|
|
155
|
-
export function decision2Softmax(logits) {
|
|
156
|
-
const max = Math.max(...logits);
|
|
157
|
-
const exps = logits.map((x) => Math.exp(Number(x) - max));
|
|
158
|
-
const sum = exps.reduce((a, b) => a + b, 0);
|
|
159
|
-
return exps.map((e) => e / sum);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Decode one question's logits into a jev-web answer. Score questions add
|
|
164
|
-
* the per-level bias before the softmax.
|
|
165
|
-
*/
|
|
166
|
-
export function decision2AnswerFromLogits({ logits, question, keys, scoreBias }) {
|
|
167
|
-
const adjusted = logits.map(Number);
|
|
168
|
-
if (question.type === "score" && scoreBias) {
|
|
169
|
-
const bias = scoreBias[String(keys.length)];
|
|
170
|
-
if (bias) {
|
|
171
|
-
if (bias.length !== adjusted.length) {
|
|
172
|
-
throw new TypeError(
|
|
173
|
-
`decision2: score_bias length ${bias.length} != ${adjusted.length} options`);
|
|
174
|
-
}
|
|
175
|
-
for (let i = 0; i < adjusted.length; i++) adjusted[i] += bias[i];
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
const probs = decision2Softmax(adjusted);
|
|
179
|
-
const best = probs.indexOf(Math.max(...probs));
|
|
180
|
-
|
|
181
|
-
if (question.type === "choice") {
|
|
182
|
-
const probabilities = Object.fromEntries(keys.map((k, i) => [k, probs[i]]));
|
|
183
|
-
return {
|
|
184
|
-
type: "choice", choice: keys[best], index: best,
|
|
185
|
-
probabilities, confidence: Math.max(...probs),
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
if (question.type === "noul") {
|
|
189
|
-
const yesIdx = keys.indexOf("true");
|
|
190
|
-
const yes = probs[yesIdx];
|
|
191
|
-
const [noLabel, yesLabel] = question.options;
|
|
192
|
-
return {
|
|
193
|
-
type: "noul", noul: yes,
|
|
194
|
-
probabilities: { [noLabel]: 1 - yes, [yesLabel]: yes },
|
|
195
|
-
confidence: Math.max(yes, 1 - yes),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
// score
|
|
199
|
-
const values = keys.map((k) => Number(k));
|
|
200
|
-
const score = probs.reduce((acc, p, i) => acc + p * values[i], 0);
|
|
201
|
-
const probabilities = Object.fromEntries(keys.map((k, i) => [k, probs[i]]));
|
|
202
|
-
return {
|
|
203
|
-
type: "score", score, level: best,
|
|
204
|
-
legend: Object.fromEntries(question.options.map((r, i) => [String(i), r])),
|
|
205
|
-
probabilities, confidence: Math.max(...probs),
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
210
|
-
const hfUrl = (model, revision, ...parts) =>
|
|
211
|
-
`https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${parts.join("/")}`;
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Create a Decision 2.0 decider.
|
|
215
|
-
*
|
|
216
|
-
* @param {object} [opts]
|
|
217
|
-
* model/revision/file — pinned ONNX export (or your own mirror)
|
|
218
|
-
* configUrl — override for config.json (carries decision2.score_bias)
|
|
219
|
-
* ort, transformers, fetchImpl, wasmPaths, device, sessionFactory — as in
|
|
220
|
-
* the other families
|
|
221
|
-
* maxLength — token budget (default 8192)
|
|
222
|
-
*/
|
|
223
|
-
export async function createDecision2Decider({
|
|
224
|
-
model = DECISION2_DEFAULT_MODEL,
|
|
225
|
-
revision = DECISION2_DEFAULT_REVISION,
|
|
226
|
-
variant = null,
|
|
227
|
-
file = DECISION2_DEFAULT_FILE,
|
|
228
|
-
configUrl = null,
|
|
229
|
-
ort = null,
|
|
230
|
-
transformers = null,
|
|
231
|
-
fetchImpl = fetch,
|
|
232
|
-
wasmPaths = null,
|
|
233
|
-
device = "auto",
|
|
234
|
-
sessionFactory = null,
|
|
235
|
-
onProgress = null,
|
|
236
|
-
maxLength = DECISION2_MAX_INPUT_TOKENS,
|
|
237
|
-
scope = globalThis,
|
|
238
|
-
} = {}) {
|
|
239
|
-
// Named variant shortcut: {variant: "eos"} sets model/revision/maxLength.
|
|
240
|
-
if (variant) {
|
|
241
|
-
const v = DECISION2_VARIANTS[variant];
|
|
242
|
-
if (!v) throw new TypeError(`decision2: unknown variant "${variant}" (kai, eos, sol)`);
|
|
243
|
-
model = v.model;
|
|
244
|
-
revision = v.revision;
|
|
245
|
-
maxLength = v.maxLength;
|
|
246
|
-
}
|
|
247
|
-
const ortMod = ort ?? (await import("onnxruntime-web"));
|
|
248
|
-
const tf = transformers ?? (await import("@huggingface/transformers"));
|
|
249
|
-
if (wasmPaths) {
|
|
250
|
-
try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const resolvedDevice = device !== "auto"
|
|
254
|
-
? device
|
|
255
|
-
: (scope?.navigator?.gpu ? "webgpu" : "wasm");
|
|
256
|
-
const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
|
|
257
|
-
|
|
258
|
-
const configRes = await fetchImpl(configUrl ?? hfUrl(model, revision, "config.json"));
|
|
259
|
-
if (!configRes.ok) throw new Error(`decision2: config fetch failed (${configRes.status})`);
|
|
260
|
-
const config = await configRes.json();
|
|
261
|
-
const scoreBias = config?.decision2?.score_bias ?? {};
|
|
262
|
-
|
|
263
|
-
const tok = await tf.AutoTokenizer.from_pretrained(model, {
|
|
264
|
-
revision,
|
|
265
|
-
progress_callback: (p) => onProgress?.({ phase: "tokenizer", ...p }),
|
|
266
|
-
});
|
|
267
|
-
const encode = (text) =>
|
|
268
|
-
Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
|
|
269
|
-
|
|
270
|
-
const url = hfUrl(model, revision, file);
|
|
271
|
-
const createSession = sessionFactory
|
|
272
|
-
?? (async (u, opts) => {
|
|
273
|
-
const modelBytes = await fetchCachedBytes(u, {
|
|
274
|
-
fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
|
|
275
|
-
});
|
|
276
|
-
return ortMod.InferenceSession.create(modelBytes, opts);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
let session;
|
|
280
|
-
let activeDevice = resolvedDevice;
|
|
281
|
-
try {
|
|
282
|
-
session = await createSession(url, { executionProviders, graphOptimizationLevel: "all" });
|
|
283
|
-
} catch (e) {
|
|
284
|
-
if (resolvedDevice === "webgpu") {
|
|
285
|
-
onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
|
|
286
|
-
session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
|
|
287
|
-
activeDevice = "wasm";
|
|
288
|
-
} else {
|
|
289
|
-
throw e;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Probe external data (the q8 file ships a .onnx_data sidecar).
|
|
294
|
-
try {
|
|
295
|
-
const probe = await fetchImpl(url + "_data", { method: "HEAD" });
|
|
296
|
-
if (!probe.ok) {
|
|
297
|
-
const probe2 = await fetchImpl(url.replace(/\.onnx$/, ".onnx_data"), { method: "HEAD" });
|
|
298
|
-
void probe2;
|
|
299
|
-
}
|
|
300
|
-
} catch { /* optional */ }
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
info: {
|
|
304
|
-
family: "decision2",
|
|
305
|
-
model, revision, device: activeDevice,
|
|
306
|
-
maxLength,
|
|
307
|
-
},
|
|
308
|
-
async decide(state, questions) {
|
|
309
|
-
const t0 = now();
|
|
310
|
-
const normalized = normalizeQuestions(questions);
|
|
311
|
-
const stateText = renderDecision2Content(state);
|
|
312
|
-
const B = normalized.length;
|
|
313
|
-
const K = Math.max(...normalized.map((q) => renderDecision2Options(q).length));
|
|
314
|
-
|
|
315
|
-
const allIds = [];
|
|
316
|
-
const answerPos = [];
|
|
317
|
-
const optionPos = [];
|
|
318
|
-
const metas = [];
|
|
319
|
-
let truncated = false;
|
|
320
|
-
let length = 0;
|
|
321
|
-
|
|
322
|
-
for (const q of normalized) {
|
|
323
|
-
const { segments, optionEnds, keys } = buildDecision2Segments(q, stateText);
|
|
324
|
-
const { ids, optionPos: oPos, answerPos: aPos } =
|
|
325
|
-
tokenizeDecision2Segments(segments, optionEnds, encode, maxLength);
|
|
326
|
-
allIds.push(ids);
|
|
327
|
-
answerPos.push(aPos);
|
|
328
|
-
// Pad option positions with 0 (matches the strands convention).
|
|
329
|
-
const padded = [...oPos];
|
|
330
|
-
while (padded.length < K) padded.push(0);
|
|
331
|
-
optionPos.push(padded);
|
|
332
|
-
metas.push({ question: q, keys });
|
|
333
|
-
length += ids.length;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Pad to a rectangular batch.
|
|
337
|
-
const L = Math.max(...allIds.map((r) => r.length));
|
|
338
|
-
const inputIds = new BigInt64Array(B * L);
|
|
339
|
-
const attentionMask = new BigInt64Array(B * L);
|
|
340
|
-
for (let b = 0; b < B; b++) {
|
|
341
|
-
allIds[b].forEach((id, i) => {
|
|
342
|
-
inputIds[b * L + i] = BigInt(id);
|
|
343
|
-
attentionMask[b * L + i] = 1n;
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
const feeds = {
|
|
347
|
-
input_ids: new ortMod.Tensor("int64", inputIds, [B, L]),
|
|
348
|
-
attention_mask: new ortMod.Tensor("int64", attentionMask, [B, L]),
|
|
349
|
-
answer_pos: new ortMod.Tensor("int64", BigInt64Array.from(answerPos.map(BigInt)), [B]),
|
|
350
|
-
option_pos: new ortMod.Tensor(
|
|
351
|
-
"int64",
|
|
352
|
-
BigInt64Array.from(optionPos.flat().map(BigInt)),
|
|
353
|
-
[B, K]),
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
const tRun = now();
|
|
357
|
-
const out = await session.run(feeds);
|
|
358
|
-
const runMs = now() - tRun;
|
|
359
|
-
const logits = out.logits ?? out[Object.keys(out)[0]];
|
|
360
|
-
if (logits.dims.length !== 2 || logits.dims[0] !== B || logits.dims[1] !== K) {
|
|
361
|
-
throw new Error(
|
|
362
|
-
`decision2: invalid logits shape [${logits.dims}] for batch ${B}x${K}`);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const answers = metas.map(({ question, keys }, b) => {
|
|
366
|
-
const row = Array.from(
|
|
367
|
-
{ length: keys.length },
|
|
368
|
-
(_, k) => Number(logits.data[b * K + k]));
|
|
369
|
-
return decision2AnswerFromLogits({ logits: row, question, keys, scoreBias });
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
return {
|
|
373
|
-
answers, truncated, length,
|
|
374
|
-
prompts: metas.map(({ question, keys }) => ({ question: question.instructions, keys })),
|
|
375
|
-
timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
|
|
376
|
-
};
|
|
377
|
-
},
|
|
378
|
-
async dispose() {
|
|
379
|
-
try { await session.release?.(); } catch { /* optional */ }
|
|
380
|
-
},
|
|
381
|
-
};
|
|
382
|
-
}
|
|
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
|
+
/** Named variants with their pinned revisions and context sizes. */
|
|
39
|
+
export const DECISION2_VARIANTS = {
|
|
40
|
+
kai: {
|
|
41
|
+
model: DECISION2_DEFAULT_MODEL,
|
|
42
|
+
revision: DECISION2_DEFAULT_REVISION,
|
|
43
|
+
maxLength: 8192,
|
|
44
|
+
description: "0.6B, 0.61 GB q8",
|
|
45
|
+
},
|
|
46
|
+
eos: {
|
|
47
|
+
model: DECISION2_EOS_MODEL,
|
|
48
|
+
revision: DECISION2_EOS_REVISION,
|
|
49
|
+
maxLength: 16384,
|
|
50
|
+
description: "0.8B, q8/q4f16",
|
|
51
|
+
},
|
|
52
|
+
sol: {
|
|
53
|
+
model: DECISION2_SOL_MODEL,
|
|
54
|
+
revision: DECISION2_SOL_REVISION,
|
|
55
|
+
maxLength: 16384,
|
|
56
|
+
description: "2B, q8/q4f16",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const DECISION2_NOUL_DESCRIPTIONS = { false: "No", true: "Yes" };
|
|
61
|
+
const DECISION_SUFFIX = "Select the single option best supported by the context and instructions.";
|
|
62
|
+
|
|
63
|
+
/** Flatten a state or instruction into text, stably (object keys sorted). */
|
|
64
|
+
export function renderDecision2Content(content) {
|
|
65
|
+
if (typeof content === "string") return content;
|
|
66
|
+
return JSON.stringify(sortKeys(content));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Recursively sort object keys for deterministic serialization. */
|
|
70
|
+
function sortKeys(value) {
|
|
71
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
72
|
+
if (value !== null && typeof value === "object") {
|
|
73
|
+
return Object.fromEntries(
|
|
74
|
+
Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the option list for one question: [{key, description}]. Descriptions
|
|
81
|
+
* are null when empty (matching the reference encoding).
|
|
82
|
+
*/
|
|
83
|
+
export function renderDecision2Options(question) {
|
|
84
|
+
if (question.type === "choice") {
|
|
85
|
+
const criteria = question.criteria ?? {};
|
|
86
|
+
return question.options.map((label) => {
|
|
87
|
+
const d = criteria[label];
|
|
88
|
+
return { key: label, description: d == null || String(d).trim() === "" ? null : String(d) };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (question.type === "noul") {
|
|
92
|
+
const criteria = question.criteria ?? {};
|
|
93
|
+
const falseDesc = criteria.false ?? criteria.no ?? DECISION2_NOUL_DESCRIPTIONS.false;
|
|
94
|
+
const trueDesc = criteria.true ?? criteria.yes ?? DECISION2_NOUL_DESCRIPTIONS.true;
|
|
95
|
+
return [
|
|
96
|
+
{ key: "false", description: String(falseDesc) },
|
|
97
|
+
{ key: "true", description: String(trueDesc) },
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
if (question.type === "score") {
|
|
101
|
+
return question.options.map((rubric, i) => ({ key: String(i), description: String(rubric) }));
|
|
102
|
+
}
|
|
103
|
+
throw new TypeError(`decision2: unsupported question type ${question.type}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the prompt segments for one question. Each segment is tokenized
|
|
108
|
+
* separately; the returned `optionEnds` marks which segment index ends each
|
|
109
|
+
* option (for option_pos), and the prompt ends with the Decision: segment.
|
|
110
|
+
*/
|
|
111
|
+
export function buildDecision2Segments(question, stateText) {
|
|
112
|
+
const options = renderDecision2Options(question);
|
|
113
|
+
const segments = [
|
|
114
|
+
`Context:\n${stateText}\n\n`,
|
|
115
|
+
`Task type: ${question.type}\n`,
|
|
116
|
+
`Question:\n${renderDecision2Content(question.instructions)}\n`,
|
|
117
|
+
`Options:`,
|
|
118
|
+
];
|
|
119
|
+
const optionEnds = [];
|
|
120
|
+
for (const opt of options) {
|
|
121
|
+
// Sorted keys, compact JSON, matching the reference.
|
|
122
|
+
const payload = JSON.stringify({ description: opt.description, key: opt.key });
|
|
123
|
+
segments.push(`\n<option>\n${payload}\n</option>`);
|
|
124
|
+
optionEnds.push(segments.length - 1);
|
|
125
|
+
}
|
|
126
|
+
segments.push(`\n\n${DECISION_SUFFIX}\n`);
|
|
127
|
+
segments.push(`Decision:`);
|
|
128
|
+
return { segments, optionEnds, keys: options.map((o) => o.key) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tokenize segments separately and concatenate, recording the absolute
|
|
133
|
+
* position of each option's last token (option_pos) and the prompt's last
|
|
134
|
+
* token (answer_pos).
|
|
135
|
+
*/
|
|
136
|
+
export function tokenizeDecision2Segments(segments, optionEnds, encode, maxLength) {
|
|
137
|
+
const ids = [];
|
|
138
|
+
const optionPos = [];
|
|
139
|
+
for (let s = 0; s < segments.length; s++) {
|
|
140
|
+
const segIds = encode(segments[s]);
|
|
141
|
+
const start = ids.length;
|
|
142
|
+
ids.push(...segIds);
|
|
143
|
+
if (optionEnds.includes(s)) {
|
|
144
|
+
optionPos.push(start + segIds.length - 1);
|
|
145
|
+
}
|
|
146
|
+
if (ids.length > maxLength) {
|
|
147
|
+
throw new RangeError(
|
|
148
|
+
`decision2: prompt too long (${ids.length} > ${maxLength} tokens)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { ids, optionPos, answerPos: ids.length - 1 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Softmax with temperature (temperature 1 for Decision 2.0). */
|
|
155
|
+
export function decision2Softmax(logits) {
|
|
156
|
+
const max = Math.max(...logits);
|
|
157
|
+
const exps = logits.map((x) => Math.exp(Number(x) - max));
|
|
158
|
+
const sum = exps.reduce((a, b) => a + b, 0);
|
|
159
|
+
return exps.map((e) => e / sum);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decode one question's logits into a jev-web answer. Score questions add
|
|
164
|
+
* the per-level bias before the softmax.
|
|
165
|
+
*/
|
|
166
|
+
export function decision2AnswerFromLogits({ logits, question, keys, scoreBias }) {
|
|
167
|
+
const adjusted = logits.map(Number);
|
|
168
|
+
if (question.type === "score" && scoreBias) {
|
|
169
|
+
const bias = scoreBias[String(keys.length)];
|
|
170
|
+
if (bias) {
|
|
171
|
+
if (bias.length !== adjusted.length) {
|
|
172
|
+
throw new TypeError(
|
|
173
|
+
`decision2: score_bias length ${bias.length} != ${adjusted.length} options`);
|
|
174
|
+
}
|
|
175
|
+
for (let i = 0; i < adjusted.length; i++) adjusted[i] += bias[i];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const probs = decision2Softmax(adjusted);
|
|
179
|
+
const best = probs.indexOf(Math.max(...probs));
|
|
180
|
+
|
|
181
|
+
if (question.type === "choice") {
|
|
182
|
+
const probabilities = Object.fromEntries(keys.map((k, i) => [k, probs[i]]));
|
|
183
|
+
return {
|
|
184
|
+
type: "choice", choice: keys[best], index: best,
|
|
185
|
+
probabilities, confidence: Math.max(...probs),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (question.type === "noul") {
|
|
189
|
+
const yesIdx = keys.indexOf("true");
|
|
190
|
+
const yes = probs[yesIdx];
|
|
191
|
+
const [noLabel, yesLabel] = question.options;
|
|
192
|
+
return {
|
|
193
|
+
type: "noul", noul: yes,
|
|
194
|
+
probabilities: { [noLabel]: 1 - yes, [yesLabel]: yes },
|
|
195
|
+
confidence: Math.max(yes, 1 - yes),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// score
|
|
199
|
+
const values = keys.map((k) => Number(k));
|
|
200
|
+
const score = probs.reduce((acc, p, i) => acc + p * values[i], 0);
|
|
201
|
+
const probabilities = Object.fromEntries(keys.map((k, i) => [k, probs[i]]));
|
|
202
|
+
return {
|
|
203
|
+
type: "score", score, level: best,
|
|
204
|
+
legend: Object.fromEntries(question.options.map((r, i) => [String(i), r])),
|
|
205
|
+
probabilities, confidence: Math.max(...probs),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
210
|
+
const hfUrl = (model, revision, ...parts) =>
|
|
211
|
+
`https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${parts.join("/")}`;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create a Decision 2.0 decider.
|
|
215
|
+
*
|
|
216
|
+
* @param {object} [opts]
|
|
217
|
+
* model/revision/file — pinned ONNX export (or your own mirror)
|
|
218
|
+
* configUrl — override for config.json (carries decision2.score_bias)
|
|
219
|
+
* ort, transformers, fetchImpl, wasmPaths, device, sessionFactory — as in
|
|
220
|
+
* the other families
|
|
221
|
+
* maxLength — token budget (default 8192)
|
|
222
|
+
*/
|
|
223
|
+
export async function createDecision2Decider({
|
|
224
|
+
model = DECISION2_DEFAULT_MODEL,
|
|
225
|
+
revision = DECISION2_DEFAULT_REVISION,
|
|
226
|
+
variant = null,
|
|
227
|
+
file = DECISION2_DEFAULT_FILE,
|
|
228
|
+
configUrl = null,
|
|
229
|
+
ort = null,
|
|
230
|
+
transformers = null,
|
|
231
|
+
fetchImpl = fetch,
|
|
232
|
+
wasmPaths = null,
|
|
233
|
+
device = "auto",
|
|
234
|
+
sessionFactory = null,
|
|
235
|
+
onProgress = null,
|
|
236
|
+
maxLength = DECISION2_MAX_INPUT_TOKENS,
|
|
237
|
+
scope = globalThis,
|
|
238
|
+
} = {}) {
|
|
239
|
+
// Named variant shortcut: {variant: "eos"} sets model/revision/maxLength.
|
|
240
|
+
if (variant) {
|
|
241
|
+
const v = DECISION2_VARIANTS[variant];
|
|
242
|
+
if (!v) throw new TypeError(`decision2: unknown variant "${variant}" (kai, eos, sol)`);
|
|
243
|
+
model = v.model;
|
|
244
|
+
revision = v.revision;
|
|
245
|
+
maxLength = v.maxLength;
|
|
246
|
+
}
|
|
247
|
+
const ortMod = ort ?? (await import("onnxruntime-web"));
|
|
248
|
+
const tf = transformers ?? (await import("@huggingface/transformers"));
|
|
249
|
+
if (wasmPaths) {
|
|
250
|
+
try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const resolvedDevice = device !== "auto"
|
|
254
|
+
? device
|
|
255
|
+
: (scope?.navigator?.gpu ? "webgpu" : "wasm");
|
|
256
|
+
const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
|
|
257
|
+
|
|
258
|
+
const configRes = await fetchImpl(configUrl ?? hfUrl(model, revision, "config.json"));
|
|
259
|
+
if (!configRes.ok) throw new Error(`decision2: config fetch failed (${configRes.status})`);
|
|
260
|
+
const config = await configRes.json();
|
|
261
|
+
const scoreBias = config?.decision2?.score_bias ?? {};
|
|
262
|
+
|
|
263
|
+
const tok = await tf.AutoTokenizer.from_pretrained(model, {
|
|
264
|
+
revision,
|
|
265
|
+
progress_callback: (p) => onProgress?.({ phase: "tokenizer", ...p }),
|
|
266
|
+
});
|
|
267
|
+
const encode = (text) =>
|
|
268
|
+
Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
|
|
269
|
+
|
|
270
|
+
const url = hfUrl(model, revision, file);
|
|
271
|
+
const createSession = sessionFactory
|
|
272
|
+
?? (async (u, opts) => {
|
|
273
|
+
const modelBytes = await fetchCachedBytes(u, {
|
|
274
|
+
fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
|
|
275
|
+
});
|
|
276
|
+
return ortMod.InferenceSession.create(modelBytes, opts);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
let session;
|
|
280
|
+
let activeDevice = resolvedDevice;
|
|
281
|
+
try {
|
|
282
|
+
session = await createSession(url, { executionProviders, graphOptimizationLevel: "all" });
|
|
283
|
+
} catch (e) {
|
|
284
|
+
if (resolvedDevice === "webgpu") {
|
|
285
|
+
onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
|
|
286
|
+
session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
|
|
287
|
+
activeDevice = "wasm";
|
|
288
|
+
} else {
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Probe external data (the q8 file ships a .onnx_data sidecar).
|
|
294
|
+
try {
|
|
295
|
+
const probe = await fetchImpl(url + "_data", { method: "HEAD" });
|
|
296
|
+
if (!probe.ok) {
|
|
297
|
+
const probe2 = await fetchImpl(url.replace(/\.onnx$/, ".onnx_data"), { method: "HEAD" });
|
|
298
|
+
void probe2;
|
|
299
|
+
}
|
|
300
|
+
} catch { /* optional */ }
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
info: {
|
|
304
|
+
family: "decision2",
|
|
305
|
+
model, revision, device: activeDevice,
|
|
306
|
+
maxLength,
|
|
307
|
+
},
|
|
308
|
+
async decide(state, questions) {
|
|
309
|
+
const t0 = now();
|
|
310
|
+
const normalized = normalizeQuestions(questions);
|
|
311
|
+
const stateText = renderDecision2Content(state);
|
|
312
|
+
const B = normalized.length;
|
|
313
|
+
const K = Math.max(...normalized.map((q) => renderDecision2Options(q).length));
|
|
314
|
+
|
|
315
|
+
const allIds = [];
|
|
316
|
+
const answerPos = [];
|
|
317
|
+
const optionPos = [];
|
|
318
|
+
const metas = [];
|
|
319
|
+
let truncated = false;
|
|
320
|
+
let length = 0;
|
|
321
|
+
|
|
322
|
+
for (const q of normalized) {
|
|
323
|
+
const { segments, optionEnds, keys } = buildDecision2Segments(q, stateText);
|
|
324
|
+
const { ids, optionPos: oPos, answerPos: aPos } =
|
|
325
|
+
tokenizeDecision2Segments(segments, optionEnds, encode, maxLength);
|
|
326
|
+
allIds.push(ids);
|
|
327
|
+
answerPos.push(aPos);
|
|
328
|
+
// Pad option positions with 0 (matches the strands convention).
|
|
329
|
+
const padded = [...oPos];
|
|
330
|
+
while (padded.length < K) padded.push(0);
|
|
331
|
+
optionPos.push(padded);
|
|
332
|
+
metas.push({ question: q, keys });
|
|
333
|
+
length += ids.length;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Pad to a rectangular batch.
|
|
337
|
+
const L = Math.max(...allIds.map((r) => r.length));
|
|
338
|
+
const inputIds = new BigInt64Array(B * L);
|
|
339
|
+
const attentionMask = new BigInt64Array(B * L);
|
|
340
|
+
for (let b = 0; b < B; b++) {
|
|
341
|
+
allIds[b].forEach((id, i) => {
|
|
342
|
+
inputIds[b * L + i] = BigInt(id);
|
|
343
|
+
attentionMask[b * L + i] = 1n;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const feeds = {
|
|
347
|
+
input_ids: new ortMod.Tensor("int64", inputIds, [B, L]),
|
|
348
|
+
attention_mask: new ortMod.Tensor("int64", attentionMask, [B, L]),
|
|
349
|
+
answer_pos: new ortMod.Tensor("int64", BigInt64Array.from(answerPos.map(BigInt)), [B]),
|
|
350
|
+
option_pos: new ortMod.Tensor(
|
|
351
|
+
"int64",
|
|
352
|
+
BigInt64Array.from(optionPos.flat().map(BigInt)),
|
|
353
|
+
[B, K]),
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const tRun = now();
|
|
357
|
+
const out = await session.run(feeds);
|
|
358
|
+
const runMs = now() - tRun;
|
|
359
|
+
const logits = out.logits ?? out[Object.keys(out)[0]];
|
|
360
|
+
if (logits.dims.length !== 2 || logits.dims[0] !== B || logits.dims[1] !== K) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`decision2: invalid logits shape [${logits.dims}] for batch ${B}x${K}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const answers = metas.map(({ question, keys }, b) => {
|
|
366
|
+
const row = Array.from(
|
|
367
|
+
{ length: keys.length },
|
|
368
|
+
(_, k) => Number(logits.data[b * K + k]));
|
|
369
|
+
return decision2AnswerFromLogits({ logits: row, question, keys, scoreBias });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
answers, truncated, length,
|
|
374
|
+
prompts: metas.map(({ question, keys }) => ({ question: question.instructions, keys })),
|
|
375
|
+
timings: { totalMs: Math.round(now() - t0), runMs: Math.round(runMs) },
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
async dispose() {
|
|
379
|
+
try { await session.release?.(); } catch { /* optional */ }
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|