jev-web 0.7.2 → 0.9.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/src/kev.mjs CHANGED
@@ -1,297 +1,297 @@
1
- /**
2
- * kev.mjs — Kev decision-model family (jaredpalmer/kev, Apache-2.0).
3
- *
4
- * Kev is a typed decision model: one document (the *state*) and any number of
5
- * typed questions are packed into a single sequence with five delimiter
6
- * tokens. A block-causal mask (derived in-graph from the delimiter ids) lets
7
- * every question see the state and itself only; a pointer head scores each
8
- * option's `</opt>` token against its question's `<decide>` token.
9
- *
10
- * graph: input_ids, attention_mask (right padded) -> logits [B, seq]
11
- * decode: read the logit at every option's `</opt>` position,
12
- * softmax within each question.
13
- *
14
- * Reference: onnx-community/kev-0.6b-ONNX README (ships a JS implementation
15
- * this mirrors) and config.json `kev` block (delimiters, ids, limits).
16
- */
17
-
18
- import { normalizeQuestions } from "./questions.mjs";
19
- import { answersFromScores } from "./answers.mjs";
20
- import { fetchCachedBytes, fetchOnnxExternalData } from "./laya.mjs";
21
- import { resolveDtype } from "./config.mjs";
22
-
23
- const HF_BASE = "https://huggingface.co";
24
- const hfUrl = (model, revision, ...parts) =>
25
- [HF_BASE, model, "resolve", revision, ...parts.filter(Boolean)].join("/");
26
-
27
- export const KEV_DEFAULT_MODEL = "onnx-community/kev-0.6b-ONNX";
28
- export const KEV_DEFAULT_REVISION = "5a827091b3d58fe4fdfc02a5a5b2ce364047d678";
29
- export const KEV_4B_MODEL = "onnx-community/kev-4b-ONNX";
30
- export const KEV_4B_REVISION = "195bdf73096df2bca7150223c9279af7893163c";
31
-
32
- /** Named size variants with their pinned revisions and download sizes. */
33
- export const KEV_VARIANTS = {
34
- "0.6b": {
35
- model: KEV_DEFAULT_MODEL,
36
- revision: KEV_DEFAULT_REVISION,
37
- bytes: 335_000_000,
38
- description: "kev-0.6b, 335 MB q4f16 browser ONNX",
39
- },
40
- "4b": {
41
- model: KEV_4B_MODEL,
42
- revision: KEV_4B_REVISION,
43
- bytes: 1_070_000_000,
44
- description: "kev-4b, 1.07 GB q4 browser ONNX",
45
- },
46
- };
47
-
48
- // Fallback when config.json has no `kev` block (both shipped repos have one).
49
- const KEV_FALLBACK = {
50
- delimiters: {
51
- state: "<|fim_prefix|>",
52
- question: "<|fim_middle|>",
53
- option_start: "<|box_start|>",
54
- option_end: "<|box_end|>",
55
- decide: "<|fim_suffix|>",
56
- },
57
- max_state_tokens: 8192,
58
- max_branch_tokens: 8192,
59
- max_options: 255,
60
- };
61
-
62
- /** Caller text can never produce a delimiter: <|name|> -> <¦name¦>. */
63
- export function escapeKevText(text) {
64
- return String(text).replace(/<\|([A-Za-z0-9_]+)\|>/g, "<¦$1¦>");
65
- }
66
-
67
- function renderKevContent(content) {
68
- if (typeof content === "string") return content.trim();
69
- return JSON.stringify(content);
70
- }
71
-
72
- /** Render one option; choice options pick up `criteria` descriptions. */
73
- function renderKevOption(question, label) {
74
- if (question.type === "choice") {
75
- const desc = question.criteria?.[label];
76
- if (desc != null && String(desc).trim() && String(desc).trim() !== label) {
77
- return `${label}: ${String(desc).trim()}`;
78
- }
79
- }
80
- return label;
81
- }
82
-
83
- /**
84
- * Pack the state and normalized questions into one kev token sequence.
85
- * Returns { tokens, groups } where groups[i] holds the sequence indices of
86
- * question i's option `</opt>` tokens.
87
- */
88
- export function packKevSequence({ stateText, questions, encode, delims, limits }) {
89
- const { state: STATE, question: Q, option_start: OPT, option_end: END, decide: DECIDE } = delims;
90
- const stateIds = encode(stateText).slice(0, limits.max_state_tokens);
91
- const tokens = [STATE, ...stateIds];
92
- const groups = [];
93
- let truncated = false;
94
-
95
- for (const q of questions) {
96
- const instrIds = encode(q.instructions);
97
- const spans = q.options.map((label) => [OPT, ...encode(renderKevOption(q, label)), END]);
98
- const branch = [Q, ...instrIds, ...spans.flat(), DECIDE];
99
- if (branch.length > limits.max_branch_tokens) {
100
- throw new Error(
101
- `kev: question branch too long (${branch.length} > ${limits.max_branch_tokens} tokens)`,
102
- );
103
- }
104
- if (tokens.length + branch.length > limits.max_state_tokens) {
105
- truncated = true;
106
- break;
107
- }
108
- const base = tokens.length;
109
- let cursor = 1 + instrIds.length;
110
- const ends = spans.map((s) => {
111
- cursor += s.length;
112
- return base + cursor - 1; // index of this option's </opt>
113
- });
114
- tokens.push(...branch);
115
- groups.push(ends);
116
- }
117
- return { tokens, groups, truncated };
118
- }
119
-
120
- /**
121
- * Create a Kev decider.
122
- *
123
- * @param {object} [opts]
124
- * model/revision — pinned ONNX export (or your own mirror)
125
- * variant — "0.6b" | "4b" shortcut for model/revision
126
- * modelFile — override the resolved onnx file (default picks q4f16/q4 by device)
127
- * configUrl/modelUrl — override the resolved file URLs
128
- * ort — injected onnxruntime-web (lazy import otherwise)
129
- * transformers — injected @huggingface/transformers (tokenizer only)
130
- * fetchImpl — fetch implementation
131
- * wasmPaths — ORT wasm/loader directory (self-hosted builds)
132
- * device — "auto" | "wasm" | "webgpu"
133
- * dtype — "auto" | "q4" | "q4f16"
134
- * sessionFactory — (url, sessionOptions) => Promise<InferenceSession>
135
- * onProgress — progress callback
136
- */
137
- export async function createKevDecider({
138
- model = KEV_DEFAULT_MODEL,
139
- revision = KEV_DEFAULT_REVISION,
140
- variant = null,
141
- modelFile = null,
142
- configUrl = null,
143
- modelUrl = null,
144
- ort = null,
145
- transformers = null,
146
- fetchImpl = fetch,
147
- wasmPaths = null,
148
- device = "auto",
149
- dtype = "auto",
150
- sessionFactory = null,
151
- onProgress = null,
152
- scope = globalThis,
153
- } = {}) {
154
- if (variant) {
155
- const v = KEV_VARIANTS[variant];
156
- if (!v) throw new TypeError(`kev: unknown variant "${variant}" (0.6b, 4b)`);
157
- model = v.model;
158
- revision = v.revision;
159
- }
160
- const ortMod = ort ?? (await import("onnxruntime-web"));
161
- const tf = transformers ?? (await import("@huggingface/transformers"));
162
- if (wasmPaths) {
163
- try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
164
- }
165
-
166
- const resolvedDevice = device !== "auto"
167
- ? device
168
- : (scope?.navigator?.gpu ? "webgpu" : "wasm");
169
- const resolvedDtype = resolveDtype(resolvedDevice, dtype);
170
- const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
171
-
172
- const report = (phase) => (p) =>
173
- onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
174
-
175
- // Model config carries the kev protocol block (delimiters, ids, limits).
176
- const cfgRes = await fetchImpl(configUrl ?? hfUrl(model, revision, "", "config.json"));
177
- if (!cfgRes.ok) throw new Error(`kev: config fetch failed (${cfgRes.status})`);
178
- const kevCfg = (await cfgRes.json()).kev ?? {};
179
- const delimiters = { ...KEV_FALLBACK.delimiters, ...(kevCfg.delimiters ?? {}) };
180
- const limits = {
181
- max_state_tokens: kevCfg.max_state_tokens ?? KEV_FALLBACK.max_state_tokens,
182
- max_branch_tokens: kevCfg.max_branch_tokens ?? KEV_FALLBACK.max_branch_tokens,
183
- max_options: kevCfg.max_options ?? KEV_FALLBACK.max_options,
184
- };
185
-
186
- const tok = await tf.AutoTokenizer.from_pretrained(model, {
187
- revision,
188
- progress_callback: report("tokenizer"),
189
- });
190
- const rawEncode = (text) =>
191
- Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
192
- // Delimiter ids are derived from the raw strings; caller text is escaped so
193
- // it can never produce a delimiter.
194
- const encode = (text) => rawEncode(escapeKevText(text));
195
- // Derive delimiter ids through the tokenizer (robust to id changes).
196
- const delims = Object.fromEntries(
197
- Object.entries(delimiters).map(([k, t]) => {
198
- const ids = rawEncode(t);
199
- if (ids.length !== 1) throw new Error(`kev: delimiter ${t} is not a single token`);
200
- return [k, ids[0]];
201
- }),
202
- );
203
-
204
- const qfile = modelFile ?? (resolvedDtype === "q4f16" ? "model_q4f16.onnx" : "model_q4.onnx");
205
- const url = modelUrl ?? hfUrl(model, revision, "onnx", qfile);
206
- const createSession = sessionFactory
207
- ?? (async (u, opts) => {
208
- const modelBytes = await fetchCachedBytes(u, {
209
- fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
210
- });
211
- const external = await fetchOnnxExternalData(u, {
212
- fetchImpl, onProgress: (p) => onProgress?.(p),
213
- });
214
- const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
215
- return ortMod.InferenceSession.create(modelBytes, sessionOpts);
216
- });
217
- let session;
218
- let activeDevice = resolvedDevice;
219
- try {
220
- session = await createSession(url, {
221
- executionProviders,
222
- graphOptimizationLevel: "all",
223
- });
224
- } catch (e) {
225
- if (resolvedDevice === "webgpu") {
226
- onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
227
- session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
228
- activeDevice = "wasm";
229
- } else {
230
- throw e;
231
- }
232
- }
233
-
234
- return {
235
- info: {
236
- family: "kev",
237
- model,
238
- revision,
239
- device: activeDevice,
240
- dtype: resolvedDtype,
241
- tasks: ["choice", "noul", "score"],
242
- limits,
243
- },
244
- async decide(state, questions) {
245
- const t0 = Date.now();
246
- const normalized = normalizeQuestions(questions);
247
- for (const [i, q] of normalized.entries()) {
248
- if (q.options.length > limits.max_options) {
249
- throw new TypeError(`questions[${i}]: kev supports at most ${limits.max_options} options`);
250
- }
251
- }
252
- const stateText = renderKevContent(state);
253
- const { tokens, groups, truncated } = packKevSequence({
254
- stateText, questions: normalized, encode, delims, limits,
255
- });
256
- if (groups.length !== normalized.length) {
257
- throw new Error("kev: packed fewer questions than requested");
258
- }
259
-
260
- const n = tokens.length;
261
- const inputIds = new ortMod.Tensor("int64", BigInt64Array.from(tokens, BigInt), [1, n]);
262
- const attentionMask = new ortMod.Tensor("int64", new BigInt64Array(n).fill(1n), [1, n]);
263
- const tRun = Date.now();
264
- const out = await session.run({ input_ids: inputIds, attention_mask: attentionMask });
265
- const runMs = Date.now() - tRun;
266
- const outLogits = out.logits ?? out[Object.keys(out)[0]];
267
- if (outLogits.dims.length !== 2 || outLogits.dims[0] !== 1 || outLogits.dims[1] !== n) {
268
- throw new Error("kev: invalid model logits shape");
269
- }
270
- const scores = Array.from(outLogits.data, Number);
271
- // One flat score list; groups map each question to its option positions.
272
- const flat = [];
273
- const flatGroups = groups.map((ends) => ends.map((pos) => {
274
- flat.push(scores[pos]);
275
- return flat.length - 1;
276
- }));
277
- const answers = answersFromScores(flat, {
278
- questions: normalized,
279
- groups: flatGroups,
280
- });
281
-
282
- return {
283
- answers,
284
- truncated,
285
- length: n,
286
- prompts: normalized.map((q) => ({
287
- instructions: q.instructions,
288
- options: q.options,
289
- })),
290
- timings: { totalMs: Date.now() - t0, runMs },
291
- };
292
- },
293
- async dispose() {
294
- try { await session.release?.(); } catch { /* optional */ }
295
- },
296
- };
1
+ /**
2
+ * kev.mjs — Kev decision-model family (jaredpalmer/kev, Apache-2.0).
3
+ *
4
+ * Kev is a typed decision model: one document (the *state*) and any number of
5
+ * typed questions are packed into a single sequence with five delimiter
6
+ * tokens. A block-causal mask (derived in-graph from the delimiter ids) lets
7
+ * every question see the state and itself only; a pointer head scores each
8
+ * option's `</opt>` token against its question's `<decide>` token.
9
+ *
10
+ * graph: input_ids, attention_mask (right padded) -> logits [B, seq]
11
+ * decode: read the logit at every option's `</opt>` position,
12
+ * softmax within each question.
13
+ *
14
+ * Reference: onnx-community/kev-0.6b-ONNX README (ships a JS implementation
15
+ * this mirrors) and config.json `kev` block (delimiters, ids, limits).
16
+ */
17
+
18
+ import { normalizeQuestions } from "./questions.mjs";
19
+ import { answersFromScores } from "./answers.mjs";
20
+ import { fetchCachedBytes, fetchOnnxExternalData } from "./laya.mjs";
21
+ import { resolveDtype } from "./config.mjs";
22
+
23
+ const HF_BASE = "https://huggingface.co";
24
+ const hfUrl = (model, revision, ...parts) =>
25
+ [HF_BASE, model, "resolve", revision, ...parts.filter(Boolean)].join("/");
26
+
27
+ export const KEV_DEFAULT_MODEL = "onnx-community/kev-0.6b-ONNX";
28
+ export const KEV_DEFAULT_REVISION = "5a827091b3d58fe4fdfc02a5a5b2ce364047d678";
29
+ export const KEV_4B_MODEL = "onnx-community/kev-4b-ONNX";
30
+ export const KEV_4B_REVISION = "195bdf73096df2bca7150223c9279af7893163c";
31
+
32
+ /** Named size variants with their pinned revisions and download sizes. */
33
+ export const KEV_VARIANTS = {
34
+ "0.6b": {
35
+ model: KEV_DEFAULT_MODEL,
36
+ revision: KEV_DEFAULT_REVISION,
37
+ bytes: 335_000_000,
38
+ description: "kev-0.6b, 335 MB q4f16 browser ONNX",
39
+ },
40
+ "4b": {
41
+ model: KEV_4B_MODEL,
42
+ revision: KEV_4B_REVISION,
43
+ bytes: 1_070_000_000,
44
+ description: "kev-4b, 1.07 GB q4 browser ONNX",
45
+ },
46
+ };
47
+
48
+ // Fallback when config.json has no `kev` block (both shipped repos have one).
49
+ const KEV_FALLBACK = {
50
+ delimiters: {
51
+ state: "<|fim_prefix|>",
52
+ question: "<|fim_middle|>",
53
+ option_start: "<|box_start|>",
54
+ option_end: "<|box_end|>",
55
+ decide: "<|fim_suffix|>",
56
+ },
57
+ max_state_tokens: 8192,
58
+ max_branch_tokens: 8192,
59
+ max_options: 255,
60
+ };
61
+
62
+ /** Caller text can never produce a delimiter: <|name|> -> <¦name¦>. */
63
+ export function escapeKevText(text) {
64
+ return String(text).replace(/<\|([A-Za-z0-9_]+)\|>/g, "<¦$1¦>");
65
+ }
66
+
67
+ function renderKevContent(content) {
68
+ if (typeof content === "string") return content.trim();
69
+ return JSON.stringify(content);
70
+ }
71
+
72
+ /** Render one option; choice options pick up `criteria` descriptions. */
73
+ function renderKevOption(question, label) {
74
+ if (question.type === "choice") {
75
+ const desc = question.criteria?.[label];
76
+ if (desc != null && String(desc).trim() && String(desc).trim() !== label) {
77
+ return `${label}: ${String(desc).trim()}`;
78
+ }
79
+ }
80
+ return label;
81
+ }
82
+
83
+ /**
84
+ * Pack the state and normalized questions into one kev token sequence.
85
+ * Returns { tokens, groups } where groups[i] holds the sequence indices of
86
+ * question i's option `</opt>` tokens.
87
+ */
88
+ export function packKevSequence({ stateText, questions, encode, delims, limits }) {
89
+ const { state: STATE, question: Q, option_start: OPT, option_end: END, decide: DECIDE } = delims;
90
+ const stateIds = encode(stateText).slice(0, limits.max_state_tokens);
91
+ const tokens = [STATE, ...stateIds];
92
+ const groups = [];
93
+ let truncated = false;
94
+
95
+ for (const q of questions) {
96
+ const instrIds = encode(q.instructions);
97
+ const spans = q.options.map((label) => [OPT, ...encode(renderKevOption(q, label)), END]);
98
+ const branch = [Q, ...instrIds, ...spans.flat(), DECIDE];
99
+ if (branch.length > limits.max_branch_tokens) {
100
+ throw new Error(
101
+ `kev: question branch too long (${branch.length} > ${limits.max_branch_tokens} tokens)`,
102
+ );
103
+ }
104
+ if (tokens.length + branch.length > limits.max_state_tokens) {
105
+ truncated = true;
106
+ break;
107
+ }
108
+ const base = tokens.length;
109
+ let cursor = 1 + instrIds.length;
110
+ const ends = spans.map((s) => {
111
+ cursor += s.length;
112
+ return base + cursor - 1; // index of this option's </opt>
113
+ });
114
+ tokens.push(...branch);
115
+ groups.push(ends);
116
+ }
117
+ return { tokens, groups, truncated };
118
+ }
119
+
120
+ /**
121
+ * Create a Kev decider.
122
+ *
123
+ * @param {object} [opts]
124
+ * model/revision — pinned ONNX export (or your own mirror)
125
+ * variant — "0.6b" | "4b" shortcut for model/revision
126
+ * modelFile — override the resolved onnx file (default picks q4f16/q4 by device)
127
+ * configUrl/modelUrl — override the resolved file URLs
128
+ * ort — injected onnxruntime-web (lazy import otherwise)
129
+ * transformers — injected @huggingface/transformers (tokenizer only)
130
+ * fetchImpl — fetch implementation
131
+ * wasmPaths — ORT wasm/loader directory (self-hosted builds)
132
+ * device — "auto" | "wasm" | "webgpu"
133
+ * dtype — "auto" | "q4" | "q4f16"
134
+ * sessionFactory — (url, sessionOptions) => Promise<InferenceSession>
135
+ * onProgress — progress callback
136
+ */
137
+ export async function createKevDecider({
138
+ model = KEV_DEFAULT_MODEL,
139
+ revision = KEV_DEFAULT_REVISION,
140
+ variant = null,
141
+ modelFile = null,
142
+ configUrl = null,
143
+ modelUrl = null,
144
+ ort = null,
145
+ transformers = null,
146
+ fetchImpl = fetch,
147
+ wasmPaths = null,
148
+ device = "auto",
149
+ dtype = "auto",
150
+ sessionFactory = null,
151
+ onProgress = null,
152
+ scope = globalThis,
153
+ } = {}) {
154
+ if (variant) {
155
+ const v = KEV_VARIANTS[variant];
156
+ if (!v) throw new TypeError(`kev: unknown variant "${variant}" (0.6b, 4b)`);
157
+ model = v.model;
158
+ revision = v.revision;
159
+ }
160
+ const ortMod = ort ?? (await import("onnxruntime-web"));
161
+ const tf = transformers ?? (await import("@huggingface/transformers"));
162
+ if (wasmPaths) {
163
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
164
+ }
165
+
166
+ const resolvedDevice = device !== "auto"
167
+ ? device
168
+ : (scope?.navigator?.gpu ? "webgpu" : "wasm");
169
+ const resolvedDtype = resolveDtype(resolvedDevice, dtype);
170
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
171
+
172
+ const report = (phase) => (p) =>
173
+ onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
174
+
175
+ // Model config carries the kev protocol block (delimiters, ids, limits).
176
+ const cfgRes = await fetchImpl(configUrl ?? hfUrl(model, revision, "", "config.json"));
177
+ if (!cfgRes.ok) throw new Error(`kev: config fetch failed (${cfgRes.status})`);
178
+ const kevCfg = (await cfgRes.json()).kev ?? {};
179
+ const delimiters = { ...KEV_FALLBACK.delimiters, ...(kevCfg.delimiters ?? {}) };
180
+ const limits = {
181
+ max_state_tokens: kevCfg.max_state_tokens ?? KEV_FALLBACK.max_state_tokens,
182
+ max_branch_tokens: kevCfg.max_branch_tokens ?? KEV_FALLBACK.max_branch_tokens,
183
+ max_options: kevCfg.max_options ?? KEV_FALLBACK.max_options,
184
+ };
185
+
186
+ const tok = await tf.AutoTokenizer.from_pretrained(model, {
187
+ revision,
188
+ progress_callback: report("tokenizer"),
189
+ });
190
+ const rawEncode = (text) =>
191
+ Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number);
192
+ // Delimiter ids are derived from the raw strings; caller text is escaped so
193
+ // it can never produce a delimiter.
194
+ const encode = (text) => rawEncode(escapeKevText(text));
195
+ // Derive delimiter ids through the tokenizer (robust to id changes).
196
+ const delims = Object.fromEntries(
197
+ Object.entries(delimiters).map(([k, t]) => {
198
+ const ids = rawEncode(t);
199
+ if (ids.length !== 1) throw new Error(`kev: delimiter ${t} is not a single token`);
200
+ return [k, ids[0]];
201
+ }),
202
+ );
203
+
204
+ const qfile = modelFile ?? (resolvedDtype === "q4f16" ? "model_q4f16.onnx" : "model_q4.onnx");
205
+ const url = modelUrl ?? hfUrl(model, revision, "onnx", qfile);
206
+ const createSession = sessionFactory
207
+ ?? (async (u, opts) => {
208
+ const modelBytes = await fetchCachedBytes(u, {
209
+ fetchImpl, onProgress: (p) => onProgress?.(p), persist: true,
210
+ });
211
+ const external = await fetchOnnxExternalData(u, {
212
+ fetchImpl, onProgress: (p) => onProgress?.(p),
213
+ });
214
+ const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
215
+ return ortMod.InferenceSession.create(modelBytes, sessionOpts);
216
+ });
217
+ let session;
218
+ let activeDevice = resolvedDevice;
219
+ try {
220
+ session = await createSession(url, {
221
+ executionProviders,
222
+ graphOptimizationLevel: "all",
223
+ });
224
+ } catch (e) {
225
+ if (resolvedDevice === "webgpu") {
226
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
227
+ session = await createSession(url, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
228
+ activeDevice = "wasm";
229
+ } else {
230
+ throw e;
231
+ }
232
+ }
233
+
234
+ return {
235
+ info: {
236
+ family: "kev",
237
+ model,
238
+ revision,
239
+ device: activeDevice,
240
+ dtype: resolvedDtype,
241
+ tasks: ["choice", "noul", "score"],
242
+ limits,
243
+ },
244
+ async decide(state, questions) {
245
+ const t0 = Date.now();
246
+ const normalized = normalizeQuestions(questions);
247
+ for (const [i, q] of normalized.entries()) {
248
+ if (q.options.length > limits.max_options) {
249
+ throw new TypeError(`questions[${i}]: kev supports at most ${limits.max_options} options`);
250
+ }
251
+ }
252
+ const stateText = renderKevContent(state);
253
+ const { tokens, groups, truncated } = packKevSequence({
254
+ stateText, questions: normalized, encode, delims, limits,
255
+ });
256
+ if (groups.length !== normalized.length) {
257
+ throw new Error("kev: packed fewer questions than requested");
258
+ }
259
+
260
+ const n = tokens.length;
261
+ const inputIds = new ortMod.Tensor("int64", BigInt64Array.from(tokens, BigInt), [1, n]);
262
+ const attentionMask = new ortMod.Tensor("int64", new BigInt64Array(n).fill(1n), [1, n]);
263
+ const tRun = Date.now();
264
+ const out = await session.run({ input_ids: inputIds, attention_mask: attentionMask });
265
+ const runMs = Date.now() - tRun;
266
+ const outLogits = out.logits ?? out[Object.keys(out)[0]];
267
+ if (outLogits.dims.length !== 2 || outLogits.dims[0] !== 1 || outLogits.dims[1] !== n) {
268
+ throw new Error("kev: invalid model logits shape");
269
+ }
270
+ const scores = Array.from(outLogits.data, Number);
271
+ // One flat score list; groups map each question to its option positions.
272
+ const flat = [];
273
+ const flatGroups = groups.map((ends) => ends.map((pos) => {
274
+ flat.push(scores[pos]);
275
+ return flat.length - 1;
276
+ }));
277
+ const answers = answersFromScores(flat, {
278
+ questions: normalized,
279
+ groups: flatGroups,
280
+ });
281
+
282
+ return {
283
+ answers,
284
+ truncated,
285
+ length: n,
286
+ prompts: normalized.map((q) => ({
287
+ instructions: q.instructions,
288
+ options: q.options,
289
+ })),
290
+ timings: { totalMs: Date.now() - t0, runMs },
291
+ };
292
+ },
293
+ async dispose() {
294
+ try { await session.release?.(); } catch { /* optional */ }
295
+ },
296
+ };
297
297
  }