jev-web 0.2.0 → 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.0",
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) => {
@@ -216,6 +279,13 @@ export function layaTokenizerAdapter(tok) {
216
279
  };
217
280
  return {
218
281
  encode: (text) => Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number),
282
+ decode: (ids) => {
283
+ try {
284
+ return tok.decode(Array.from(ids), { skip_special_tokens: false });
285
+ } catch {
286
+ return Array.from(ids).join(" ");
287
+ }
288
+ },
219
289
  ids: {
220
290
  cls: idOf("cls_token_id", "[CLS]"),
221
291
  sep: idOf("sep_token_id", "[SEP]"),
@@ -225,6 +295,8 @@ export function layaTokenizerAdapter(tok) {
225
295
  };
226
296
  }
227
297
 
298
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
299
+
228
300
  const hfUrl = (model, revision, file) =>
229
301
  `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
230
302
 
@@ -249,6 +321,7 @@ export async function createLayaDecider({
249
321
  tokenizerSubfolder = null,
250
322
  ort = null,
251
323
  transformers = null,
324
+ fetchImpl = fetch,
252
325
  wasmPaths = null,
253
326
  device = "auto",
254
327
  // (url, sessionOptions) => Promise<InferenceSession>. Hosts that need custom
@@ -276,15 +349,28 @@ export async function createLayaDecider({
276
349
  const report = (phase) => (p) =>
277
350
  onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
278
351
 
279
- const tokModel = tokenizerModel ?? model;
280
- const tokSubfolder = tokenizerSubfolder === null
281
- ? undefined
282
- : (tokenizerSubfolder ?? subfolder);
283
- const tok = await tf.AutoTokenizer.from_pretrained(tokModel, {
284
- revision: tokenizerModel ? undefined : revision,
285
- ...(tokSubfolder ? { subfolder: tokSubfolder } : {}),
286
- progress_callback: report("tokenizer"),
287
- });
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
+ }
288
374
  const adapter = layaTokenizerAdapter(tok);
289
375
 
290
376
  const encUrl = encoderUrl ?? hfUrl(model, revision, `${subfolder}/encoder_q8.onnx`);
@@ -334,22 +420,27 @@ export async function createLayaDecider({
334
420
  Object.entries(temperatureByOptions).map(([k, v]) => [k, clampTemperature(v)])),
335
421
  },
336
422
  async decide(state, questions) {
423
+ const t0 = now();
337
424
  const normalized = normalizeQuestions(questions);
338
425
  const items = normalized.map((q) =>
339
426
  buildLayaSequence({ encode: adapter.encode, ids: adapter.ids, state, question: q, maxLen, headMaxLen, maxPrefixes }));
340
427
  const b = collateLayaItems(items, adapter.pad);
341
428
 
429
+ const tEncode = now();
342
430
  const encOut = await encoder.run({
343
431
  input_ids: int64(b.inputIds, [b.n, b.L]),
344
432
  attention_mask: int64(b.attention, [b.n, b.L]),
345
433
  });
434
+ const encoderMs = now() - tEncode;
346
435
  const hidden = encOut.hidden ?? encOut.last_hidden_state ?? encOut[Object.keys(encOut)[0]];
347
436
 
348
437
  // The q8 web head is exported batch-1 only (despite dynamic batch
349
438
  // axes), so run it once per question over the shared encoder output.
350
439
  // The encoder — the expensive half — still runs as one batched pass.
351
440
  const headLogits = [];
441
+ const headPerQuestionMs = [];
352
442
  const markerCounts = items.map((it) => it.markers.length);
443
+ const tHead = now();
353
444
  for (let i = 0; i < b.n; i++) {
354
445
  const k = markerCounts[i];
355
446
  const row = hidden.data.subarray(i * b.L * hidden.dims[2], (i + 1) * b.L * hidden.dims[2]);
@@ -357,6 +448,7 @@ export async function createLayaDecider({
357
448
  const rowMask = b.attention.subarray(i * b.L, (i + 1) * b.L);
358
449
  const rowPos = b.markerPos.subarray(i * b.M, i * b.M + k);
359
450
  const rowMarkerMask = b.markerMask.subarray(i * b.M, i * b.M + k);
451
+ const tHeadTotal = now();
360
452
  const headOut = await head.run({
361
453
  hidden: rowHidden,
362
454
  attention_mask: int64(rowMask, [1, b.L]),
@@ -366,6 +458,7 @@ export async function createLayaDecider({
366
458
  });
367
459
  const rowLogits = headOut.logits ?? headOut[Object.keys(headOut)[0]];
368
460
  headLogits.push(...Array.from(rowLogits.data, Number));
461
+ headPerQuestionMs.push(Math.round(now() - tHeadTotal));
369
462
  }
370
463
 
371
464
  const answers = layaAnswersFromLogits({
@@ -376,7 +469,22 @@ export async function createLayaDecider({
376
469
  temperatureByOptions,
377
470
  });
378
471
  const length = Number(b.attention.reduce((a, v) => a + v, 0n));
379
- return { answers, truncated: items.some((it) => it.ids.length >= maxLen), length };
472
+ // Exact, human-readable view of what the encoder consumed: each
473
+ // question's token sequence decoded back to text (special tokens kept).
474
+ const prompts = items.map((it) => adapter.decode(it.ids));
475
+ const headMs = now() - tHead;
476
+ return {
477
+ answers,
478
+ truncated: items.some((it) => it.ids.length >= maxLen),
479
+ length,
480
+ prompts,
481
+ timings: {
482
+ totalMs: Math.round(now() - t0),
483
+ encoderMs: Math.round(encoderMs),
484
+ headMs: Math.round(headMs),
485
+ headPerQuestionMs,
486
+ },
487
+ };
380
488
  },
381
489
  async dispose() {
382
490
  try { await encoder.release?.(); } catch { /* optional */ }
@@ -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);
@@ -118,7 +124,20 @@ export async function createDecider({
118
124
  groups: input.groups,
119
125
  temperature: effective.temperature,
120
126
  });
121
- return { answers, truncated: input.truncated, length: input.length };
127
+ // Exact view of the single fused sequence the graph consumed.
128
+ let prompt;
129
+ try {
130
+ prompt = tokenizer.decode(input.inputIds, { skip_special_tokens: false });
131
+ } catch {
132
+ prompt = input.inputIds.join(" ");
133
+ }
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
+ };
122
141
  },
123
142
  async decideMany(cases) {
124
143
  const out = [];