jev-web 0.2.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 ADDED
@@ -0,0 +1,171 @@
1
+ # jev-web
2
+
3
+ Run **open-jev-shaped typed-decision models** in the browser. One *state* text
4
+ plus any number of typed *questions* (`choice` / `score` / `noul`) go in; a
5
+ calibrated probability distribution per question comes back from a single
6
+ forward pass. No server, no API keys — weights and inference stay on-device.
7
+
8
+ This is a generic runtime, not a classifier for any particular domain. You
9
+ bring the state text and the questions; the model answers by choosing among
10
+ the options you give it.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i jev-web @huggingface/transformers
16
+ ```
17
+
18
+ `@huggingface/transformers` is an optional peer dependency (it is imported
19
+ lazily). Hosts that already ship their own copy can inject it via
20
+ `createDecider({ transformers })`.
21
+
22
+ ## Use
23
+
24
+ ```js
25
+ import { createDecider } from "jev-web";
26
+
27
+ const decider = await createDecider({
28
+ // defaults to the reference open-jev DeBERTa-v3-large ONNX export,
29
+ // pinned by revision; any repo with the same typed-decision graph works
30
+ onProgress: (p) => console.log(p.phase, p.file, p.progress),
31
+ });
32
+
33
+ const { answers } = await decider.decide(
34
+ "I was charged twice for the same order and nobody answers my emails.",
35
+ [
36
+ {
37
+ type: "choice",
38
+ instructions: "Which product area is the message about?",
39
+ options: ["fees & charges", "refund & dispute", "card", "other"],
40
+ },
41
+ { type: "noul", instructions: "The customer is asking for a refund." },
42
+ {
43
+ type: "score",
44
+ instructions: "How negative is the message?",
45
+ options: ["very negative", "negative", "neutral", "positive", "very positive"],
46
+ },
47
+ ],
48
+ );
49
+
50
+ // [
51
+ // { type: "choice", choice: "refund & dispute", index: 1, probabilities: {...}, confidence: 0.61 },
52
+ // { type: "noul", noul: 0.93, probabilities: { no: 0.07, yes: 0.93 }, confidence: 0.93 },
53
+ // { type: "score", score: 0.4, level: 1, probabilities: {...}, confidence: 0.55 },
54
+ // ]
55
+ ```
56
+
57
+ ## Question types
58
+
59
+ | type | options | answer |
60
+ |---|---|---|
61
+ | `choice` | 2–255 labels | `choice` (argmax), `probabilities`, `confidence` |
62
+ | `score` | 2–10 ordered levels | `score` (expected level index, may fall between levels) |
63
+ | `noul` | exactly 2 (defaults `["no","yes"]`) | `noul` (p of the second option = p(yes)), `confidence` |
64
+
65
+ Every answer carries the full distribution. `decide()` also returns
66
+ `{ truncated, length }` — the state is capped (256 tokens by default) and the
67
+ flag tells you when it was cut.
68
+
69
+ ## Backends and quantization
70
+
71
+ `device` / `dtype` default to `auto`: WebGPU → `q4f16`, otherwise WASM → `q4`.
72
+ Override explicitly (`fp16`, `fp32`, `q8`, …) when accuracy matters more than
73
+ download size. First load downloads the weights once; transformers.js caches
74
+ them (Cache API / IndexedDB) so later visits are offline-fast.
75
+
76
+ `decider.info` reports the resolved `{ model, revision, device, dtype,
77
+ temperature, maxStateTokens, maxLen, pool, configSource }`.
78
+
79
+ ## Model config
80
+
81
+ Defaults come from the model repo's `open_jev_config.json` when present
82
+ (temperature, marker tokens, state cap), else the published reference values.
83
+ To use a different typed-decision export:
84
+
85
+ ```js
86
+ createDecider({ model: "your-org/your-typed-decision-ONNX", revision: "main" });
87
+ ```
88
+
89
+ The export must add the `[STATE]`, `[Q]`, `[OPT]` markers to its tokenizer,
90
+ take `input_ids, attention_mask, seg, pair_q, pair_opt`, and return per-pair
91
+ `logits`. `typedMarkerIds()` fails loudly when a repo is missing them.
92
+
93
+
94
+ ## Laya family (ModernBERT encoder + typed head)
95
+
96
+ `createLayaDecider(options)` runs the [Laya](https://huggingface.co/convaiinnovations/laya)
97
+ System-1 decision models, whose browser export is a **two-file q8 ONNX split**
98
+ (encoder + head) rather than one fused graph:
99
+
100
+ ```js
101
+ import { createLayaDecider } from "jev-web";
102
+
103
+ const decider = await createLayaDecider({
104
+ // defaults to alfred361/laya-typed-decisions-web-q8 @ pinned revision
105
+ wasmPaths: "/assets/", // self-hosted ORT wasm (optional)
106
+ onProgress: (p) => console.log(p.phase, p.loaded, p.total),
107
+ });
108
+ const { answers } = await decider.decide(stateText, questions);
109
+ ```
110
+
111
+ Protocol implemented here (mirrors the reference `laya` Python runtime):
112
+
113
+ - sequence `[CLS] <type> question: <instructions> [SEP] [MASK]opt0 … [SEP] <state> [SEP]`,
114
+ `marker_pos` at each option's `[MASK]`, `qtype` 0 choice / 1 score / 2 noul;
115
+ - encoder `input_ids, attention_mask → hidden`; head
116
+ `hidden, attention_mask, marker_pos, marker_mask, qtype → logits`;
117
+ - per-question calibration from `rl_agent_config.json`
118
+ (`temperature` + `temperature_by_options` buckets, clamped to [0.5, 5]).
119
+
120
+ Notes:
121
+ - the q8 web head is **batch-1 only**, so `decide()` batches the encoder and
122
+ runs the head once per question;
123
+ - `choice` supports at most `max_prefixes` options (6 for the typed-decisions
124
+ export);
125
+ - weights download once (~520 MB q8) and are browser-cached.
126
+
127
+ Lower-level helpers are exported for custom runtimes:
128
+ `buildLayaSequence`, `collateLayaItems`, `layaAnswersFromLogits`,
129
+ `layaTokenizerAdapter`, `renderLayaOptions`, `layaTempBucket`, `clampTemperature`.
130
+
131
+ ## Worker / thread
132
+
133
+ `jev-web` does not bundle a worker, so hosts can create one the way their
134
+ bundler prefers:
135
+
136
+ ```js
137
+ // host code
138
+ const worker = new Worker(new URL("./my-worker.mjs", import.meta.url), { type: "module" });
139
+ // my-worker.mjs
140
+ import { createDecider } from "jev-web";
141
+ ```
142
+
143
+ ## API
144
+
145
+ - `createDecider(options)` → open-jev family: `{ info, decide, decideMany, dispose }`
146
+ - `createLayaDecider(options)` → Laya family: `{ info, decide, dispose }` (same `decide` contract)
147
+ - `normalizeQuestions`, `buildDecisionInput`, `typedMarkerIds`, `answersFromScores`,
148
+ `softmaxWithTemperature`, `resolveDevice`, `resolveDtype`, `detectWebGPU`,
149
+ `loadModelConfig`, defaults (model/revision/temperature/caps).
150
+
151
+ ## Playground
152
+
153
+ ```bash
154
+ npm run dev # packages/jev-web → http://localhost:5188
155
+ ```
156
+
157
+ Generic presets plus a paste-your-own state/questions box, download progress,
158
+ latency, and a model-cache reset.
159
+
160
+ ## Limits
161
+
162
+ - Typed-decision models answer **only** by picking among your options; they do
163
+ not explain or generate text.
164
+ - Accuracy is domain-bound: questions and states unlike the model's training
165
+ domain are out of distribution. Measure before relying on any answer.
166
+ - English, 512-token sequence (state capped), one state per forward pass.
167
+
168
+ ## License
169
+
170
+ MIT (runtime code). Model weights keep their own license (the reference export
171
+ is Apache-2.0).
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "jev-web",
3
+ "version": "0.2.0",
4
+ "description": "Run open-jev- and Laya-shaped typed-decision models in the browser: one state plus typed questions in, calibrated probabilities out.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "open-jev",
16
+ "typed-decisions",
17
+ "onnx",
18
+ "transformers.js",
19
+ "webgpu",
20
+ "browser",
21
+ "classification"
22
+ ],
23
+ "scripts": {
24
+ "dev": "vite",
25
+ "build:dev": "vite build",
26
+ "test": "node --test test/*.test.mjs"
27
+ },
28
+ "peerDependencies": {
29
+ "@huggingface/transformers": ">=3.0.0",
30
+ "onnxruntime-web": ">=1.17.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@huggingface/transformers": {
34
+ "optional": true
35
+ },
36
+ "onnxruntime-web": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@huggingface/transformers": "^4.3.0",
42
+ "vite": "^6.3.5"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ }
47
+ }
@@ -0,0 +1,36 @@
1
+ // jev-web — score → answer decoding for typed-decision models.
2
+ //
3
+ // The graph returns one logit per (question, option) pair. Each question's
4
+ // options form a softmax group; a post-hoc temperature (fitted by the model
5
+ // authors) calibrates the distribution.
6
+
7
+ export function softmaxWithTemperature(logits, temperature = 1) {
8
+ const t = Number.isFinite(temperature) && temperature > 0 ? temperature : 1;
9
+ if (!Array.isArray(logits) || logits.length === 0) throw new TypeError("logits must be a non-empty array");
10
+ const max = Math.max(...logits);
11
+ const exps = logits.map((x) => Math.exp((Number(x) - max) / t));
12
+ const sum = exps.reduce((a, b) => a + b, 0);
13
+ return exps.map((e) => e / sum);
14
+ }
15
+
16
+ export function answersFromScores(scores, { questions, groups, temperature = 1 }) {
17
+ if (!Array.isArray(scores)) throw new TypeError("scores must be an array");
18
+ return questions.map((q, i) => {
19
+ const pairIndices = groups[i];
20
+ const probs = softmaxWithTemperature(pairIndices.map((p) => scores[p]), temperature);
21
+ const probabilities = Object.fromEntries(q.options.map((option, j) => [option, probs[j]]));
22
+ const confidence = Math.max(...probs);
23
+ const best = probs.indexOf(confidence);
24
+
25
+ if (q.type === "choice") {
26
+ return { type: "choice", choice: q.options[best], index: best, probabilities, confidence };
27
+ }
28
+ if (q.type === "score") {
29
+ const expected = probs.reduce((acc, p, j) => acc + p * j, 0);
30
+ return { type: "score", score: expected, level: best, probabilities, confidence };
31
+ }
32
+ // noul: probabilities are { no, yes }; the answer is p(yes).
33
+ const yes = probabilities[q.options[1]];
34
+ return { type: "noul", noul: yes, probabilities, confidence: Math.max(yes, 1 - yes) };
35
+ });
36
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,65 @@
1
+ // jev-web — model defaults, backend resolution, and model-side config.
2
+ //
3
+ // Defaults point at the reference ONNX export of com-kotobalabs/open-jev-
4
+ // deberta-v3-large (Apache-2.0), pinned by revision for reproducibility. Any
5
+ // repo with the same typed-decision graph layout works: pass `model` and
6
+ // optionally `revision`.
7
+
8
+ export const DEFAULT_MODEL = "onnx-community/open-jev-deberta-v3-large-ONNX";
9
+ export const DEFAULT_REVISION = "7c79f25b5ac496089f448a969c801872ad59d31c";
10
+ export const DEFAULT_TEMPERATURE = 1.05;
11
+ export const DEFAULT_MARKERS = ["[STATE]", "[Q]", "[OPT]"];
12
+ export const DEFAULT_MAX_STATE_TOKENS = 256;
13
+ export const DEFAULT_MAX_LEN = 512;
14
+
15
+ const HF_BASE = "https://huggingface.co";
16
+
17
+ export function resolveDevice({ device = "auto", webgpu = false } = {}) {
18
+ if (device && device !== "auto") return device;
19
+ return webgpu ? "webgpu" : "wasm";
20
+ }
21
+
22
+ // Quantized WebGPU kernels are f16-friendly (q4f16); WASM wants the
23
+ // non-f16 q4 build. fp32/fp16/q8 stay explicit opt-ins.
24
+ export function resolveDtype(device, dtype = "auto") {
25
+ if (dtype && dtype !== "auto") return dtype;
26
+ return device === "webgpu" ? "q4f16" : "q4";
27
+ }
28
+
29
+ export async function detectWebGPU(scope = globalThis) {
30
+ try {
31
+ if (!scope?.navigator?.gpu?.requestAdapter) return false;
32
+ return !!(await scope.navigator.gpu.requestAdapter());
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // Read the model's own calibration/marker config when the repo ships one
39
+ // (open_jev_config.json), else fall back to the published reference values.
40
+ export async function loadModelConfig({ model = DEFAULT_MODEL, revision = DEFAULT_REVISION, fetchImpl = fetch } = {}) {
41
+ const fallback = {
42
+ temperature: DEFAULT_TEMPERATURE,
43
+ markers: [...DEFAULT_MARKERS],
44
+ maxStateTokens: DEFAULT_MAX_STATE_TOKENS,
45
+ maxLen: DEFAULT_MAX_LEN,
46
+ pool: "span",
47
+ source: "defaults",
48
+ };
49
+ const url = `${HF_BASE}/${model}/resolve/${revision ? `${revision}/` : ""}open_jev_config.json`;
50
+ try {
51
+ const res = await fetchImpl(url, { cache: "no-store" });
52
+ if (!res.ok) return fallback;
53
+ const cfg = await res.json();
54
+ return {
55
+ temperature: Number.isFinite(Number(cfg.temperature)) ? Number(cfg.temperature) : fallback.temperature,
56
+ markers: Array.isArray(cfg.markers) && cfg.markers.length === 3 ? cfg.markers.map(String) : fallback.markers,
57
+ maxStateTokens: Number.isFinite(Number(cfg.max_state_tokens)) ? Number(cfg.max_state_tokens) : fallback.maxStateTokens,
58
+ maxLen: Number.isFinite(Number(cfg.max_len)) ? Number(cfg.max_len) : fallback.maxLen,
59
+ pool: typeof cfg.pool === "string" ? cfg.pool : fallback.pool,
60
+ source: "model",
61
+ };
62
+ } catch {
63
+ return fallback;
64
+ }
65
+ }
package/src/encode.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // jev-web — pure input builder for open-jev-shaped models.
2
+ //
3
+ // Layout produced (mirrors the reference export):
4
+ // [CLS] [STATE] state [Q] instructions [OPT] option … [SEP]
5
+ //
6
+ // `seg` carries a span slot per token: option tokens get their pair index,
7
+ // question-instruction tokens get `totalPairs + question index`, everything
8
+ // else -1. `pair_q` / `pair_opt` list the question slot and pair slot for
9
+ // every (question, option) pair; `groups` groups pair indices per question.
10
+ // The ONNX graph consumes these directly (see the model's export docs).
11
+
12
+ export class DecisionInputTooLongError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "DecisionInputTooLongError";
16
+ }
17
+ }
18
+
19
+ // Runtime-independent: take pre-tokenized ids and lay out the sequence.
20
+ export function buildDecisionInput({
21
+ stateIds,
22
+ markers, // { cls, sep, state, question, option } single token ids
23
+ questions, // [{ type, instructionIds: number[], optionIds: number[][] }]
24
+ maxStateTokens = 256,
25
+ maxLen = 512,
26
+ }) {
27
+ if (!Array.isArray(stateIds)) throw new TypeError("stateIds must be an array of token ids");
28
+ if (!Array.isArray(questions) || questions.length === 0) {
29
+ throw new TypeError("questions must be a non-empty array of tokenized questions");
30
+ }
31
+ for (const key of ["cls", "sep", "state", "question", "option"]) {
32
+ if (!Number.isInteger(markers?.[key])) throw new TypeError(`markers.${key} must be a token id`);
33
+ }
34
+
35
+ const truncated = stateIds.length > maxStateTokens;
36
+ const state = truncated ? stateIds.slice(0, maxStateTokens) : stateIds;
37
+
38
+ const inputIds = [markers.cls, markers.state, ...state];
39
+ const seg = inputIds.map(() => -1);
40
+ const pairQ = [];
41
+ const pairOpt = [];
42
+ const groups = [];
43
+ const totalPairs = questions.reduce((n, q) => n + q.optionIds.length, 0);
44
+
45
+ questions.forEach((q, qi) => {
46
+ inputIds.push(markers.question, ...q.instructionIds);
47
+ seg.push(-1, ...q.instructionIds.map(() => totalPairs + qi));
48
+ groups.push(
49
+ q.optionIds.map((ids) => {
50
+ inputIds.push(markers.option, ...ids);
51
+ seg.push(-1, ...ids.map(() => pairOpt.length));
52
+ pairQ.push(totalPairs + qi);
53
+ pairOpt.push(pairOpt.length);
54
+ return pairOpt.length - 1;
55
+ }),
56
+ );
57
+ });
58
+ inputIds.push(markers.sep);
59
+ seg.push(-1);
60
+
61
+ if (inputIds.length > maxLen) {
62
+ throw new DecisionInputTooLongError(
63
+ `decision input is ${inputIds.length} tokens, over the ${maxLen}-token limit ` +
64
+ `(state capped at ${maxStateTokens}) — shorten the questions or the state`,
65
+ );
66
+ }
67
+ return { inputIds, seg, pairQ, pairOpt, groups, truncated, length: inputIds.length, totalPairs };
68
+ }
69
+
70
+ // Shared marker lookup: every marker must encode to exactly one token id.
71
+ // (Custom typed-decision exports add [STATE]/[Q]/[OPT] to the tokenizer; if
72
+ // they are missing, the model is not a typed-decision export.)
73
+ export function typedMarkerIds(encodeFn, { markers = ["[STATE]", "[Q]", "[OPT]"], cls = "[CLS]", sep = "[SEP]" } = {}) {
74
+ const one = (text) => {
75
+ const ids = encodeFn(text);
76
+ if (!Array.isArray(ids) || ids.length !== 1) {
77
+ throw new Error(`marker ${text} must encode to exactly one token (got ${ids?.length ?? 0})`);
78
+ }
79
+ return ids[0];
80
+ };
81
+ const [state, question, option] = markers.map(one);
82
+ return { cls: one(cls), sep: one(sep), state, question, option };
83
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // jev-web — public API (typed decisions in the browser).
2
+ export { createDecider } from "./session.mjs";
3
+ export { normalizeQuestions, QUESTION_TYPES, NOUL_OPTIONS } from "./questions.mjs";
4
+ export { buildDecisionInput, typedMarkerIds, DecisionInputTooLongError } from "./encode.mjs";
5
+ export { answersFromScores, softmaxWithTemperature } from "./answers.mjs";
6
+ export {
7
+ createLayaDecider,
8
+ buildLayaSequence,
9
+ collateLayaItems,
10
+ layaAnswersFromLogits,
11
+ layaTokenizerAdapter,
12
+ renderLayaOptions,
13
+ layaTempBucket,
14
+ clampTemperature,
15
+ LAYA_DEFAULT_MODEL,
16
+ LAYA_DEFAULT_REVISION,
17
+ LAYA_DEFAULT_SUBFOLDER,
18
+ LAYA_DEFAULT_TEMPERATURES,
19
+ LAYA_DEFAULT_TEMPERATURES_BY_OPTIONS,
20
+ LAYA_DEFAULT_MAX_LEN,
21
+ LAYA_DEFAULT_HEAD_MAX_LEN,
22
+ LAYA_DEFAULT_MAX_PREFIXES,
23
+ LAYA_QTYPE,
24
+ LAYA_TEMP_MIN,
25
+ LAYA_TEMP_MAX,
26
+ } from "./laya.mjs";
27
+ export {
28
+ DEFAULT_MODEL,
29
+ DEFAULT_REVISION,
30
+ DEFAULT_TEMPERATURE,
31
+ DEFAULT_MARKERS,
32
+ DEFAULT_MAX_STATE_TOKENS,
33
+ DEFAULT_MAX_LEN,
34
+ resolveDevice,
35
+ resolveDtype,
36
+ detectWebGPU,
37
+ loadModelConfig,
38
+ } from "./config.mjs";
package/src/laya.mjs ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * laya.mjs — Laya System-1 decision models (ModernBERT encoder + typed head).
3
+ *
4
+ * The Laya family is a *different graph* from open-jev:
5
+ * encoder: input_ids, attention_mask -> hidden (last_hidden_state)
6
+ * head: hidden, marker_pos, marker_mask, qtype -> logits, act_logits
7
+ *
8
+ * Sequence layout (mirrors the reference `laya` Python runtime,
9
+ * `laya/common.py build_sequence`):
10
+ * [CLS] <type> question: <instructions> [SEP] [MASK]opt0 [MASK]opt1 … [SEP] <state> [SEP]
11
+ * `marker_pos` points at each option's [MASK]; `qtype` is 0 choice / 1 score /
12
+ * 2 noul. Logits are calibrated per question type *and* option-count bucket
13
+ * (rl_agent_config.json `temperature` / `temperature_by_options`), clamped to
14
+ * [0.5, 5] like the Python runtime.
15
+ *
16
+ * The browser export used here is a two-file q8 ONNX split
17
+ * (alfred361/laya-typed-decisions-web-q8, pinned by revision).
18
+ */
19
+
20
+ import { normalizeQuestions } from "./questions.mjs";
21
+ import { softmaxWithTemperature } from "./answers.mjs";
22
+
23
+ export const LAYA_DEFAULT_MODEL = "alfred361/laya-typed-decisions-web-q8";
24
+ export const LAYA_DEFAULT_REVISION = "96f637c0bee046dce5c61a3174de9f7cdcf09ace";
25
+ export const LAYA_DEFAULT_SUBFOLDER = "v1";
26
+
27
+ /** Reference calibration from the typed-decisions rl_agent_config.json. */
28
+ export const LAYA_DEFAULT_TEMPERATURES = [1.0148024559020996, 1.0374259948730469, 1.0575125217437744];
29
+ export const LAYA_DEFAULT_TEMPERATURES_BY_OPTIONS = {
30
+ "choice:3-5": 1.7601518630981445,
31
+ "choice:6-10": 1.0000158548355103,
32
+ "score:3-5": 1.2514300346374512,
33
+ "noul:2": 1.983399510383606,
34
+ "choice:11+": 0.10058280825614929,
35
+ "choice:2": 1.9063563346862793,
36
+ };
37
+ export const LAYA_DEFAULT_MAX_LEN = 1024;
38
+ export const LAYA_DEFAULT_HEAD_MAX_LEN = 256;
39
+ export const LAYA_DEFAULT_MAX_PREFIXES = 6;
40
+
41
+ export const LAYA_QTYPE = { choice: 0, score: 1, noul: 2 };
42
+ const QTYPE_NAMES = { 0: "choice", 1: "score", 2: "noul" };
43
+
44
+ // The Python runtime refuses temperatures outside [0.5, 5]: the shipped
45
+ // `choice:11+` bucket (0.1006) would sharpen logits ~10x and publish a coin
46
+ // flip as near-certainty.
47
+ export const LAYA_TEMP_MIN = 0.5;
48
+ export const LAYA_TEMP_MAX = 5;
49
+
50
+ export function clampTemperature(t, lo = LAYA_TEMP_MIN, hi = LAYA_TEMP_MAX) {
51
+ const n = Number(t);
52
+ if (!Number.isFinite(n)) return 1;
53
+ return Math.min(hi, Math.max(lo, n));
54
+ }
55
+
56
+ export function layaTempBucket(qtype, k) {
57
+ const size = k <= 2 ? "2" : k <= 5 ? "3-5" : k <= 10 ? "6-10" : "11+";
58
+ return `${QTYPE_NAMES[qtype]}:${size}`;
59
+ }
60
+
61
+ /**
62
+ * Option texts exactly as the reference runtime renders them.
63
+ * Our normalized questions carry no per-option criteria, so choice options
64
+ * render as their labels and noul falls back to the reference wording.
65
+ */
66
+ export function renderLayaOptions(question) {
67
+ if (question.type === "choice") return question.options.map(String);
68
+ if (question.type === "score") return question.options.map((c, i) => `level ${i}: ${c}`);
69
+ // noul
70
+ const [no, yes] = question.options;
71
+ if (no === "no" && yes === "yes") {
72
+ return ["false: no, the statement does not hold", "true: yes, the statement holds"];
73
+ }
74
+ return [`false: ${no}`, `true: ${yes}`];
75
+ }
76
+
77
+ /**
78
+ * Build one Laya sequence + marker positions.
79
+ * @param {{encode:(text:string)=>number[], ids:{cls:number,sep:number,mask:number},
80
+ * state:string, question:object, maxLen?:number, headMaxLen?:number,
81
+ * maxPrefixes?:number}} spec
82
+ * @returns {{ids:number[], markers:number[], qtype:number}}
83
+ */
84
+ export function buildLayaSequence({
85
+ encode,
86
+ ids,
87
+ state,
88
+ question,
89
+ maxLen = LAYA_DEFAULT_MAX_LEN,
90
+ headMaxLen = LAYA_DEFAULT_HEAD_MAX_LEN,
91
+ maxPrefixes = LAYA_DEFAULT_MAX_PREFIXES,
92
+ }) {
93
+ const options = renderLayaOptions(question);
94
+ if (options.length > maxPrefixes) {
95
+ throw new RangeError(
96
+ `laya: ${question.type} with ${options.length} options exceeds max_prefixes=${maxPrefixes}`);
97
+ }
98
+ const qtype = LAYA_QTYPE[question.type];
99
+ if (qtype === undefined) throw new TypeError(`laya: unsupported question type ${question.type}`);
100
+
101
+ const strip = (s) => String(s).replace(/\[MASK\]/g, " ");
102
+ let headIds = encode(`${question.type} question: ${strip(question.instructions)}`);
103
+ let optIds = options.map((o) => [ids.mask, ...encode(` ${strip(o)}`).slice(0, 48)]);
104
+
105
+ let optBudget = headMaxLen - optIds.reduce((n, o) => n + o.length, 0);
106
+ if (optBudget < 16) {
107
+ const per = Math.max(4, Math.floor((headMaxLen - 16) / Math.max(1, optIds.length)));
108
+ optIds = optIds.map((o) => o.slice(0, per));
109
+ optBudget = headMaxLen - optIds.reduce((n, o) => n + o.length, 0);
110
+ }
111
+ headIds = headIds.slice(0, Math.max(8, optBudget));
112
+
113
+ let seq = [ids.cls, ...headIds, ids.sep];
114
+ const markers = [];
115
+ for (const o of optIds) {
116
+ markers.push(seq.length);
117
+ seq.push(...o);
118
+ }
119
+ seq.push(ids.sep);
120
+ const room = Math.max(0, maxLen - seq.length - 1);
121
+ const stateIds = encode(String(state ?? "")).slice(0, room);
122
+ seq = [...seq, ...stateIds, ids.sep];
123
+
124
+ const clipped = seq.slice(0, maxLen);
125
+ return {
126
+ ids: clipped,
127
+ markers: markers.filter((m) => m < maxLen),
128
+ qtype,
129
+ };
130
+ }
131
+
132
+ /** Pad a batch of Laya items into int64/bool tensors. */
133
+ export function collateLayaItems(items, padId = 0) {
134
+ const n = items.length;
135
+ const L = Math.max(...items.map((it) => it.ids.length));
136
+ const M = Math.max(...items.map((it) => it.markers.length));
137
+ const inputIds = new BigInt64Array(n * L).fill(BigInt(padId));
138
+ const attention = new BigInt64Array(n * L);
139
+ const markerPos = new BigInt64Array(n * M);
140
+ const markerMask = new Uint8Array(n * M);
141
+ const qtype = new BigInt64Array(n);
142
+ items.forEach((it, i) => {
143
+ it.ids.forEach((v, j) => { inputIds[i * L + j] = BigInt(v); attention[i * L + j] = 1n; });
144
+ it.markers.forEach((m, j) => { markerPos[i * M + j] = BigInt(m); markerMask[i * M + j] = 1; });
145
+ qtype[i] = BigInt(it.qtype);
146
+ });
147
+ return { n, L, M, inputIds, attention, markerPos, markerMask, qtype };
148
+ }
149
+
150
+ /**
151
+ * Decode head logits (row-major [question][marker]) into jev-web answers.
152
+ * @param {{logits:number[]|Float32Array, questions:object[], markerCounts:number[],
153
+ * temperatures?:number[], temperatureByOptions?:object}} spec
154
+ */
155
+ export function layaAnswersFromLogits({
156
+ logits,
157
+ questions,
158
+ markerCounts,
159
+ temperatures = LAYA_DEFAULT_TEMPERATURES,
160
+ temperatureByOptions = LAYA_DEFAULT_TEMPERATURES_BY_OPTIONS,
161
+ }) {
162
+ const answers = [];
163
+ let offset = 0;
164
+ questions.forEach((q, i) => {
165
+ const k = markerCounts[i];
166
+ const qtype = LAYA_QTYPE[q.type];
167
+ const t = clampTemperature(
168
+ temperatureByOptions[layaTempBucket(qtype, k)] ?? temperatures[qtype] ?? 1,
169
+ );
170
+ const probs = softmaxWithTemperature(Array.from(logits.slice(offset, offset + k), Number), t);
171
+ offset += k;
172
+
173
+ const entropy = k > 1
174
+ ? -probs.reduce((acc, p) => acc + p * Math.log(Math.max(p, 1e-12)), 0) / Math.log(k)
175
+ : 0;
176
+ const confidence = Math.min(1, Math.max(0, 1 - entropy));
177
+ const best = probs.indexOf(Math.max(...probs));
178
+
179
+ if (q.type === "choice") {
180
+ answers.push({
181
+ type: "choice",
182
+ choice: q.options[best],
183
+ index: best,
184
+ probabilities: Object.fromEntries(q.options.map((o, j) => [o, probs[j]])),
185
+ confidence,
186
+ });
187
+ } else if (q.type === "score") {
188
+ const expected = probs.reduce((acc, p, j) => acc + p * j, 0);
189
+ answers.push({
190
+ type: "score",
191
+ score: expected,
192
+ level: best,
193
+ probabilities: Object.fromEntries(q.options.map((o, j) => [o, probs[j]])),
194
+ confidence,
195
+ });
196
+ } else {
197
+ const yes = probs[1];
198
+ answers.push({
199
+ type: "noul",
200
+ noul: yes,
201
+ probabilities: { [q.options[0]]: probs[0], [q.options[1]]: probs[1] },
202
+ confidence: Math.max(yes, 1 - yes),
203
+ });
204
+ }
205
+ });
206
+ return answers;
207
+ }
208
+
209
+ /** Wrap a transformers.js tokenizer into the tiny adapter laya needs. */
210
+ export function layaTokenizerAdapter(tok) {
211
+ const idOf = (prop, text) => {
212
+ const direct = tok?.[prop];
213
+ if (Number.isFinite(direct)) return Number(direct);
214
+ const ids = tok.encode?.(text) ?? tok(text, { add_special_tokens: true }).input_ids?.data;
215
+ return Number(Array.from(ids ?? [])[0] ?? 0);
216
+ };
217
+ return {
218
+ encode: (text) => Array.from(tok(text, { add_special_tokens: false }).input_ids.data, Number),
219
+ ids: {
220
+ cls: idOf("cls_token_id", "[CLS]"),
221
+ sep: idOf("sep_token_id", "[SEP]"),
222
+ mask: idOf("mask_token_id", "[MASK]"),
223
+ },
224
+ pad: idOf("pad_token_id", "[PAD]"),
225
+ };
226
+ }
227
+
228
+ const hfUrl = (model, revision, file) =>
229
+ `https://huggingface.co/${model}/resolve/${revision ? `${revision}/` : ""}${file}`;
230
+
231
+ /**
232
+ * Create a Laya decider.
233
+ *
234
+ * @param {object} [opts]
235
+ * model/revision/subfolder — pinned web-q8 ONNX export (or your own mirror)
236
+ * encoderUrl/headUrl — override the resolved ONNX URLs
237
+ * ort — injected onnxruntime-web (lazy import otherwise)
238
+ * transformers — injected @huggingface/transformers (tokenizer only)
239
+ * wasmPaths — ORT wasm/loader directory (self-hosted builds)
240
+ * device — "auto" | "wasm" | "webgpu"
241
+ */
242
+ export async function createLayaDecider({
243
+ model = LAYA_DEFAULT_MODEL,
244
+ revision = LAYA_DEFAULT_REVISION,
245
+ subfolder = LAYA_DEFAULT_SUBFOLDER,
246
+ encoderUrl = null,
247
+ headUrl = null,
248
+ tokenizerModel = null,
249
+ tokenizerSubfolder = null,
250
+ ort = null,
251
+ transformers = null,
252
+ wasmPaths = null,
253
+ device = "auto",
254
+ // (url, sessionOptions) => Promise<InferenceSession>. Hosts that need custom
255
+ // loading (local files, bundled weights, offline mirrors) override this.
256
+ sessionFactory = null,
257
+ onProgress = null,
258
+ maxLen = LAYA_DEFAULT_MAX_LEN,
259
+ headMaxLen = LAYA_DEFAULT_HEAD_MAX_LEN,
260
+ maxPrefixes = LAYA_DEFAULT_MAX_PREFIXES,
261
+ temperatures = LAYA_DEFAULT_TEMPERATURES,
262
+ temperatureByOptions = LAYA_DEFAULT_TEMPERATURES_BY_OPTIONS,
263
+ scope = globalThis,
264
+ } = {}) {
265
+ const ortMod = ort ?? (await import("onnxruntime-web"));
266
+ const tf = transformers ?? (await import("@huggingface/transformers"));
267
+ if (wasmPaths) {
268
+ try { ortMod.env.wasm.wasmPaths = wasmPaths; } catch { /* older runtime */ }
269
+ }
270
+
271
+ const resolvedDevice = device !== "auto"
272
+ ? device
273
+ : (scope?.navigator?.gpu ? "webgpu" : "wasm");
274
+ const executionProviders = resolvedDevice === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
275
+
276
+ const report = (phase) => (p) =>
277
+ onProgress?.({ phase, file: p?.file ?? null, loaded: p?.loaded ?? null, total: p?.total ?? null });
278
+
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
+ });
288
+ const adapter = layaTokenizerAdapter(tok);
289
+
290
+ const encUrl = encoderUrl ?? hfUrl(model, revision, `${subfolder}/encoder_q8.onnx`);
291
+ const headUrlResolved = headUrl ?? hfUrl(model, revision, `${subfolder}/head_q8.onnx`);
292
+ const createSession = sessionFactory
293
+ ?? ((url, opts) => ortMod.InferenceSession.create(url, opts));
294
+ const loadPair = async (providers) => {
295
+ const opts = { executionProviders: providers, graphOptimizationLevel: "all" };
296
+ const enc = await createSession(encUrl, { ...opts, progress_callback: report("encoder") });
297
+ const hd = await createSession(headUrlResolved, { ...opts, progress_callback: report("head") });
298
+ return { enc, hd };
299
+ };
300
+ let encoder, head, activeDevice = resolvedDevice;
301
+ try {
302
+ ({ enc: encoder, hd: head } = await loadPair(executionProviders));
303
+ } catch (e) {
304
+ // A quantized graph can be rejected by the WebGPU EP at session creation;
305
+ // retry wasm-only before giving up (the caller may still fall back).
306
+ if (resolvedDevice === "webgpu") {
307
+ onProgress?.({ phase: `webgpu unavailable (${e?.message ?? e}) — retrying on wasm` });
308
+ ({ enc: encoder, hd: head } = await loadPair(["wasm"]));
309
+ activeDevice = "wasm";
310
+ } else {
311
+ throw e;
312
+ }
313
+ }
314
+
315
+ const int64 = (arr, dims) => new ortMod.Tensor("int64", arr, dims);
316
+ const bool = (arr, dims) => new ortMod.Tensor("bool", arr, dims);
317
+
318
+ return {
319
+ info: {
320
+ family: "laya",
321
+ model,
322
+ revision,
323
+ device: activeDevice,
324
+ dtype: "q8",
325
+ maxLen,
326
+ headMaxLen,
327
+ maxPrefixes,
328
+ // Single representative temperature for UIs that show one value
329
+ // (noul:2 bucket, else the noul type temperature); per-bucket values
330
+ // below are what the decoder actually applies.
331
+ temperature: clampTemperature(temperatureByOptions["noul:2"] ?? temperatures[2] ?? 1),
332
+ temperatures: temperatures.map(clampTemperature),
333
+ temperatureByOptions: Object.fromEntries(
334
+ Object.entries(temperatureByOptions).map(([k, v]) => [k, clampTemperature(v)])),
335
+ },
336
+ async decide(state, questions) {
337
+ const normalized = normalizeQuestions(questions);
338
+ const items = normalized.map((q) =>
339
+ buildLayaSequence({ encode: adapter.encode, ids: adapter.ids, state, question: q, maxLen, headMaxLen, maxPrefixes }));
340
+ const b = collateLayaItems(items, adapter.pad);
341
+
342
+ const encOut = await encoder.run({
343
+ input_ids: int64(b.inputIds, [b.n, b.L]),
344
+ attention_mask: int64(b.attention, [b.n, b.L]),
345
+ });
346
+ const hidden = encOut.hidden ?? encOut.last_hidden_state ?? encOut[Object.keys(encOut)[0]];
347
+
348
+ // The q8 web head is exported batch-1 only (despite dynamic batch
349
+ // axes), so run it once per question over the shared encoder output.
350
+ // The encoder — the expensive half — still runs as one batched pass.
351
+ const headLogits = [];
352
+ const markerCounts = items.map((it) => it.markers.length);
353
+ for (let i = 0; i < b.n; i++) {
354
+ const k = markerCounts[i];
355
+ const row = hidden.data.subarray(i * b.L * hidden.dims[2], (i + 1) * b.L * hidden.dims[2]);
356
+ const rowHidden = new ortMod.Tensor(hidden.type, row, [1, b.L, hidden.dims[2]]);
357
+ const rowMask = b.attention.subarray(i * b.L, (i + 1) * b.L);
358
+ const rowPos = b.markerPos.subarray(i * b.M, i * b.M + k);
359
+ const rowMarkerMask = b.markerMask.subarray(i * b.M, i * b.M + k);
360
+ const headOut = await head.run({
361
+ hidden: rowHidden,
362
+ attention_mask: int64(rowMask, [1, b.L]),
363
+ marker_pos: int64(rowPos, [1, k]),
364
+ marker_mask: bool(rowMarkerMask, [1, k]),
365
+ qtype: int64(b.qtype.subarray(i, i + 1), [1]),
366
+ });
367
+ const rowLogits = headOut.logits ?? headOut[Object.keys(headOut)[0]];
368
+ headLogits.push(...Array.from(rowLogits.data, Number));
369
+ }
370
+
371
+ const answers = layaAnswersFromLogits({
372
+ logits: headLogits,
373
+ questions: normalized,
374
+ markerCounts,
375
+ temperatures,
376
+ temperatureByOptions,
377
+ });
378
+ const length = Number(b.attention.reduce((a, v) => a + v, 0n));
379
+ return { answers, truncated: items.some((it) => it.ids.length >= maxLen), length };
380
+ },
381
+ async dispose() {
382
+ try { await encoder.release?.(); } catch { /* optional */ }
383
+ try { await head.release?.(); } catch { /* optional */ }
384
+ },
385
+ };
386
+ }
@@ -0,0 +1,39 @@
1
+ // jev-web — question normalization for typed-decision models.
2
+ //
3
+ // The model answers three question kinds:
4
+ // choice — pick one of 2..255 options
5
+ // score — ordered levels (2..10); the answer is the expected level index
6
+ // noul — yes/no; the answer is p(yes)
7
+
8
+ export const QUESTION_TYPES = new Set(["choice", "score", "noul"]);
9
+ export const NOUL_OPTIONS = ["no", "yes"];
10
+
11
+ export function normalizeQuestions(questions) {
12
+ if (!Array.isArray(questions) || questions.length === 0) {
13
+ throw new TypeError("questions must be a non-empty array");
14
+ }
15
+ return questions.map((q, i) => {
16
+ const type = q?.type;
17
+ if (!QUESTION_TYPES.has(type)) {
18
+ throw new TypeError(`questions[${i}].type must be one of ${[...QUESTION_TYPES].join("|")}`);
19
+ }
20
+ const instructions = String(q?.instructions ?? "").trim();
21
+ if (!instructions) throw new TypeError(`questions[${i}].instructions is required`);
22
+
23
+ let options;
24
+ if (type === "noul") {
25
+ options = Array.isArray(q.options) && q.options.length ? q.options.map(String) : [...NOUL_OPTIONS];
26
+ if (options.length !== 2) throw new TypeError(`questions[${i}]: noul takes exactly 2 options`);
27
+ } else if (type === "choice") {
28
+ options = (q.options ?? []).map(String);
29
+ if (options.length < 2) throw new TypeError(`questions[${i}]: choice needs at least 2 options`);
30
+ if (options.length > 255) throw new TypeError(`questions[${i}]: choice supports at most 255 options`);
31
+ } else {
32
+ options = (q.options ?? []).map(String);
33
+ if (options.length < 2 || options.length > 10) {
34
+ throw new TypeError(`questions[${i}]: score needs 2-10 ordered levels`);
35
+ }
36
+ }
37
+ return { type, instructions, options };
38
+ });
39
+ }
@@ -0,0 +1,132 @@
1
+ // jev-web — decider session: tokenizer + model + decide().
2
+ //
3
+ // transformers.js is an optional peer dependency, imported lazily so the
4
+ // package works anywhere and hosts can inject their own copy (tests, custom
5
+ // builds). Everything heavy stays out of module scope.
6
+
7
+ import { normalizeQuestions } from "./questions.mjs";
8
+ import { buildDecisionInput, typedMarkerIds } from "./encode.mjs";
9
+ import { answersFromScores } from "./answers.mjs";
10
+ import {
11
+ DEFAULT_MODEL,
12
+ DEFAULT_REVISION,
13
+ loadModelConfig,
14
+ resolveDevice,
15
+ resolveDtype,
16
+ detectWebGPU,
17
+ } from "./config.mjs";
18
+
19
+ export async function createDecider({
20
+ model = DEFAULT_MODEL,
21
+ revision = DEFAULT_REVISION,
22
+ dtype = "auto",
23
+ device = "auto",
24
+ temperature = null,
25
+ maxStateTokens = null,
26
+ onProgress = null,
27
+ transformers = null,
28
+ fetchImpl = fetch,
29
+ scope = globalThis,
30
+ // Directory (or CDN base) holding the onnxruntime-web wasm/loader files.
31
+ // Bundlers usually cannot ship the 26 MB asyncify wasm under static-host
32
+ // file-size limits; hosts that self-host a compressed copy set this to
33
+ // e.g. "/assets/". Defaults to the runtime's own resolution.
34
+ wasmPaths = null,
35
+ } = {}) {
36
+ const tf = transformers ?? (await import("@huggingface/transformers"));
37
+ if (wasmPaths) {
38
+ try {
39
+ tf.env.backends.onnx.wasm.wasmPaths = wasmPaths;
40
+ } catch { /* older runtime: ignore */ }
41
+ }
42
+ const remote = await loadModelConfig({ model, revision, fetchImpl });
43
+ const resolvedDevice = resolveDevice({ device, webgpu: await detectWebGPU(scope) });
44
+ const resolvedDtype = resolveDtype(resolvedDevice, dtype);
45
+
46
+ const report = (phase) => (p) =>
47
+ onProgress?.({
48
+ phase,
49
+ status: p?.status ?? null,
50
+ file: p?.file ?? null,
51
+ loaded: p?.loaded ?? null,
52
+ total: p?.total ?? null,
53
+ progress: p?.progress ?? null,
54
+ });
55
+
56
+ const tokenizer = await tf.AutoTokenizer.from_pretrained(model, {
57
+ revision,
58
+ progress_callback: report("tokenizer"),
59
+ });
60
+ const net = await tf.AutoModel.from_pretrained(model, {
61
+ revision,
62
+ dtype: resolvedDtype,
63
+ device: resolvedDevice,
64
+ progress_callback: report("model"),
65
+ });
66
+
67
+ const encode = (text) => Array.from(tokenizer(text, { add_special_tokens: false }).input_ids.data, Number);
68
+ const markers = typedMarkerIds(encode, { markers: remote.markers });
69
+
70
+ const effective = {
71
+ temperature: Number.isFinite(temperature) ? temperature : remote.temperature,
72
+ maxStateTokens: Number.isFinite(maxStateTokens) ? maxStateTokens : remote.maxStateTokens,
73
+ maxLen: remote.maxLen,
74
+ };
75
+
76
+ const i64 = (values, dims) => new tf.Tensor("int64", BigInt64Array.from(values, BigInt), dims);
77
+
78
+ return {
79
+ info: {
80
+ model,
81
+ revision,
82
+ device: resolvedDevice,
83
+ dtype: resolvedDtype,
84
+ temperature: effective.temperature,
85
+ maxStateTokens: effective.maxStateTokens,
86
+ maxLen: effective.maxLen,
87
+ pool: remote.pool,
88
+ configSource: remote.source,
89
+ },
90
+ async decide(state, questions) {
91
+ const normalized = normalizeQuestions(questions);
92
+ const tokenized = normalized.map((q) => ({
93
+ type: q.type,
94
+ instructionIds: encode(q.instructions),
95
+ optionIds: q.options.map((option) => encode(option)),
96
+ }));
97
+ const stateIds = encode(String(state ?? ""));
98
+ const input = buildDecisionInput({
99
+ stateIds,
100
+ markers,
101
+ questions: tokenized,
102
+ maxStateTokens: effective.maxStateTokens,
103
+ maxLen: effective.maxLen,
104
+ });
105
+ const feeds = {
106
+ input_ids: i64(input.inputIds, [1, input.length]),
107
+ attention_mask: i64(input.inputIds.map(() => 1), [1, input.length]),
108
+ seg: i64(input.seg, [1, input.length]),
109
+ pair_q: i64(input.pairQ, [1, input.pairQ.length]),
110
+ pair_opt: i64(input.pairOpt, [1, input.pairOpt.length]),
111
+ };
112
+ const out = await net(feeds);
113
+ const logits = out?.logits;
114
+ if (!logits) throw new Error("model output has no `logits` — not a typed-decision export?");
115
+ const scores = Array.from(logits.to("float32").data, Number);
116
+ const answers = answersFromScores(scores, {
117
+ questions: normalized,
118
+ groups: input.groups,
119
+ temperature: effective.temperature,
120
+ });
121
+ return { answers, truncated: input.truncated, length: input.length };
122
+ },
123
+ async decideMany(cases) {
124
+ const out = [];
125
+ for (const c of cases ?? []) out.push(await this.decide(c.state, c.questions));
126
+ return out;
127
+ },
128
+ dispose() {
129
+ net?.dispose?.();
130
+ },
131
+ };
132
+ }