jev-web 0.2.1 → 0.3.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
@@ -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.0",
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,69 @@ 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 res = await fetchImpl(url);
215
+ if (!res.ok) throw new Error(`tokenizer ${file}: HTTP ${res.status}`);
216
+ const total = Number(res.headers?.get?.("content-length")) || 0;
217
+ if (onProgress && res.body?.getReader) {
218
+ const reader = res.body.getReader();
219
+ const chunks = [];
220
+ let loaded = 0;
221
+ for (;;) {
222
+ const { done, value } = await reader.read();
223
+ if (done) break;
224
+ chunks.push(value);
225
+ loaded += value.length;
226
+ onProgress({ phase: "tokenizer", file, loaded, total: total || null });
227
+ }
228
+ const buf = new Uint8Array(loaded);
229
+ let off = 0;
230
+ for (const c of chunks) { buf.set(c, off); off += c.length; }
231
+ return JSON.parse(new TextDecoder().decode(buf));
232
+ }
233
+ return res.json();
234
+ }
235
+
236
+ /**
237
+ * Load a Laya tokenizer directly from its files.
238
+ *
239
+ * transformers.js `AutoTokenizer.from_pretrained` ignores `subfolder`, and the
240
+ * Laya web export keeps its tokenizer under `v1/` (and its root has no
241
+ * `tokenizer_config.json`), so the library path throws
242
+ * "Cannot read properties of undefined (reading 'tokenizer_class')".
243
+ * Fetch the two JSON files ourselves and instantiate the class the config
244
+ * names (e.g. `PreTrainedTokenizerFast` -> `PreTrainedTokenizer`).
245
+ *
246
+ * @param {{urlBase:string, fetchImpl?:Function, transformers?:object,
247
+ * onProgress?:Function}} spec `urlBase` must end with "/".
248
+ * @returns {Promise<object>} tokenizer instance
249
+ */
250
+ export async function loadLayaTokenizer({
251
+ urlBase,
252
+ fetchImpl = fetch,
253
+ transformers = null,
254
+ onProgress = null,
255
+ } = {}) {
256
+ if (!urlBase) throw new TypeError("loadLayaTokenizer: urlBase is required");
257
+ const base = urlBase.endsWith("/") ? urlBase : `${urlBase}/`;
258
+ const [tokenizerJSON, tokenizerConfig] = await Promise.all([
259
+ fetchJson(`${base}tokenizer.json`, "tokenizer.json", { fetchImpl, onProgress }),
260
+ fetchJson(`${base}tokenizer_config.json`, "tokenizer_config.json", { fetchImpl, onProgress }),
261
+ ]);
262
+ const tf = transformers ?? (await import("@huggingface/transformers"));
263
+ const className = String(tokenizerConfig?.tokenizer_class ?? "PreTrainedTokenizer")
264
+ .replace(/Fast$/, "");
265
+ const Cls = tf[className] ?? tf.PreTrainedTokenizer;
266
+ if (typeof Cls !== "function") {
267
+ throw new Error(`loadLayaTokenizer: no tokenizer class for "${className}"`);
268
+ }
269
+ return new Cls(tokenizerJSON, tokenizerConfig);
270
+ }
271
+
209
272
  /** Wrap a transformers.js tokenizer into the tiny adapter laya needs. */
210
273
  export function layaTokenizerAdapter(tok) {
211
274
  const idOf = (prop, text) => {
@@ -232,6 +295,8 @@ export function layaTokenizerAdapter(tok) {
232
295
  };
233
296
  }
234
297
 
298
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
299
+
235
300
  const hfUrl = (model, revision, file) =>
236
301
  `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
237
302
 
@@ -256,6 +321,7 @@ export async function createLayaDecider({
256
321
  tokenizerSubfolder = null,
257
322
  ort = null,
258
323
  transformers = null,
324
+ fetchImpl = fetch,
259
325
  wasmPaths = null,
260
326
  device = "auto",
261
327
  // (url, sessionOptions) => Promise<InferenceSession>. Hosts that need custom
@@ -283,15 +349,28 @@ export async function createLayaDecider({
283
349
  const report = (phase) => (p) =>
284
350
  onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
285
351
 
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
- });
352
+ let tok;
353
+ if (tokenizerModel) {
354
+ // Host-provided path/repo (local dir in Node, or a repo whose tokenizer
355
+ // lives at the root) — use the library loader.
356
+ const tokSubfolder = tokenizerSubfolder === null
357
+ ? undefined
358
+ : (tokenizerSubfolder ?? subfolder);
359
+ tok = await tf.AutoTokenizer.from_pretrained(tokenizerModel, {
360
+ revision: tokenizerSubfolder ? undefined : revision,
361
+ ...(tokSubfolder ? { subfolder: tokSubfolder } : {}),
362
+ progress_callback: report("tokenizer"),
363
+ });
364
+ } else {
365
+ // Default: the pinned web export keeps the tokenizer under `v1/`, which
366
+ // AutoTokenizer cannot address (no `subfolder` support) — load it directly.
367
+ tok = await loadLayaTokenizer({
368
+ urlBase: hfUrl(model, revision, `${subfolder}/`),
369
+ transformers: tf,
370
+ fetchImpl,
371
+ onProgress,
372
+ });
373
+ }
295
374
  const adapter = layaTokenizerAdapter(tok);
296
375
 
297
376
  const encUrl = encoderUrl ?? hfUrl(model, revision, `${subfolder}/encoder_q8.onnx`);
@@ -341,22 +420,27 @@ export async function createLayaDecider({
341
420
  Object.entries(temperatureByOptions).map(([k, v]) => [k, clampTemperature(v)])),
342
421
  },
343
422
  async decide(state, questions) {
423
+ const t0 = now();
344
424
  const normalized = normalizeQuestions(questions);
345
425
  const items = normalized.map((q) =>
346
426
  buildLayaSequence({ encode: adapter.encode, ids: adapter.ids, state, question: q, maxLen, headMaxLen, maxPrefixes }));
347
427
  const b = collateLayaItems(items, adapter.pad);
348
428
 
429
+ const tEncode = now();
349
430
  const encOut = await encoder.run({
350
431
  input_ids: int64(b.inputIds, [b.n, b.L]),
351
432
  attention_mask: int64(b.attention, [b.n, b.L]),
352
433
  });
434
+ const encoderMs = now() - tEncode;
353
435
  const hidden = encOut.hidden ?? encOut.last_hidden_state ?? encOut[Object.keys(encOut)[0]];
354
436
 
355
437
  // The q8 web head is exported batch-1 only (despite dynamic batch
356
438
  // axes), so run it once per question over the shared encoder output.
357
439
  // The encoder — the expensive half — still runs as one batched pass.
358
440
  const headLogits = [];
441
+ const headPerQuestionMs = [];
359
442
  const markerCounts = items.map((it) => it.markers.length);
443
+ const tHead = now();
360
444
  for (let i = 0; i < b.n; i++) {
361
445
  const k = markerCounts[i];
362
446
  const row = hidden.data.subarray(i * b.L * hidden.dims[2], (i + 1) * b.L * hidden.dims[2]);
@@ -364,6 +448,7 @@ export async function createLayaDecider({
364
448
  const rowMask = b.attention.subarray(i * b.L, (i + 1) * b.L);
365
449
  const rowPos = b.markerPos.subarray(i * b.M, i * b.M + k);
366
450
  const rowMarkerMask = b.markerMask.subarray(i * b.M, i * b.M + k);
451
+ const tHeadTotal = now();
367
452
  const headOut = await head.run({
368
453
  hidden: rowHidden,
369
454
  attention_mask: int64(rowMask, [1, b.L]),
@@ -373,6 +458,7 @@ export async function createLayaDecider({
373
458
  });
374
459
  const rowLogits = headOut.logits ?? headOut[Object.keys(headOut)[0]];
375
460
  headLogits.push(...Array.from(rowLogits.data, Number));
461
+ headPerQuestionMs.push(Math.round(now() - tHeadTotal));
376
462
  }
377
463
 
378
464
  const answers = layaAnswersFromLogits({
@@ -386,11 +472,18 @@ export async function createLayaDecider({
386
472
  // Exact, human-readable view of what the encoder consumed: each
387
473
  // question's token sequence decoded back to text (special tokens kept).
388
474
  const prompts = items.map((it) => adapter.decode(it.ids));
475
+ const headMs = now() - tHead;
389
476
  return {
390
477
  answers,
391
478
  truncated: items.some((it) => it.ids.length >= maxLen),
392
479
  length,
393
480
  prompts,
481
+ timings: {
482
+ totalMs: Math.round(now() - t0),
483
+ encoderMs: Math.round(encoderMs),
484
+ headMs: Math.round(headMs),
485
+ headPerQuestionMs,
486
+ },
394
487
  };
395
488
  },
396
489
  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 = [];