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 +30 -0
- package/package.json +1 -1
- package/src/index.mjs +4 -0
- package/src/laya.mjs +234 -10
- 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,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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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) =>
|
|
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() {
|
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 = [];
|