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 +30 -0
- package/package.json +1 -1
- package/src/index.mjs +4 -0
- package/src/laya.mjs +102 -9
- package/src/registry.mjs +102 -0
- package/src/session.mjs +13 -1
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
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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() {
|
package/src/registry.mjs
ADDED
|
@@ -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 {
|
|
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 = [];
|