jev-web 0.2.1 → 0.3.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/README.md CHANGED
@@ -91,6 +91,36 @@ take `input_ids, attention_mask, seg, pair_q, pair_opt`, and return per-pair
91
91
  `logits`. `typedMarkerIds()` fails loudly when a repo is missing them.
92
92
 
93
93
 
94
+ ## Multiple families (registry)
95
+
96
+ `jev-web` is a multi-family runtime: `createDecisionRuntime({ family, fallback })`
97
+ tries the requested family and then any fallbacks, returning the shared contract
98
+ `{ info, decide(state, questions), dispose? }`:
99
+
100
+ ```js
101
+ import { createDecisionRuntime, listDecisionFamilies } from "jev-web";
102
+
103
+ listDecisionFamilies(); // ["open-jev", "laya"]
104
+ const runtime = await createDecisionRuntime({
105
+ family: "laya",
106
+ fallback: ["open-jev"],
107
+ onFallback: ({ family, error }) => console.warn(family, error),
108
+ });
109
+ const { answers, truncated, length, prompts, timings } = await runtime.decide(state, questions);
110
+ ```
111
+
112
+ Third-party families plug in without touching core:
113
+
114
+ ```js
115
+ registerDecisionFamily("my-model", {
116
+ defaults: { model: "org/my-typed-decision-ONNX", revision: "main" },
117
+ create: async (opts) => ({ info, decide, dispose }),
118
+ });
119
+ ```
120
+
121
+ `decide()` returns `prompts` (decoded input sequence(s), for audit/debug) and
122
+ `timings` (per-stage ms) alongside the answers.
123
+
94
124
  ## Laya family (ModernBERT encoder + typed head)
95
125
 
96
126
  `createLayaDecider(options)` runs the [Laya](https://huggingface.co/convaiinnovations/laya)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-web",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Run open-jev- and Laya-shaped typed-decision models in the browser: one state plus typed questions in, calibrated probabilities out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -1,5 +1,9 @@
1
1
  // jev-web — public API (typed decisions in the browser).
2
2
  export { createDecider } from "./session.mjs";
3
+ export {
4
+ registerDecisionFamily, createDecisionRuntime,
5
+ listDecisionFamilies, getDecisionFamily,
6
+ } from "./registry.mjs";
3
7
  export { normalizeQuestions, QUESTION_TYPES, NOUL_OPTIONS } from "./questions.mjs";
4
8
  export { buildDecisionInput, typedMarkerIds, DecisionInputTooLongError } from "./encode.mjs";
5
9
  export { answersFromScores, softmaxWithTemperature } from "./answers.mjs";
package/src/laya.mjs CHANGED
@@ -206,6 +206,54 @@ export function layaAnswersFromLogits({
206
206
  return answers;
207
207
  }
208
208
 
209
+ /**
210
+ * Fetch a JSON file with optional byte progress (fetch/Response compatible).
211
+ * Falls back to `res.json()` when the body isn't streamable (Node fakes/tests).
212
+ */
213
+ async function fetchJson(url, file, { fetchImpl, onProgress }) {
214
+ const bytes = await fetchCachedBytes(url, {
215
+ fetchImpl,
216
+ onProgress: (p) => onProgress?.({ ...p, phase: "tokenizer", file }),
217
+ });
218
+ return JSON.parse(new TextDecoder().decode(bytes));
219
+ }
220
+
221
+ /**
222
+ * Load a Laya tokenizer directly from its files.
223
+ *
224
+ * transformers.js `AutoTokenizer.from_pretrained` ignores `subfolder`, and the
225
+ * Laya web export keeps its tokenizer under `v1/` (and its root has no
226
+ * `tokenizer_config.json`), so the library path throws
227
+ * "Cannot read properties of undefined (reading 'tokenizer_class')".
228
+ * Fetch the two JSON files ourselves and instantiate the class the config
229
+ * names (e.g. `PreTrainedTokenizerFast` -> `PreTrainedTokenizer`).
230
+ *
231
+ * @param {{urlBase:string, fetchImpl?:Function, transformers?:object,
232
+ * onProgress?:Function}} spec `urlBase` must end with "/".
233
+ * @returns {Promise<object>} tokenizer instance
234
+ */
235
+ export async function loadLayaTokenizer({
236
+ urlBase,
237
+ fetchImpl = fetch,
238
+ transformers = null,
239
+ onProgress = null,
240
+ } = {}) {
241
+ if (!urlBase) throw new TypeError("loadLayaTokenizer: urlBase is required");
242
+ const base = urlBase.endsWith("/") ? urlBase : `${urlBase}/`;
243
+ const [tokenizerJSON, tokenizerConfig] = await Promise.all([
244
+ fetchJson(`${base}tokenizer.json`, "tokenizer.json", { fetchImpl, onProgress }),
245
+ fetchJson(`${base}tokenizer_config.json`, "tokenizer_config.json", { fetchImpl, onProgress }),
246
+ ]);
247
+ const tf = transformers ?? (await import("@huggingface/transformers"));
248
+ const className = String(tokenizerConfig?.tokenizer_class ?? "PreTrainedTokenizer")
249
+ .replace(/Fast$/, "");
250
+ const Cls = tf[className] ?? tf.PreTrainedTokenizer;
251
+ if (typeof Cls !== "function") {
252
+ throw new Error(`loadLayaTokenizer: no tokenizer class for "${className}"`);
253
+ }
254
+ return new Cls(tokenizerJSON, tokenizerConfig);
255
+ }
256
+
209
257
  /** Wrap a transformers.js tokenizer into the tiny adapter laya needs. */
210
258
  export function layaTokenizerAdapter(tok) {
211
259
  const idOf = (prop, text) => {
@@ -232,9 +280,151 @@ export function layaTokenizerAdapter(tok) {
232
280
  };
233
281
  }
234
282
 
283
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
284
+
235
285
  const hfUrl = (model, revision, file) =>
236
286
  `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
237
287
 
288
+ const MODEL_CACHE = "kf-models-v1";
289
+
290
+ async function modelCacheStore() {
291
+ try {
292
+ return typeof caches !== "undefined" ? caches : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ /** Ask for persistent storage once so big weights survive eviction pressure. */
299
+ export async function requestPersistentStorage() {
300
+ try {
301
+ return (await globalThis.navigator?.storage?.persist?.()) ?? false;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Fetch bytes with a Cache-API layer. Hugging Face redirects to a *signed*
309
+ * CDN URL that changes per request, so the browser HTTP cache never hits for
310
+ * the 468 MB encoder weights; this caches them under the stable model URL
311
+ * instead (Cache API), so a page refresh is free.
312
+ *
313
+ * @returns {Promise<Uint8Array>}
314
+ */
315
+ export async function fetchCachedBytes(url, {
316
+ fetchImpl = fetch,
317
+ onProgress = null,
318
+ cacheName = MODEL_CACHE,
319
+ force = false,
320
+ persist = false,
321
+ } = {}) {
322
+ const store = await modelCacheStore();
323
+ if (persist) await requestPersistentStorage();
324
+ if (store && !force) {
325
+ try {
326
+ const hit = await store.match(url);
327
+ if (hit) {
328
+ const data = new Uint8Array(await hit.arrayBuffer());
329
+ onProgress?.({ phase: "cache", file: String(url).split("/").pop(), loaded: data.length, total: data.length });
330
+ return data;
331
+ }
332
+ } catch {
333
+ /* cache read failed — fall through to the network */
334
+ }
335
+ }
336
+ const res = await fetchImpl(url);
337
+ if (!res?.ok) throw new Error(`fetch ${url} failed (HTTP ${res?.status ?? "no response"})`);
338
+ const data = await readBytesWithProgress(res, url, onProgress);
339
+ if (store) {
340
+ try {
341
+ await store.put(url, new Response(data, {
342
+ headers: { "content-type": "application/octet-stream", "content-length": String(data.length) },
343
+ }));
344
+ } catch {
345
+ /* quota exceeded / private mode — caching is best-effort */
346
+ }
347
+ }
348
+ return data;
349
+ }
350
+
351
+ async function readBytesWithProgress(res, url, onProgress) {
352
+ const total = Number(res.headers?.get?.("content-length")) || 0;
353
+ const file = String(url).split("/").pop();
354
+ if (res.body?.getReader) {
355
+ const reader = res.body.getReader();
356
+ let received = 0;
357
+ let data = total ? new Uint8Array(total) : null;
358
+ const chunks = total ? null : [];
359
+ for (;;) {
360
+ const { done, value } = await reader.read();
361
+ if (done) break;
362
+ if (data) {
363
+ if (received + value.length > data.length) {
364
+ // content-length can describe the *encoded* size (gzip/br), so the
365
+ // decoded stream may be larger — grow instead of overflowing.
366
+ const grown = new Uint8Array(Math.max(data.length * 2, received + value.length));
367
+ grown.set(data.subarray(0, received));
368
+ data = grown;
369
+ }
370
+ data.set(value, received);
371
+ } else {
372
+ chunks.push(value);
373
+ }
374
+ received += value.length;
375
+ onProgress?.({ phase: "download", file, loaded: received, total: total || null });
376
+ }
377
+ if (!data) {
378
+ data = new Uint8Array(received);
379
+ let off = 0;
380
+ for (const c of chunks) { data.set(c, off); off += c.length; }
381
+ } else {
382
+ // the buffer may have grown past the decoded length (content-length is
383
+ // the *encoded* size) — always trim to what was actually received
384
+ data = data.subarray(0, received);
385
+ }
386
+ return data;
387
+ }
388
+ let data;
389
+ if (typeof res.arrayBuffer === "function") {
390
+ data = new Uint8Array(await res.arrayBuffer());
391
+ } else if (typeof res.text === "function") {
392
+ data = new TextEncoder().encode(await res.text());
393
+ } else if (typeof res.json === "function") {
394
+ data = new TextEncoder().encode(JSON.stringify(await res.json()));
395
+ } else {
396
+ throw new Error(`fetch ${url}: response has no readable body`);
397
+ }
398
+ onProgress?.({ phase: "download", file, loaded: data.length, total: data.length });
399
+ return data;
400
+ }
401
+
402
+ /**
403
+ * ONNX external-data loader.
404
+ *
405
+ * The pinned Laya export stores weights in `<model>.onnx.data` siblings
406
+ * (encoder ~468 MB, head ~53 MB). onnxruntime-web cannot resolve those from a
407
+ * URL by itself (`Module.MountedFiles is not available`); it needs the bytes
408
+ * passed via `session_options.externalData`. Fetch the sibling (streaming,
409
+ * with progress) and hand it over.
410
+ *
411
+ * @returns {Promise<{path:string, data:Uint8Array}|null>} null when the model
412
+ * has no external data (404) or the fetch fails.
413
+ */
414
+ export async function fetchOnnxExternalData(url, { fetchImpl = fetch, onProgress = null, force = false } = {}) {
415
+ const dataUrl = `${url}.data`;
416
+ const path = `${String(url).split("/").pop()}.data`;
417
+ try {
418
+ // Probe cheaply first: a 404 means the model has no external data.
419
+ const head = await fetchImpl(dataUrl, { method: "HEAD" }).catch(() => null);
420
+ if (head && !head.ok) return null;
421
+ const data = await fetchCachedBytes(dataUrl, { fetchImpl, onProgress, force, persist: true });
422
+ return { path, data };
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
238
428
  /**
239
429
  * Create a Laya decider.
240
430
  *
@@ -256,6 +446,7 @@ export async function createLayaDecider({
256
446
  tokenizerSubfolder = null,
257
447
  ort = null,
258
448
  transformers = null,
449
+ fetchImpl = fetch,
259
450
  wasmPaths = null,
260
451
  device = "auto",
261
452
  // (url, sessionOptions) => Promise<InferenceSession>. Hosts that need custom
@@ -283,21 +474,40 @@ export async function createLayaDecider({
283
474
  const report = (phase) => (p) =>
284
475
  onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
285
476
 
286
- const tokModel = tokenizerModel ?? model;
287
- const tokSubfolder = tokenizerSubfolder === null
288
- ? undefined
289
- : (tokenizerSubfolder ?? subfolder);
290
- const tok = await tf.AutoTokenizer.from_pretrained(tokModel, {
291
- revision: tokenizerModel ? undefined : revision,
292
- ...(tokSubfolder ? { subfolder: tokSubfolder } : {}),
293
- progress_callback: report("tokenizer"),
294
- });
477
+ let tok;
478
+ if (tokenizerModel) {
479
+ // Host-provided path/repo (local dir in Node, or a repo whose tokenizer
480
+ // lives at the root) — use the library loader.
481
+ const tokSubfolder = tokenizerSubfolder === null
482
+ ? undefined
483
+ : (tokenizerSubfolder ?? subfolder);
484
+ tok = await tf.AutoTokenizer.from_pretrained(tokenizerModel, {
485
+ revision: tokenizerSubfolder ? undefined : revision,
486
+ ...(tokSubfolder ? { subfolder: tokSubfolder } : {}),
487
+ progress_callback: report("tokenizer"),
488
+ });
489
+ } else {
490
+ // Default: the pinned web export keeps the tokenizer under `v1/`, which
491
+ // AutoTokenizer cannot address (no `subfolder` support) — load it directly.
492
+ tok = await loadLayaTokenizer({
493
+ urlBase: hfUrl(model, revision, `${subfolder}/`),
494
+ transformers: tf,
495
+ fetchImpl,
496
+ onProgress,
497
+ });
498
+ }
295
499
  const adapter = layaTokenizerAdapter(tok);
296
500
 
297
501
  const encUrl = encoderUrl ?? hfUrl(model, revision, `${subfolder}/encoder_q8.onnx`);
298
502
  const headUrlResolved = headUrl ?? hfUrl(model, revision, `${subfolder}/head_q8.onnx`);
299
503
  const createSession = sessionFactory
300
- ?? ((url, opts) => ortMod.InferenceSession.create(url, opts));
504
+ ?? (async (url, opts) => {
505
+ const report2 = (p) => onProgress?.(p);
506
+ const modelBytes = await fetchCachedBytes(url, { fetchImpl, onProgress: report2, persist: true });
507
+ const external = await fetchOnnxExternalData(url, { fetchImpl, onProgress: report2 });
508
+ const sessionOpts = external ? { ...opts, externalData: [external] } : opts;
509
+ return ortMod.InferenceSession.create(modelBytes, sessionOpts);
510
+ });
301
511
  const loadPair = async (providers) => {
302
512
  const opts = { executionProviders: providers, graphOptimizationLevel: "all" };
303
513
  const enc = await createSession(encUrl, { ...opts, progress_callback: report("encoder") });
@@ -341,22 +551,27 @@ export async function createLayaDecider({
341
551
  Object.entries(temperatureByOptions).map(([k, v]) => [k, clampTemperature(v)])),
342
552
  },
343
553
  async decide(state, questions) {
554
+ const t0 = now();
344
555
  const normalized = normalizeQuestions(questions);
345
556
  const items = normalized.map((q) =>
346
557
  buildLayaSequence({ encode: adapter.encode, ids: adapter.ids, state, question: q, maxLen, headMaxLen, maxPrefixes }));
347
558
  const b = collateLayaItems(items, adapter.pad);
348
559
 
560
+ const tEncode = now();
349
561
  const encOut = await encoder.run({
350
562
  input_ids: int64(b.inputIds, [b.n, b.L]),
351
563
  attention_mask: int64(b.attention, [b.n, b.L]),
352
564
  });
565
+ const encoderMs = now() - tEncode;
353
566
  const hidden = encOut.hidden ?? encOut.last_hidden_state ?? encOut[Object.keys(encOut)[0]];
354
567
 
355
568
  // The q8 web head is exported batch-1 only (despite dynamic batch
356
569
  // axes), so run it once per question over the shared encoder output.
357
570
  // The encoder — the expensive half — still runs as one batched pass.
358
571
  const headLogits = [];
572
+ const headPerQuestionMs = [];
359
573
  const markerCounts = items.map((it) => it.markers.length);
574
+ const tHead = now();
360
575
  for (let i = 0; i < b.n; i++) {
361
576
  const k = markerCounts[i];
362
577
  const row = hidden.data.subarray(i * b.L * hidden.dims[2], (i + 1) * b.L * hidden.dims[2]);
@@ -364,6 +579,7 @@ export async function createLayaDecider({
364
579
  const rowMask = b.attention.subarray(i * b.L, (i + 1) * b.L);
365
580
  const rowPos = b.markerPos.subarray(i * b.M, i * b.M + k);
366
581
  const rowMarkerMask = b.markerMask.subarray(i * b.M, i * b.M + k);
582
+ const tHeadTotal = now();
367
583
  const headOut = await head.run({
368
584
  hidden: rowHidden,
369
585
  attention_mask: int64(rowMask, [1, b.L]),
@@ -373,6 +589,7 @@ export async function createLayaDecider({
373
589
  });
374
590
  const rowLogits = headOut.logits ?? headOut[Object.keys(headOut)[0]];
375
591
  headLogits.push(...Array.from(rowLogits.data, Number));
592
+ headPerQuestionMs.push(Math.round(now() - tHeadTotal));
376
593
  }
377
594
 
378
595
  const answers = layaAnswersFromLogits({
@@ -386,11 +603,18 @@ export async function createLayaDecider({
386
603
  // Exact, human-readable view of what the encoder consumed: each
387
604
  // question's token sequence decoded back to text (special tokens kept).
388
605
  const prompts = items.map((it) => adapter.decode(it.ids));
606
+ const headMs = now() - tHead;
389
607
  return {
390
608
  answers,
391
609
  truncated: items.some((it) => it.ids.length >= maxLen),
392
610
  length,
393
611
  prompts,
612
+ timings: {
613
+ totalMs: Math.round(now() - t0),
614
+ encoderMs: Math.round(encoderMs),
615
+ headMs: Math.round(headMs),
616
+ headPerQuestionMs,
617
+ },
394
618
  };
395
619
  },
396
620
  async dispose() {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * registry.mjs — multi-family runtime for JEV-style typed-decision models.
3
+ *
4
+ * jev-web ships two families today (open-jev's fused DeBERTa graph and Laya's
5
+ * ModernBERT encoder + typed head) and any third party can add its own without
6
+ * touching core:
7
+ *
8
+ * registerDecisionFamily("my-model", {
9
+ * defaults: { model: "org/my-typed-decision-ONNX", revision: "main" },
10
+ * create: async (opts) => ({ info, decide(state, questions), dispose? }),
11
+ * });
12
+ *
13
+ * const runtime = await createDecisionRuntime({ family: "my-model" });
14
+ *
15
+ * `createDecisionRuntime` tries the requested family, then any `fallback`
16
+ * families in order, reporting each failure through `onFallback`. The returned
17
+ * object always exposes the shared contract:
18
+ * { info: { family, ... }, decide(state, questions) -> DecideResult, dispose? }
19
+ * where DecideResult is `{ answers, truncated, length, prompts?, timings? }`.
20
+ */
21
+
22
+ import { createDecider } from "./session.mjs";
23
+ import { DEFAULT_MODEL, DEFAULT_REVISION } from "./config.mjs";
24
+ import {
25
+ createLayaDecider,
26
+ LAYA_DEFAULT_MODEL,
27
+ LAYA_DEFAULT_REVISION,
28
+ LAYA_DEFAULT_SUBFOLDER,
29
+ } from "./laya.mjs";
30
+
31
+ const families = new Map();
32
+
33
+ /**
34
+ * Register (or replace) a decision family.
35
+ * @param {string} id short stable identifier, e.g. "open-jev"
36
+ * @param {{create:(opts:object)=>Promise<object>, defaults?:object}} descriptor
37
+ */
38
+ export function registerDecisionFamily(id, descriptor) {
39
+ if (!id || typeof id !== "string") throw new TypeError("registerDecisionFamily: id is required");
40
+ if (!descriptor || typeof descriptor.create !== "function") {
41
+ throw new TypeError(`registerDecisionFamily(${id}): descriptor.create is required`);
42
+ }
43
+ families.set(id, { defaults: {}, ...descriptor });
44
+ return id;
45
+ }
46
+
47
+ /** @returns {string[]} registered family ids in registration order. */
48
+ export function listDecisionFamilies() {
49
+ return [...families.keys()];
50
+ }
51
+
52
+ export function getDecisionFamily(id) {
53
+ return families.get(id) ?? null;
54
+ }
55
+
56
+ registerDecisionFamily("open-jev", {
57
+ defaults: { model: DEFAULT_MODEL, revision: DEFAULT_REVISION },
58
+ create: createDecider,
59
+ });
60
+
61
+ registerDecisionFamily("laya", {
62
+ defaults: {
63
+ model: LAYA_DEFAULT_MODEL,
64
+ revision: LAYA_DEFAULT_REVISION,
65
+ subfolder: LAYA_DEFAULT_SUBFOLDER,
66
+ },
67
+ create: createLayaDecider,
68
+ });
69
+
70
+ /**
71
+ * Create a decision runtime, preferring `family` and falling back in order.
72
+ * @param {{family?:string, fallback?:string[], onFallback?:Function}} [opts]
73
+ * @returns {Promise<{info:object, decide:Function, dispose?:Function}>}
74
+ */
75
+ export async function createDecisionRuntime({
76
+ family = "laya",
77
+ fallback = [],
78
+ onFallback = null,
79
+ ...opts
80
+ } = {}) {
81
+ const order = [family, ...(Array.isArray(fallback) ? fallback : [])].filter(Boolean);
82
+ let lastError = null;
83
+ for (const id of order) {
84
+ const descriptor = families.get(id);
85
+ if (!descriptor) {
86
+ lastError = new Error(`unknown decision family "${id}"`);
87
+ onFallback?.({ family: id, error: lastError });
88
+ continue;
89
+ }
90
+ try {
91
+ const runtime = await descriptor.create({ ...descriptor.defaults, ...opts });
92
+ return {
93
+ ...runtime,
94
+ info: { family: id, ...(runtime?.info ?? {}) },
95
+ };
96
+ } catch (error) {
97
+ lastError = error;
98
+ onFallback?.({ family: id, error });
99
+ }
100
+ }
101
+ throw lastError ?? new Error("createDecisionRuntime: no family could be created");
102
+ }
package/src/session.mjs CHANGED
@@ -5,6 +5,8 @@
5
5
  // builds). Everything heavy stays out of module scope.
6
6
 
7
7
  import { normalizeQuestions } from "./questions.mjs";
8
+
9
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
8
10
  import { buildDecisionInput, typedMarkerIds } from "./encode.mjs";
9
11
  import { answersFromScores } from "./answers.mjs";
10
12
  import {
@@ -77,6 +79,7 @@ export async function createDecider({
77
79
 
78
80
  return {
79
81
  info: {
82
+ family: "open-jev",
80
83
  model,
81
84
  revision,
82
85
  device: resolvedDevice,
@@ -88,6 +91,7 @@ export async function createDecider({
88
91
  configSource: remote.source,
89
92
  },
90
93
  async decide(state, questions) {
94
+ const t0 = now();
91
95
  const normalized = normalizeQuestions(questions);
92
96
  const tokenized = normalized.map((q) => ({
93
97
  type: q.type,
@@ -109,7 +113,9 @@ export async function createDecider({
109
113
  pair_q: i64(input.pairQ, [1, input.pairQ.length]),
110
114
  pair_opt: i64(input.pairOpt, [1, input.pairOpt.length]),
111
115
  };
116
+ const tInfer = now();
112
117
  const out = await net(feeds);
118
+ const inferMs = now() - tInfer;
113
119
  const logits = out?.logits;
114
120
  if (!logits) throw new Error("model output has no `logits` — not a typed-decision export?");
115
121
  const scores = Array.from(logits.to("float32").data, Number);
@@ -125,7 +131,13 @@ export async function createDecider({
125
131
  } catch {
126
132
  prompt = input.inputIds.join(" ");
127
133
  }
128
- return { answers, truncated: input.truncated, length: input.length, prompts: [prompt] };
134
+ return {
135
+ answers,
136
+ truncated: input.truncated,
137
+ length: input.length,
138
+ prompts: [prompt],
139
+ timings: { totalMs: Math.round(now() - t0), inferMs: Math.round(inferMs) },
140
+ };
129
141
  },
130
142
  async decideMany(cases) {
131
143
  const out = [];