Run open-jev- and Laya-shaped typed-decision models in the browser: one state plus typed questions in, calibrated probabilities out.
JavaScript
0
45 commits
updated Oct 15, 2026
Run typed-decision models in the browser. One state text
plus any number of typed questions (choice / score / noul) go in; a
calibrated probability distribution per question comes back from a single
forward pass. No server, no API keys, no network calls after the weights are
cached.
Nine model families ship today — open-jev (fused DeBERTa graph), Laya
(ModernBERT encoder + typed head), Strands Decider 2B (Qwen torso + pointer
head), Bekko System One v0 (Ettin cross-encoder), Decision 2.0 (Qwen3 +
candidate head), Kev (Qwen3 + pointer head), Vega 0.8B (frozen Qwen3.5
torso + latent decision-physics engine), clef (Qwen3 torso + joint schema
head), and Decision 3.0 (d3 code readout) — behind one registry:
createDecisionRuntime({ family, fallback }).
This is a generic runtime, not a classifier for any particular domain. You bring the state text and the questions; the model answers by choosing among the options you give it.
https://warsang.github.io/jev-web/
An interactive playground that runs this exact package: type a state, add typed
questions, and watch the model and a hand-written keyword baseline answer
side by side. It also counts your actual outbound requests, so the
"offline / private / no server" claim is checkable rather than asserted. The
model selector loads any of the nine registered families — open-jev by
default, plus Bekko, Kev, Laya, Decision 2.0/3.0, Vega 0.8B (fused or the
8-bit split export), clef and Strands Decider — and ?model=<family>
deep-links one.

The picker is part of the demo: every family answers through the same
decide() contract, and the pass counter reports each family's real cost
(split Vega reads the state once per question, and Laya's q8 web head runs once per question over a single shared encoder pass; the others batch).

The frames are captured from the live demo at warsang.github.io/jev-web — site → typed decisions with latency → the negation case where the rules and the model disagree → the comparison matrix.
Three questions, one forward pass, and a hard label from the rule parser that could not answer the third question at all:

import { createDecider } from "jev-web";
const decider = await createDecider(); // ~340 MB of weights, once (default model; 29 MB - 0.61 GB across families)
const { answers } = await decider.decide(
"I was charged twice for the same order.",
[{ type: "noul", instructions: "The customer is asking for a refund." }],
);
answers[0].noul; // 0.92 — p(yes)
answers[0].probabilities; // { no: 0.08, yes: 0.92 }
answers[0].confidence; // 0.92
That is the whole integration. The first call downloads the weights and the
browser caches them; every later call — and every later visit, including
offline — is local. Zero bundled dependencies: both runtimes are optional
peers, imported lazily — @huggingface/transformers brings onnxruntime-web
with it — and hosts can inject their own copy of either.
const { answers } = await decider.decide(
"I was charged twice for the same order and nobody answers my emails. I want my money back now.",
[
{ type: "choice", instructions: "Which product area is the message about?",
options: ["fees & charges", "refund & dispute", "card", "other"] },
{ type: "noul", instructions: "The customer is asking for a refund." },
{ type: "score", instructions: "How negative is the message?",
options: ["very negative", "negative", "neutral", "positive", "very positive"] },
],
);
// [
// { type: "choice", choice: "fees & charges", index: 0,
// probabilities: { "fees & charges": 0.796, "refund & dispute": 0.169,
// card: 0.019, other: 0.016 }, confidence: 0.796 },
// { type: "noul", noul: 0.927,
// probabilities: { no: 0.073, yes: 0.927 }, confidence: 0.927 },
// { type: "score", score: 0.866, level: 1,
// probabilities: { "very negative": 0.336, negative: 0.478, neutral: 0.175,
// positive: 0.008, "very positive": 0.003 }, confidence: 0.478 },
// ]
// timings: { totalMs, inferMs } · length, truncated, prompts
Three questions, one forward pass, and every question carries the full
distribution — not just a label. That last part is the point: a hard label
cannot be thresholded, routed by confidence, or second-guessed, and the
choice answer above is a genuine 79.6 / 16.9 split rather than a coin flip you
find out about later.
fetch()| jev-web | Hosted LLM API via fetch() | |
|---|---|---|
| Cost per decision | $0.00 — the weights are static files | per-token, forever; a busy form multiplies it by every visitor |
| Latency | one local forward pass, measured live on the demo page | typically 0.4–3 s, plus a network round trip |
| Network at answer time | 0 requests — works in airplane mode | one round trip per call, minimum |
| Output shape | calibrated distribution per question, every question in one pass | prose you then have to regex back into a distribution |
| Determinism | same input → same probabilities, always | temperature 0 + structured outputs helps; still not guaranteed |
| Privacy | your text never leaves the device | your users' text is sent to a third party on every call |
| Ops burden | static files on a CDN, no key, no rate limit | key management, quotas, rate limits, uptime, spend alerts |
| Ceiling | only picks among options you supply — never writes, never explains | general purpose; will happily invent an option you never offered |
The honest trade. jev-web is a classifier, not a chatbot. If you need free-form answers you need a generator. If you need a routing decision, a score, or a yes/no with a confidence you can threshold on, this is the cheaper, faster and private one — and it is also smaller: a 0.5 B model plus your options beats a frontier model plus a parsing layer, on every axis except generality.
The live demo covers the interesting cases, and the presets are chosen to break keyword parsing rather than to flatter the model:
| preset | what it shows |
|---|---|
| Banking triage | easy text — a fair rule can match, the model adds a confidence |
| Negation trap | "This is not about a refund. My card was stolen…" — the keyword rule routes to refund & dispute; the model routes to card (53.3%) and answers the refund question no (0.101) |
| Contradictory review | praise and complaint in one sentence; keyword polarity reads the praise |
| Mixed intent | two intents in one state — a hard label must pick one, a distribution does not have to |
| Out of domain | no in-domain signal at all; the rule parser has nothing to say |
The negation case is the one worth looking at. The state is "This is not about a refund. My card was stolen in Madrid and the bank refuses to help. I never received the money back I asked about."

The rule parser finds the word refund and routes to refund & dispute — the
exact opposite of what the customer meant. The model routes to card (53.3%)
and answers "the customer is asking for a refund" with no (0.101). Both
the bad routing and the inversion come from the same clause, and neither is
visible in a hard label.
The demo also tracks outbound requests through the Performance API: you will see ~14 requests while the weights load, then 0 for every decision after that — config and tokenizer files are cached too, so reloading the page adds no probes.
npm i jev-web @huggingface/transformers
Both runtimes are optional peers, imported lazily: @huggingface/transformers
brings its own onnxruntime-web, and hosts that already ship either package
inject it via createDecider({ transformers }) / createDecider({ ort }).
Install onnxruntime-web explicitly when your tooling does not pull transitive
dependencies, or inject a custom runtime (local files, an older ORT, a worker).
The peer range accepts the 1.31.0-dev.* builds @huggingface/transformers 4.3
pins — ORT 1.31 has no stable release yet, and the clef / Decision 3.0 fp16
graphs need that generation.
import { createDecider, DEFAULT_MODEL, DEFAULT_REVISION } from "jev-web";
const decider = await createDecider({
// defaults to the reference open-jev DeBERTa-v3-large ONNX export,
// pinned by revision; any repo with the same typed-decision graph works
onProgress: (p) => console.log(p.phase, p.file, p.progress),
});
const { answers } = await decider.decide(
"I was charged twice for the same order and nobody answers my emails.",
[
{
type: "choice",
instructions: "Which product area is the message about?",
options: ["fees & charges", "refund & dispute", "card", "other"],
},
{ type: "noul", instructions: "The customer is asking for a refund." },
{
type: "score",
instructions: "How negative is the message?",
options: ["very negative", "negative", "neutral", "positive", "very positive"],
},
],
);
| type | options | answer |
|---|---|---|
choice | 2–255 labels | choice (argmax), probabilities, confidence |
score | 2–10 ordered levels | score (expected level index, may fall between levels) |
noul | exactly 2 (defaults ["no","yes"]) | noul (p of the second option = p(yes)), confidence |
Every answer carries the full distribution. decide() also returns
{ truncated, length } — one shared rule across all nine families (see
Truncation), not a per-family guess.
device / dtype default to auto: WebGPU → q4f16, otherwise WASM → q4.
Override explicitly (fp16, fp32, q8, …) when accuracy matters more than
download size. First load downloads the weights once; transformers.js caches
them (Cache API / IndexedDB) so later visits are offline-fast. The families'
config files, tokenizer files and ONNX external-data siblings all read through
the same Cache API layer — a reload never re-probes them, so they do not show
up in the host's request counter either.
decider.info reports the resolved { model, revision, device, dtype, temperature, maxStateTokens, maxLen, pool, configSource }.
Defaults come from the model repo's open_jev_config.json when present
(temperature, marker tokens, state cap), else the published reference values.
To use a different typed-decision export:
createDecider({ model: "your-org/your-typed-decision-ONNX", revision: "main" });
The export must add the [STATE], [Q], [OPT] markers to its tokenizer,
take input_ids, attention_mask, seg, pair_q, pair_opt, and return per-pair
logits. typedMarkerIds() fails loudly when a repo is missing them.
jev-web is a multi-family runtime: createDecisionRuntime({ family, fallback })
tries the requested family and then any fallbacks, returning the shared contract
{ info, decide(state, questions), dispose?() }:
import { createDecisionRuntime, listDecisionFamilies } from "jev-web";
listDecisionFamilies(); // ["open-jev", "laya", "strands-decider", "bekko", "decision2", "kev", "vega", "clef", "decision3"]
const runtime = await createDecisionRuntime({
family: "strands-decider",
fallback: ["laya", "open-jev"],
onFallback: ({ family, error }) => console.warn(family, error),
});
const { answers, truncated, length, prompts, timings } = await runtime.decide(state, questions);
Third-party families plug in without touching core:
registerDecisionFamily("my-model", {
defaults: { model: "org/my-typed-decision-ONNX", revision: "main" },
create: async (opts) => ({ info, decide, dispose }),
});
decide() returns prompts (decoded input sequence(s), for audit/debug) and
timings (per-stage ms) alongside the answers. Calls are serialized per
runtime — two overlapping decide() calls queue instead of corrupting the
session — and dispose() releases the underlying sessions; it is async, so
await it.
truncated is true iff a piece of user text — the state, a question's
instructions, or an option — was shortened to fit the model. Padding, batching
and delimiter bookkeeping never set it, and a family that trims nothing (e.g.
Decision 3.0) always reports false.
A family that cannot shorten safely does not drop text silently — it throws:
DecisionInputTooLongError for open-jev (the laid-out sequence exceeds
maxLen), and RangeError for Laya (a question has more options than
max_prefixes), Strands (shortening the instructions would eat an option),
Decision 2.0 (a prompt exceeds maxLength), Kev (a branch exceeds
max_branch_tokens, or the branches leave no room for the state), the split
Vega export (a state that does not fit maxLen), and Decision 3.0 (a prompt
exceeds maxLen).
So truncated === true means "the answer was produced with less text than I
sent", and false means "the model saw everything" — never a silent maybe.
createLayaDecider(options) runs the Laya
System-1 decision models, whose browser export is a two-file q8 ONNX split
(encoder + head) rather than one fused graph:
import { createLayaDecider } from "jev-web";
const decider = await createLayaDecider({
// defaults to alfred361/laya-typed-decisions-web-q8 @ pinned revision
wasmPaths: "/assets/", // self-hosted ORT wasm (optional)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference laya Python runtime):
[CLS] <type> question: <instructions> [SEP] [MASK]opt0 … [SEP] <state> [SEP],
marker_pos at each option's [MASK], qtype 0 choice / 1 score / 2 noul;input_ids, attention_mask → hidden; head
hidden, attention_mask, marker_pos, marker_mask, qtype → logits;rl_agent_config.json
(temperature + temperature_by_options buckets, clamped to [0.5, 5]).Notes:
decide() batches the encoder and
runs the head once per question;choice supports at most max_prefixes options (6 for the typed-decisions
export);Lower-level helpers are exported for custom runtimes:
buildLayaSequence, collateLayaItems, layaAnswersFromLogits,
layaTokenizerAdapter, renderLayaOptions, layaTempBucket, clampTemperature.
createStrandsDecider(options) runs
Strands Decider 2B,
a decision model: a Qwen3.5 or Gemma 4 torso (LoRA merged) with its LM head
replaced by a pointer head that scores each option from its own hidden state
against the <answer> position. One fused ONNX graph, one forward pass for the
whole batch:
import { createStrandsDecider } from "jev-web";
const decider = await createStrandsDecider({
// defaults to onnx-community/strands-decider-2B-hobson-v19-ONNX @ pinned
// revision, q8 (onnx/model_quantized.onnx)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference Python runtime):
<state>…</state><question type="{noul|choice|score}">…<options>\n1. …\n</options></question><answer>;input_ids, attention_mask, answer_pos, option_pos → logits, where
answer_pos is each row's last real token (the <answer> pooling position)
and option_pos carries each option line's last-token position (padded with
-1); the prompt is tokenised in chunks at pre-tokeniser piece boundaries so
those positions are exact with no offset mapping;criteria as {label: description} (mirroring the
reference schema); noul slots are fixed false/true with overridable
rubric descriptions;<answer>;strands_decider_config.json (hobson_config.json on v19) from the
model repo at start-up and applies its temperature / temperature_by_kind
/ ordinal_smoothing / max_length. A repo that ships no config falls back
to the pinned reference values (the current pin's temperatures: noul 0.911
/ choice 0.734 / score 1.328). decider.info.configSource names the file
that was used, or null for the pinned defaults;temperature / temperaturesByKind / ordinalSmoothing / maxLen
options still override the checkpoint, which overrides the pinned values;
calibrationModel points the config load at another repo (e.g. the source
checkpoint when your own export predates config shipping);score confidence is ordinal (spread-based, with the training smoothing
floor), not max-probability.Checkpoint compatibility (verified against the 2026-10-09 drop):
| checkpoint | params / q8 ONNX | browser? |
|---|---|---|
2B-hobson-v19 (current pin) | ~2.0B / 1.8 GB | ✅ shipped |
2B-qwen3.5-v1-2610, E2B-gemma4-v1-2610 | ~2.1B / ~2.1 GB | ✅ export-ready (E2B: see caveat below) |
E4B-gemma4-v1-2610 | ~5.7B / ~5.7 GB | ⚠️ opt-in heavy only |
12B-gemma4-v1-2610 | ~13.3B / ~13 GB | ❌ too heavy |
26B-A4B-gemma4-v1-2610 (MoE, 128 experts) | ~26B / ~26 GB | ❌ too heavy |
A new browser-eligible checkpoint needs an ONNX export plus a model/revision
repin — no runtime code change (export/strands-decider/ documents the
pipeline and its browser-feasibility gate). Named checkpoints are addressed
through STRANDS_VARIANTS — temperatures are never baked into a variant,
they ship with the checkpoint:
const decider = await createStrandsDecider({ variant: "qwen3.5-v1" }); // the newer Qwen torso
const decider2 = await createStrandsDecider(); // hobson-v19, the default
qwen3.5-v1 currently ships the exact fp16 graph (~3.7 GB) because dynamic
q8 fails shape-inference on this graph's export metadata and the weight-only
path drifts on the GatedDeltaNet delta rule (see
export/strands-decider/RESUME.md), so it stays an opt-in until a quantized
build lands. Caveat: the E2B model card reports
a faulty GPU kernel path for multi-question yes/no answers before an upstream
attention fix; this runtime batches every question into one forward pass, so
validate multi-question parity before pinning E2B as a default.
Lower-level helpers are exported for custom runtimes:
renderStrandsQuestion, renderStrandsState, renderStrandsContent,
buildStrandsBatch, collateStrandsItems, strandsOptionTokenIndices,
strandsAnswersFromLogits, strandsChoiceConfidence, strandsScoreConfidence,
parseStrandsCalibration, loadStrandsCalibration.
createBekkoDecider(options) runs
Bekko System One v0, a
cross-encoder that scores each (query, candidate) pair: the query renders as
Instruction: {instruction}\nState: {state} and every option becomes a
Candidate: {id}: {description} document. One ONNX graph
(prefix_ids, prefix_mask, doc_ids, doc_mask, owners → logits [N, tasks]),
one forward per question, with candidates batched by the reference
attention budget:
import { createBekkoDecider } from "jev-web";
const decider = await createBekkoDecider({
// defaults to hotchpotch/bekko-system-one-v0-17m @ pinned revision,
// onnx_browser/ (29 MB)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick a larger sibling:
// await createBekkoDecider({ variant: "68m" }); // 196 MB
// await createBekkoDecider({ variant: "400m" }); // 1.43 GB, #2 on the S1MB leaderboard
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference browser runtime,
browser/src/core.js + decision.js, MIT):
[cls] Instruction: …\nState: … [sep]; instruction and state split the
4096-token budget 50/50 with spillover; candidates truncate to 2048 tokens;criteria as {label: description}; noul slots are
fixed true/false with authored meanings (overridable via criteria);0..n-1; the answer is the
expected value plus the argmax level and a normalised score.Larger siblings are available via the variant option ("17m", "68m",
"400m") or the exported model/revision constants: BEKKO_68M_MODEL /
BEKKO_68M_REVISION and BEKKO_400M_MODEL / BEKKO_400M_REVISION (same
onnx_browser/ layout). The BEKKO_VARIANTS map carries each size's pinned
revision and download size.
Lower-level helpers are exported for custom runtimes:
renderBekkoContent, renderBekkoRequest, tokenizeBekkoRequest,
collateBekkoDocs, bekkoSoftmax, bekkoAnswerFromLogits.
createDecision2Decider(options) runs
vLLM Semantic Router Decision 2.0,
a Qwen3 fine-tune with a candidate head: one prompt per question, each option
rendered as a {"description": …, "key": …} JSON segment. One ONNX graph
(input_ids, attention_mask, answer_pos, option_pos → logits [B, K]), one
forward pass for the whole batch:
import { createDecision2Decider } from "jev-web";
const decider = await createDecision2Decider({
// defaults to onnx-community/Decision-2.0-Kai-0.6B-ONNX @ pinned revision,
// q8 (onnx/model_quantized.onnx, 0.61 GB)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick a larger sibling:
// await createDecision2Decider({ variant: "eos" }); // 0.8B, 16k context
// await createDecision2Decider({ variant: "sol" }); // 2B, 16k context
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference decision2 runtime):
Context: / Task type: / Question: / Options: / one
<option> JSON segment per option / the Decision: suffix, each tokenized
separately so option_pos (last token of each </option>) and answer_pos
(last token) are exact — validated 11/11 against the reference fixtures;criteria as {label: description} (empty
descriptions encode as null); noul slots are fixed false/true;score_bias from config.json before the
softmax (temperature 1).Larger siblings are available via the variant option ("kai", "eos",
"sol") or the exported model/revision constants: DECISION2_EOS_MODEL /
DECISION2_EOS_REVISION (0.8B) and DECISION2_SOL_MODEL /
DECISION2_SOL_REVISION (2B). The DECISION2_VARIANTS map carries each
variant's pinned revision and context size.
Lower-level helpers are exported for custom runtimes:
renderDecision2Content, renderDecision2Options, buildDecision2Segments,
tokenizeDecision2Segments, decision2Softmax, decision2AnswerFromLogits.
createKevDecider(options) runs
Kev (Apache-2.0), a typed
decision model: one state and any number of typed questions are packed into a
single sequence with five delimiter tokens; a block-causal mask derived
in-graph lets every question see the state and itself only, and a pointer head
scores each option's </opt> token against its question's <decide> token.
One ONNX graph (input_ids, attention_mask → logits [B, seq]), one forward
pass for the whole batch — read the logit at each option's </opt> position
and softmax within the question:
import { createKevDecider } from "jev-web";
const decider = await createKevDecider({
// defaults to onnx-community/kev-0.6b-ONNX @ pinned revision,
// onnx/model_q4f16.onnx (335 MB)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick the larger sibling:
// await createKevDecider({ variant: "4b" }); // 1.07 GB, #29 on the Decision Index
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference JS in the model README):
<|fim_prefix|> (state), <|fim_middle|> (question),
<|box_start|> / <|box_end|> (option), <|fim_suffix|> (decide); ids
derived through the tokenizer, limits from config.json's kev block
(8192 state / 8192 branch tokens, 255 options);<|name|> → <¦name¦>) so it can never forge a
delimiter;criteria as {label: description}, rendered as
label: description; noul defaults to no / yes.The KEV_VARIANTS map ("0.6b", "4b") carries each size's pinned revision
and download size. Lower-level helpers are exported for custom runtimes:
escapeKevText, packKevSequence.
createVegaDecider(options) runs Vega 0.8B
(by the Laya author): the language model is frozen and never trained — a 0.8B
Qwen3.5 torso is read once (layers 13 and 19) and a 14M-parameter physics
engine on top does the deciding. Each question drops a particle into a latent
space whose landscape holds one Gaussian well per option; a damped symplectic
integrator with a selective-SSM thermostat runs up to 12 steps (settled
particles freeze), and the Boltzmann occupancy of the wells is the answer
distribution:
import { createVegaDecider } from "jev-web";
const decider = await createVegaDecider({
// variant "fp16" (default): warsang/vega-08b-web, fused torso + engine, 1.7 GB
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const q8 = await createVegaDecider({ variant: "q8" });
// nvkudva/vega-onnx-q8: 8-bit embed + cut decoder + fp32 engine, 840 MB,
// WebAssembly only (its MatMulNBits graphs have no WebGPU kernel)
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference vega_common.py / vega_api.py):
[BOS] Observation:\n<state>\n\nMeasurement (choice|score|boolean): <instructions>\nPossible outcomes:\n* <option> …\nOutcome: — the state comes first so its span never sees the question
(causal attention), and short states get a query-first prefix so the frozen
model reads knowing what is asked;vega_config.json's
calibration.theta maps the particle's physical state (residual kinetic
energy, distance to the nearest well, steps used, option count) to a
temperature per question type; decider.info.calibrationSource names the
file used. The split export's vega_web.json also carries the two gated
task adapters (public_intents, typed_decisions): the engine's per-
question gates pick the adapter whose calibration block (theta, conformal,
abstain, unbound) calibrates that answer, and the answers gain adapter
and gate;predictionSet, an abstain flag at the chosen risk
(alpha conformal level, risk abstention level, both per question type),
an unbound out-of-distribution flag (particle ended far from every
well), settleSteps, and the final energy;noul maps to Vega's boolean (fixed no / yes slots);
choice accepts criteria as {label: description}, and score uses the
option texts as levels.Variants (variant, or explicit model/revision/format):
| variant | export | graphs | size |
|---|---|---|---|
fp16 (default) | warsang/vega-08b-web | fused fp16 torso + fp32 engine, span means pooled in-graph | ~1.7 GB |
q8 | nvkudva/vega-onnx-q8 (v2/) | 8-bit embed_q8 + cut decoder_q8 (layers 0–19, text M-RoPE positions, zeroed past_* state) + fp32 engine with both adapters; JS pools | ~840 MB |
Caveats: the fused export's gated task adapters are not shipped in its v1 — it
uses the base engine, the source repo's default; and that torso ships as the
exact fp16 graph because q8 on the fused graph drifts on the GatedDeltaNet
delta rule. The split export is text-only here (its vision encoder is not
wired in), runs one decoder read per question, is refused — RangeError —
rather than truncated when a state does not fit maxLen, and its checkpoints
were verified end-to-end against the reference (export/vega/06_verify_q8_export.py:
JS-vs-reference pooling exact, engine graph vs PyTorch 1.4e-6, argmax 3/3
against the fp16 backbone). Both keep the trained 73,728-token window, so pass
maxLen to cap a browser's read. The fused export repo (warsang/vega-08b-web,
~1.7 GB) is uploaded by export/vega/05_upload.py; the revision lands in
VEGA_DEFAULT_REVISION.
Lower-level helpers are exported for custom runtimes:
buildVegaPrompt, packVegaBatch, packVegaChunks, vegaQuestionDef,
vegaOptionTexts, vegaThermalFeatures, vegaThermalProbabilities,
vegaAnswers, vegaOptionLabels, vegaJsonDumps, vegaRoute,
vegaTextPositions, emptyVegaPast, poolVegaHidden.
createClefDecider(options) runs clef-text-0.6b
(Apache-2.0): a Qwen3-0.6B torso (LoRA merged) with Cloudflare's joint schema
head — the whole record (state plus every question's schema) becomes one
prompt, and the head decides every field in one joint pass. It is distilled
from Cloudflare's clef-flash 9B (79.1 vs 88.2 mean across 12 held-out
benchmarks) and ships Cloudflare's unchanged reference module
(joint_schema_model.py).
import { createClefDecider } from "jev-web";
const decider = await createClefDecider({
// defaults to warsang/clef-text-0.6b-web @ pinned revision
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors encode_record / ClefModel):
<|im_start|>system\n…<|im_end|>\n<|im_start|>user\nSTATE:\n<state>
\n\nSCHEMA FIELDS:\n + one block per question (\nFIELD n\nID: …\nTYPE: …\nINSTRUCTION: …\nALLOWED OPTIONS:\nOPTION n: {"description":…,"option_id":…}\n…END FIELD\n) + <|im_end|>\n<|im_start|> assistant\n<think>\n\n</think>\n\nJOINT SCHEMA DECISIONS: — every piece
tokenized separately, the state truncated head-first to the window;choice option keys are sorted like the reference's
sorted(criteria.items()) (probabilities still come back keyed by your
labels); noul maps to the fixed true/false slots (default descriptions,
overridable under either true/false or yes/no); score levels map to
numeric keys 0..n-1 with a legend;id — it is rendered as the field ID (default
"0", "1", …);Lower-level helpers are exported for custom runtimes:
encodeClefRecord, packClefBatch, clefQuestionOptions, clefAnswers,
renderClefContent.
Caveat: this family ships the text-only clef — images and videos (the
multimodal clef checkpoints' processor path) are not part of the browser
export. The fused fp16 graph needs an onnxruntime in the 1.31 generation
(transformers.js 4.3 ships ORT-web 1.31 in the browser); hosts pinned to an
older runtime can re-export the exact fp32 graph (2.7 GB) with
export/clef/01_export.py and pass it as file.
createDecision3Decider(options) runs the
vLLM Semantic Router Decision 3.0
collection (Apache-2.0): Decision 2.0's candidate head is replaced by a code
readout — every option is printed under a single-token answer code (A…Z,
AA…AZ, up to 255), one question is decided per forward pass, and a 255-way
readout scores those codes at the last prompt position. d3-edge (0.59B,
multimodal) scores 28.82 on the Jev Decision Index 0.3 public suite (+12
over Decision 2.0's 0.6B).
import { createDecision3Decider } from "jev-web";
const decider = await createDecision3Decider({
// defaults to warsang/d3-edge-web @ pinned revision
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick a larger sibling:
// await createDecision3Decider({ variant: "lite" }); // 0.85B
// await createDecision3Decider({ variant: "nano" }); // 2.21B
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors d3_format.py / d3_runtime.py):
decision_config.json
(falling back to the shipped A…Z / AA…ZZ list); questions run batchSize
(8) per forward pass, left-padded like the reference;choice keys are the option labels with optional descriptions; noul maps
to the fixed false/true slots with overridable descriptions; score
takes 2–10 distinct levels, renders as bare option texts, and answers with
the expected level index, the reference entropy confidence and a legend;maxLen raises a RangeError — like the reference, nothing
is truncated (truncated is always false).Caveats: the browser export is text-only in v1 — the checkpoints'
image/video inputs run through a separate vision tower that the two shipped
graphs do not include; the exported window is capped by the browser budget
(maxLen, 8192 default) rather than the checkpoint's uncapped max_length;
and the fp16 torso needs an onnxruntime in the 1.31 generation (the same
requirement as clef — transformers.js 4.3 ships ORT-web 1.31 in the browser).
Lower-level helpers are exported for custom runtimes:
buildDecision3Prompt, decision3Messages, renderDecision3Chat,
normalizeDecision3Question, decision3OptionTexts,
decision3ProbsFromLogits, decision3Confidence, decision3Answers,
packDecision3Batch.
jev-web does not bundle a worker, so hosts can create one the way their
bundler prefers:
// host code
const worker = new Worker(new URL("./my-worker.mjs", import.meta.url), { type: "module" });
// my-worker.mjs
import { createDecider } from "jev-web";
createDecider(options) → open-jev family: { info, decide, decideMany, dispose }createLayaDecider(options) → Laya family: { info, decide, dispose } (same decide contract)createStrandsDecider(options) → Strands Decider family: { info, decide, dispose } (same decide contract)createBekkoDecider(options), createDecision2Decider(options), createKevDecider(options),
createVegaDecider(options), createClefDecider(options), createDecision3Decider(options) → the same decide contractregisterDecisionFamily, createDecisionRuntime, listDecisionFamilies,
getDecisionFamily.fetchCachedBytes,
fetchCachedJson, fetchOnnxExternalData, requestPersistentStorage,
MODEL_CACHE, and the HttpError / JsonParseError errors.normalizeQuestions, buildDecisionInput, typedMarkerIds, answersFromScores,
softmaxWithTemperature, resolveDevice, resolveDtype, detectWebGPU,
loadModelConfig, defaults (model/revision/temperature/caps).TypeScript definitions for the whole surface ship in the package
(src/index.d.ts); the resolution is nodenext-clean.
npm test — the offline unit suite (node:test, no network): protocol and
calibration maths per family, plus a warm-up → offline-reload cycle that
recreates every family from cache with zero requests.npm run typecheck — compiles types-test/consumer.ts against
src/index.d.ts with strict + nodenext and checks that every runtime
export is declared.npm run parity — runs one real example per family against its pinned
checkpoints and diffs the answers against parity/baselines.json. It runs
nightly (.github/workflows/nightly.yml); families without a baseline are
skipped, a drift exits non-zero. The baselines cover every family but
clef: its fused fp16 graph loads only inside a browser (onnxruntime-node
rejects it at load, and the 1.4 GB graph does not fit onnxruntime-web's
wasm32 heap on a headless host), so that family reports SKIP for now. Two
other headless accommodations are worth knowing: vega is recorded from its
split q8 export — the fused fp16 export is 1.7 GB and likewise does not fit
the wasm32 heap — and strands-decider (1.8 GB) runs through native
onnxruntime-node, installed by the workflow under an npm alias.demo/, deployed on every push)npm run dev → http://localhost:5188. Same ideas, plus
COOP/COEP headers that unlock SharedArrayBuffer for the threaded
onnxruntime-web build (GitHub Pages cannot set those headers, so the deployed
demo runs single-threaded).node tools/shot.mjs jobs.json drives headless
Chrome over CDP, waits on a real DOM condition, and reports the page's own
console errors. The GIF in this README was built with it. It exists because a
screenshot alone cannot tell you a promise is silently rejecting.Typed-decision models answer only by picking among your options; they do not explain or generate text.
Accuracy is domain-bound: questions and states unlike the model's training domain are out of distribution. Measure before relying on any answer.
English; state text capped at 256 tokens by default (512-token max sequence), one state per forward pass.
WebGPU support is uneven. A navigator.gpu adapter existing is not
evidence that inference will run: onnxruntime-web compiles WGSL per subgraph,
and Firefox currently rejects the reference DeBERTa-v3 export's Clip
subgraph (Failed to create a WebGPU compute pipeline: ShaderModule with 'Clip' label is invalid, surfaced as failed to call OrtRun()). The live
demo detects that signature and rebuilds on WASM, which works but is much
slower. If you hit it in your own app, fall back explicitly rather than
trusting adapter detection:
let decider;
try {
decider = await createDecider({ device: "webgpu" });
await decider.decide("warmup", [{ type: "noul", instructions: "Ready?" }]);
} catch {
decider = await createDecider({ device: "wasm", dtype: "q4" });
}
That warm-up call is the point: probe with inference, not with
requestAdapter().
MIT (runtime code). Model weights keep their own license (the reference export is Apache-2.0).
Run open-jev- and Laya-shaped typed-decision models in the browser: one state plus typed questions in, calibrated probabilities out.
JavaScript
0
45 commits
updated Oct 15, 2026
Run typed-decision models in the browser. One state text
plus any number of typed questions (choice / score / noul) go in; a
calibrated probability distribution per question comes back from a single
forward pass. No server, no API keys, no network calls after the weights are
cached.
Nine model families ship today — open-jev (fused DeBERTa graph), Laya
(ModernBERT encoder + typed head), Strands Decider 2B (Qwen torso + pointer
head), Bekko System One v0 (Ettin cross-encoder), Decision 2.0 (Qwen3 +
candidate head), Kev (Qwen3 + pointer head), Vega 0.8B (frozen Qwen3.5
torso + latent decision-physics engine), clef (Qwen3 torso + joint schema
head), and Decision 3.0 (d3 code readout) — behind one registry:
createDecisionRuntime({ family, fallback }).
This is a generic runtime, not a classifier for any particular domain. You bring the state text and the questions; the model answers by choosing among the options you give it.
https://warsang.github.io/jev-web/
An interactive playground that runs this exact package: type a state, add typed
questions, and watch the model and a hand-written keyword baseline answer
side by side. It also counts your actual outbound requests, so the
"offline / private / no server" claim is checkable rather than asserted. The
model selector loads any of the nine registered families — open-jev by
default, plus Bekko, Kev, Laya, Decision 2.0/3.0, Vega 0.8B (fused or the
8-bit split export), clef and Strands Decider — and ?model=<family>
deep-links one.

The picker is part of the demo: every family answers through the same
decide() contract, and the pass counter reports each family's real cost
(split Vega reads the state once per question, and Laya's q8 web head runs once per question over a single shared encoder pass; the others batch).

The frames are captured from the live demo at warsang.github.io/jev-web — site → typed decisions with latency → the negation case where the rules and the model disagree → the comparison matrix.
Three questions, one forward pass, and a hard label from the rule parser that could not answer the third question at all:

import { createDecider } from "jev-web";
const decider = await createDecider(); // ~340 MB of weights, once (default model; 29 MB - 0.61 GB across families)
const { answers } = await decider.decide(
"I was charged twice for the same order.",
[{ type: "noul", instructions: "The customer is asking for a refund." }],
);
answers[0].noul; // 0.92 — p(yes)
answers[0].probabilities; // { no: 0.08, yes: 0.92 }
answers[0].confidence; // 0.92
That is the whole integration. The first call downloads the weights and the
browser caches them; every later call — and every later visit, including
offline — is local. Zero bundled dependencies: both runtimes are optional
peers, imported lazily — @huggingface/transformers brings onnxruntime-web
with it — and hosts can inject their own copy of either.
const { answers } = await decider.decide(
"I was charged twice for the same order and nobody answers my emails. I want my money back now.",
[
{ type: "choice", instructions: "Which product area is the message about?",
options: ["fees & charges", "refund & dispute", "card", "other"] },
{ type: "noul", instructions: "The customer is asking for a refund." },
{ type: "score", instructions: "How negative is the message?",
options: ["very negative", "negative", "neutral", "positive", "very positive"] },
],
);
// [
// { type: "choice", choice: "fees & charges", index: 0,
// probabilities: { "fees & charges": 0.796, "refund & dispute": 0.169,
// card: 0.019, other: 0.016 }, confidence: 0.796 },
// { type: "noul", noul: 0.927,
// probabilities: { no: 0.073, yes: 0.927 }, confidence: 0.927 },
// { type: "score", score: 0.866, level: 1,
// probabilities: { "very negative": 0.336, negative: 0.478, neutral: 0.175,
// positive: 0.008, "very positive": 0.003 }, confidence: 0.478 },
// ]
// timings: { totalMs, inferMs } · length, truncated, prompts
Three questions, one forward pass, and every question carries the full
distribution — not just a label. That last part is the point: a hard label
cannot be thresholded, routed by confidence, or second-guessed, and the
choice answer above is a genuine 79.6 / 16.9 split rather than a coin flip you
find out about later.
fetch()| jev-web | Hosted LLM API via fetch() | |
|---|---|---|
| Cost per decision | $0.00 — the weights are static files | per-token, forever; a busy form multiplies it by every visitor |
| Latency | one local forward pass, measured live on the demo page | typically 0.4–3 s, plus a network round trip |
| Network at answer time | 0 requests — works in airplane mode | one round trip per call, minimum |
| Output shape | calibrated distribution per question, every question in one pass | prose you then have to regex back into a distribution |
| Determinism | same input → same probabilities, always | temperature 0 + structured outputs helps; still not guaranteed |
| Privacy | your text never leaves the device | your users' text is sent to a third party on every call |
| Ops burden | static files on a CDN, no key, no rate limit | key management, quotas, rate limits, uptime, spend alerts |
| Ceiling | only picks among options you supply — never writes, never explains | general purpose; will happily invent an option you never offered |
The honest trade. jev-web is a classifier, not a chatbot. If you need free-form answers you need a generator. If you need a routing decision, a score, or a yes/no with a confidence you can threshold on, this is the cheaper, faster and private one — and it is also smaller: a 0.5 B model plus your options beats a frontier model plus a parsing layer, on every axis except generality.
The live demo covers the interesting cases, and the presets are chosen to break keyword parsing rather than to flatter the model:
| preset | what it shows |
|---|---|
| Banking triage | easy text — a fair rule can match, the model adds a confidence |
| Negation trap | "This is not about a refund. My card was stolen…" — the keyword rule routes to refund & dispute; the model routes to card (53.3%) and answers the refund question no (0.101) |
| Contradictory review | praise and complaint in one sentence; keyword polarity reads the praise |
| Mixed intent | two intents in one state — a hard label must pick one, a distribution does not have to |
| Out of domain | no in-domain signal at all; the rule parser has nothing to say |
The negation case is the one worth looking at. The state is "This is not about a refund. My card was stolen in Madrid and the bank refuses to help. I never received the money back I asked about."

The rule parser finds the word refund and routes to refund & dispute — the
exact opposite of what the customer meant. The model routes to card (53.3%)
and answers "the customer is asking for a refund" with no (0.101). Both
the bad routing and the inversion come from the same clause, and neither is
visible in a hard label.
The demo also tracks outbound requests through the Performance API: you will see ~14 requests while the weights load, then 0 for every decision after that — config and tokenizer files are cached too, so reloading the page adds no probes.
npm i jev-web @huggingface/transformers
Both runtimes are optional peers, imported lazily: @huggingface/transformers
brings its own onnxruntime-web, and hosts that already ship either package
inject it via createDecider({ transformers }) / createDecider({ ort }).
Install onnxruntime-web explicitly when your tooling does not pull transitive
dependencies, or inject a custom runtime (local files, an older ORT, a worker).
The peer range accepts the 1.31.0-dev.* builds @huggingface/transformers 4.3
pins — ORT 1.31 has no stable release yet, and the clef / Decision 3.0 fp16
graphs need that generation.
import { createDecider, DEFAULT_MODEL, DEFAULT_REVISION } from "jev-web";
const decider = await createDecider({
// defaults to the reference open-jev DeBERTa-v3-large ONNX export,
// pinned by revision; any repo with the same typed-decision graph works
onProgress: (p) => console.log(p.phase, p.file, p.progress),
});
const { answers } = await decider.decide(
"I was charged twice for the same order and nobody answers my emails.",
[
{
type: "choice",
instructions: "Which product area is the message about?",
options: ["fees & charges", "refund & dispute", "card", "other"],
},
{ type: "noul", instructions: "The customer is asking for a refund." },
{
type: "score",
instructions: "How negative is the message?",
options: ["very negative", "negative", "neutral", "positive", "very positive"],
},
],
);
| type | options | answer |
|---|---|---|
choice | 2–255 labels | choice (argmax), probabilities, confidence |
score | 2–10 ordered levels | score (expected level index, may fall between levels) |
noul | exactly 2 (defaults ["no","yes"]) | noul (p of the second option = p(yes)), confidence |
Every answer carries the full distribution. decide() also returns
{ truncated, length } — one shared rule across all nine families (see
Truncation), not a per-family guess.
device / dtype default to auto: WebGPU → q4f16, otherwise WASM → q4.
Override explicitly (fp16, fp32, q8, …) when accuracy matters more than
download size. First load downloads the weights once; transformers.js caches
them (Cache API / IndexedDB) so later visits are offline-fast. The families'
config files, tokenizer files and ONNX external-data siblings all read through
the same Cache API layer — a reload never re-probes them, so they do not show
up in the host's request counter either.
decider.info reports the resolved { model, revision, device, dtype, temperature, maxStateTokens, maxLen, pool, configSource }.
Defaults come from the model repo's open_jev_config.json when present
(temperature, marker tokens, state cap), else the published reference values.
To use a different typed-decision export:
createDecider({ model: "your-org/your-typed-decision-ONNX", revision: "main" });
The export must add the [STATE], [Q], [OPT] markers to its tokenizer,
take input_ids, attention_mask, seg, pair_q, pair_opt, and return per-pair
logits. typedMarkerIds() fails loudly when a repo is missing them.
jev-web is a multi-family runtime: createDecisionRuntime({ family, fallback })
tries the requested family and then any fallbacks, returning the shared contract
{ info, decide(state, questions), dispose?() }:
import { createDecisionRuntime, listDecisionFamilies } from "jev-web";
listDecisionFamilies(); // ["open-jev", "laya", "strands-decider", "bekko", "decision2", "kev", "vega", "clef", "decision3"]
const runtime = await createDecisionRuntime({
family: "strands-decider",
fallback: ["laya", "open-jev"],
onFallback: ({ family, error }) => console.warn(family, error),
});
const { answers, truncated, length, prompts, timings } = await runtime.decide(state, questions);
Third-party families plug in without touching core:
registerDecisionFamily("my-model", {
defaults: { model: "org/my-typed-decision-ONNX", revision: "main" },
create: async (opts) => ({ info, decide, dispose }),
});
decide() returns prompts (decoded input sequence(s), for audit/debug) and
timings (per-stage ms) alongside the answers. Calls are serialized per
runtime — two overlapping decide() calls queue instead of corrupting the
session — and dispose() releases the underlying sessions; it is async, so
await it.
truncated is true iff a piece of user text — the state, a question's
instructions, or an option — was shortened to fit the model. Padding, batching
and delimiter bookkeeping never set it, and a family that trims nothing (e.g.
Decision 3.0) always reports false.
A family that cannot shorten safely does not drop text silently — it throws:
DecisionInputTooLongError for open-jev (the laid-out sequence exceeds
maxLen), and RangeError for Laya (a question has more options than
max_prefixes), Strands (shortening the instructions would eat an option),
Decision 2.0 (a prompt exceeds maxLength), Kev (a branch exceeds
max_branch_tokens, or the branches leave no room for the state), the split
Vega export (a state that does not fit maxLen), and Decision 3.0 (a prompt
exceeds maxLen).
So truncated === true means "the answer was produced with less text than I
sent", and false means "the model saw everything" — never a silent maybe.
createLayaDecider(options) runs the Laya
System-1 decision models, whose browser export is a two-file q8 ONNX split
(encoder + head) rather than one fused graph:
import { createLayaDecider } from "jev-web";
const decider = await createLayaDecider({
// defaults to alfred361/laya-typed-decisions-web-q8 @ pinned revision
wasmPaths: "/assets/", // self-hosted ORT wasm (optional)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference laya Python runtime):
[CLS] <type> question: <instructions> [SEP] [MASK]opt0 … [SEP] <state> [SEP],
marker_pos at each option's [MASK], qtype 0 choice / 1 score / 2 noul;input_ids, attention_mask → hidden; head
hidden, attention_mask, marker_pos, marker_mask, qtype → logits;rl_agent_config.json
(temperature + temperature_by_options buckets, clamped to [0.5, 5]).Notes:
decide() batches the encoder and
runs the head once per question;choice supports at most max_prefixes options (6 for the typed-decisions
export);Lower-level helpers are exported for custom runtimes:
buildLayaSequence, collateLayaItems, layaAnswersFromLogits,
layaTokenizerAdapter, renderLayaOptions, layaTempBucket, clampTemperature.
createStrandsDecider(options) runs
Strands Decider 2B,
a decision model: a Qwen3.5 or Gemma 4 torso (LoRA merged) with its LM head
replaced by a pointer head that scores each option from its own hidden state
against the <answer> position. One fused ONNX graph, one forward pass for the
whole batch:
import { createStrandsDecider } from "jev-web";
const decider = await createStrandsDecider({
// defaults to onnx-community/strands-decider-2B-hobson-v19-ONNX @ pinned
// revision, q8 (onnx/model_quantized.onnx)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference Python runtime):
<state>…</state><question type="{noul|choice|score}">…<options>\n1. …\n</options></question><answer>;input_ids, attention_mask, answer_pos, option_pos → logits, where
answer_pos is each row's last real token (the <answer> pooling position)
and option_pos carries each option line's last-token position (padded with
-1); the prompt is tokenised in chunks at pre-tokeniser piece boundaries so
those positions are exact with no offset mapping;criteria as {label: description} (mirroring the
reference schema); noul slots are fixed false/true with overridable
rubric descriptions;<answer>;strands_decider_config.json (hobson_config.json on v19) from the
model repo at start-up and applies its temperature / temperature_by_kind
/ ordinal_smoothing / max_length. A repo that ships no config falls back
to the pinned reference values (the current pin's temperatures: noul 0.911
/ choice 0.734 / score 1.328). decider.info.configSource names the file
that was used, or null for the pinned defaults;temperature / temperaturesByKind / ordinalSmoothing / maxLen
options still override the checkpoint, which overrides the pinned values;
calibrationModel points the config load at another repo (e.g. the source
checkpoint when your own export predates config shipping);score confidence is ordinal (spread-based, with the training smoothing
floor), not max-probability.Checkpoint compatibility (verified against the 2026-10-09 drop):
| checkpoint | params / q8 ONNX | browser? |
|---|---|---|
2B-hobson-v19 (current pin) | ~2.0B / 1.8 GB | ✅ shipped |
2B-qwen3.5-v1-2610, E2B-gemma4-v1-2610 | ~2.1B / ~2.1 GB | ✅ export-ready (E2B: see caveat below) |
E4B-gemma4-v1-2610 | ~5.7B / ~5.7 GB | ⚠️ opt-in heavy only |
12B-gemma4-v1-2610 | ~13.3B / ~13 GB | ❌ too heavy |
26B-A4B-gemma4-v1-2610 (MoE, 128 experts) | ~26B / ~26 GB | ❌ too heavy |
A new browser-eligible checkpoint needs an ONNX export plus a model/revision
repin — no runtime code change (export/strands-decider/ documents the
pipeline and its browser-feasibility gate). Named checkpoints are addressed
through STRANDS_VARIANTS — temperatures are never baked into a variant,
they ship with the checkpoint:
const decider = await createStrandsDecider({ variant: "qwen3.5-v1" }); // the newer Qwen torso
const decider2 = await createStrandsDecider(); // hobson-v19, the default
qwen3.5-v1 currently ships the exact fp16 graph (~3.7 GB) because dynamic
q8 fails shape-inference on this graph's export metadata and the weight-only
path drifts on the GatedDeltaNet delta rule (see
export/strands-decider/RESUME.md), so it stays an opt-in until a quantized
build lands. Caveat: the E2B model card reports
a faulty GPU kernel path for multi-question yes/no answers before an upstream
attention fix; this runtime batches every question into one forward pass, so
validate multi-question parity before pinning E2B as a default.
Lower-level helpers are exported for custom runtimes:
renderStrandsQuestion, renderStrandsState, renderStrandsContent,
buildStrandsBatch, collateStrandsItems, strandsOptionTokenIndices,
strandsAnswersFromLogits, strandsChoiceConfidence, strandsScoreConfidence,
parseStrandsCalibration, loadStrandsCalibration.
createBekkoDecider(options) runs
Bekko System One v0, a
cross-encoder that scores each (query, candidate) pair: the query renders as
Instruction: {instruction}\nState: {state} and every option becomes a
Candidate: {id}: {description} document. One ONNX graph
(prefix_ids, prefix_mask, doc_ids, doc_mask, owners → logits [N, tasks]),
one forward per question, with candidates batched by the reference
attention budget:
import { createBekkoDecider } from "jev-web";
const decider = await createBekkoDecider({
// defaults to hotchpotch/bekko-system-one-v0-17m @ pinned revision,
// onnx_browser/ (29 MB)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick a larger sibling:
// await createBekkoDecider({ variant: "68m" }); // 196 MB
// await createBekkoDecider({ variant: "400m" }); // 1.43 GB, #2 on the S1MB leaderboard
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference browser runtime,
browser/src/core.js + decision.js, MIT):
[cls] Instruction: …\nState: … [sep]; instruction and state split the
4096-token budget 50/50 with spillover; candidates truncate to 2048 tokens;criteria as {label: description}; noul slots are
fixed true/false with authored meanings (overridable via criteria);0..n-1; the answer is the
expected value plus the argmax level and a normalised score.Larger siblings are available via the variant option ("17m", "68m",
"400m") or the exported model/revision constants: BEKKO_68M_MODEL /
BEKKO_68M_REVISION and BEKKO_400M_MODEL / BEKKO_400M_REVISION (same
onnx_browser/ layout). The BEKKO_VARIANTS map carries each size's pinned
revision and download size.
Lower-level helpers are exported for custom runtimes:
renderBekkoContent, renderBekkoRequest, tokenizeBekkoRequest,
collateBekkoDocs, bekkoSoftmax, bekkoAnswerFromLogits.
createDecision2Decider(options) runs
vLLM Semantic Router Decision 2.0,
a Qwen3 fine-tune with a candidate head: one prompt per question, each option
rendered as a {"description": …, "key": …} JSON segment. One ONNX graph
(input_ids, attention_mask, answer_pos, option_pos → logits [B, K]), one
forward pass for the whole batch:
import { createDecision2Decider } from "jev-web";
const decider = await createDecision2Decider({
// defaults to onnx-community/Decision-2.0-Kai-0.6B-ONNX @ pinned revision,
// q8 (onnx/model_quantized.onnx, 0.61 GB)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick a larger sibling:
// await createDecision2Decider({ variant: "eos" }); // 0.8B, 16k context
// await createDecision2Decider({ variant: "sol" }); // 2B, 16k context
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference decision2 runtime):
Context: / Task type: / Question: / Options: / one
<option> JSON segment per option / the Decision: suffix, each tokenized
separately so option_pos (last token of each </option>) and answer_pos
(last token) are exact — validated 11/11 against the reference fixtures;criteria as {label: description} (empty
descriptions encode as null); noul slots are fixed false/true;score_bias from config.json before the
softmax (temperature 1).Larger siblings are available via the variant option ("kai", "eos",
"sol") or the exported model/revision constants: DECISION2_EOS_MODEL /
DECISION2_EOS_REVISION (0.8B) and DECISION2_SOL_MODEL /
DECISION2_SOL_REVISION (2B). The DECISION2_VARIANTS map carries each
variant's pinned revision and context size.
Lower-level helpers are exported for custom runtimes:
renderDecision2Content, renderDecision2Options, buildDecision2Segments,
tokenizeDecision2Segments, decision2Softmax, decision2AnswerFromLogits.
createKevDecider(options) runs
Kev (Apache-2.0), a typed
decision model: one state and any number of typed questions are packed into a
single sequence with five delimiter tokens; a block-causal mask derived
in-graph lets every question see the state and itself only, and a pointer head
scores each option's </opt> token against its question's <decide> token.
One ONNX graph (input_ids, attention_mask → logits [B, seq]), one forward
pass for the whole batch — read the logit at each option's </opt> position
and softmax within the question:
import { createKevDecider } from "jev-web";
const decider = await createKevDecider({
// defaults to onnx-community/kev-0.6b-ONNX @ pinned revision,
// onnx/model_q4f16.onnx (335 MB)
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick the larger sibling:
// await createKevDecider({ variant: "4b" }); // 1.07 GB, #29 on the Decision Index
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference JS in the model README):
<|fim_prefix|> (state), <|fim_middle|> (question),
<|box_start|> / <|box_end|> (option), <|fim_suffix|> (decide); ids
derived through the tokenizer, limits from config.json's kev block
(8192 state / 8192 branch tokens, 255 options);<|name|> → <¦name¦>) so it can never forge a
delimiter;criteria as {label: description}, rendered as
label: description; noul defaults to no / yes.The KEV_VARIANTS map ("0.6b", "4b") carries each size's pinned revision
and download size. Lower-level helpers are exported for custom runtimes:
escapeKevText, packKevSequence.
createVegaDecider(options) runs Vega 0.8B
(by the Laya author): the language model is frozen and never trained — a 0.8B
Qwen3.5 torso is read once (layers 13 and 19) and a 14M-parameter physics
engine on top does the deciding. Each question drops a particle into a latent
space whose landscape holds one Gaussian well per option; a damped symplectic
integrator with a selective-SSM thermostat runs up to 12 steps (settled
particles freeze), and the Boltzmann occupancy of the wells is the answer
distribution:
import { createVegaDecider } from "jev-web";
const decider = await createVegaDecider({
// variant "fp16" (default): warsang/vega-08b-web, fused torso + engine, 1.7 GB
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const q8 = await createVegaDecider({ variant: "q8" });
// nvkudva/vega-onnx-q8: 8-bit embed + cut decoder + fp32 engine, 840 MB,
// WebAssembly only (its MatMulNBits graphs have no WebGPU kernel)
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors the reference vega_common.py / vega_api.py):
[BOS] Observation:\n<state>\n\nMeasurement (choice|score|boolean): <instructions>\nPossible outcomes:\n* <option> …\nOutcome: — the state comes first so its span never sees the question
(causal attention), and short states get a query-first prefix so the frozen
model reads knowing what is asked;vega_config.json's
calibration.theta maps the particle's physical state (residual kinetic
energy, distance to the nearest well, steps used, option count) to a
temperature per question type; decider.info.calibrationSource names the
file used. The split export's vega_web.json also carries the two gated
task adapters (public_intents, typed_decisions): the engine's per-
question gates pick the adapter whose calibration block (theta, conformal,
abstain, unbound) calibrates that answer, and the answers gain adapter
and gate;predictionSet, an abstain flag at the chosen risk
(alpha conformal level, risk abstention level, both per question type),
an unbound out-of-distribution flag (particle ended far from every
well), settleSteps, and the final energy;noul maps to Vega's boolean (fixed no / yes slots);
choice accepts criteria as {label: description}, and score uses the
option texts as levels.Variants (variant, or explicit model/revision/format):
| variant | export | graphs | size |
|---|---|---|---|
fp16 (default) | warsang/vega-08b-web | fused fp16 torso + fp32 engine, span means pooled in-graph | ~1.7 GB |
q8 | nvkudva/vega-onnx-q8 (v2/) | 8-bit embed_q8 + cut decoder_q8 (layers 0–19, text M-RoPE positions, zeroed past_* state) + fp32 engine with both adapters; JS pools | ~840 MB |
Caveats: the fused export's gated task adapters are not shipped in its v1 — it
uses the base engine, the source repo's default; and that torso ships as the
exact fp16 graph because q8 on the fused graph drifts on the GatedDeltaNet
delta rule. The split export is text-only here (its vision encoder is not
wired in), runs one decoder read per question, is refused — RangeError —
rather than truncated when a state does not fit maxLen, and its checkpoints
were verified end-to-end against the reference (export/vega/06_verify_q8_export.py:
JS-vs-reference pooling exact, engine graph vs PyTorch 1.4e-6, argmax 3/3
against the fp16 backbone). Both keep the trained 73,728-token window, so pass
maxLen to cap a browser's read. The fused export repo (warsang/vega-08b-web,
~1.7 GB) is uploaded by export/vega/05_upload.py; the revision lands in
VEGA_DEFAULT_REVISION.
Lower-level helpers are exported for custom runtimes:
buildVegaPrompt, packVegaBatch, packVegaChunks, vegaQuestionDef,
vegaOptionTexts, vegaThermalFeatures, vegaThermalProbabilities,
vegaAnswers, vegaOptionLabels, vegaJsonDumps, vegaRoute,
vegaTextPositions, emptyVegaPast, poolVegaHidden.
createClefDecider(options) runs clef-text-0.6b
(Apache-2.0): a Qwen3-0.6B torso (LoRA merged) with Cloudflare's joint schema
head — the whole record (state plus every question's schema) becomes one
prompt, and the head decides every field in one joint pass. It is distilled
from Cloudflare's clef-flash 9B (79.1 vs 88.2 mean across 12 held-out
benchmarks) and ships Cloudflare's unchanged reference module
(joint_schema_model.py).
import { createClefDecider } from "jev-web";
const decider = await createClefDecider({
// defaults to warsang/clef-text-0.6b-web @ pinned revision
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors encode_record / ClefModel):
<|im_start|>system\n…<|im_end|>\n<|im_start|>user\nSTATE:\n<state>
\n\nSCHEMA FIELDS:\n + one block per question (\nFIELD n\nID: …\nTYPE: …\nINSTRUCTION: …\nALLOWED OPTIONS:\nOPTION n: {"description":…,"option_id":…}\n…END FIELD\n) + <|im_end|>\n<|im_start|> assistant\n<think>\n\n</think>\n\nJOINT SCHEMA DECISIONS: — every piece
tokenized separately, the state truncated head-first to the window;choice option keys are sorted like the reference's
sorted(criteria.items()) (probabilities still come back keyed by your
labels); noul maps to the fixed true/false slots (default descriptions,
overridable under either true/false or yes/no); score levels map to
numeric keys 0..n-1 with a legend;id — it is rendered as the field ID (default
"0", "1", …);Lower-level helpers are exported for custom runtimes:
encodeClefRecord, packClefBatch, clefQuestionOptions, clefAnswers,
renderClefContent.
Caveat: this family ships the text-only clef — images and videos (the
multimodal clef checkpoints' processor path) are not part of the browser
export. The fused fp16 graph needs an onnxruntime in the 1.31 generation
(transformers.js 4.3 ships ORT-web 1.31 in the browser); hosts pinned to an
older runtime can re-export the exact fp32 graph (2.7 GB) with
export/clef/01_export.py and pass it as file.
createDecision3Decider(options) runs the
vLLM Semantic Router Decision 3.0
collection (Apache-2.0): Decision 2.0's candidate head is replaced by a code
readout — every option is printed under a single-token answer code (A…Z,
AA…AZ, up to 255), one question is decided per forward pass, and a 255-way
readout scores those codes at the last prompt position. d3-edge (0.59B,
multimodal) scores 28.82 on the Jev Decision Index 0.3 public suite (+12
over Decision 2.0's 0.6B).
import { createDecision3Decider } from "jev-web";
const decider = await createDecision3Decider({
// defaults to warsang/d3-edge-web @ pinned revision
onProgress: (p) => console.log(p.phase, p.loaded, p.total),
});
// or pick a larger sibling:
// await createDecision3Decider({ variant: "lite" }); // 0.85B
// await createDecision3Decider({ variant: "nano" }); // 2.21B
const { answers } = await decider.decide(stateText, questions);
Protocol implemented here (mirrors d3_format.py / d3_runtime.py):
decision_config.json
(falling back to the shipped A…Z / AA…ZZ list); questions run batchSize
(8) per forward pass, left-padded like the reference;choice keys are the option labels with optional descriptions; noul maps
to the fixed false/true slots with overridable descriptions; score
takes 2–10 distinct levels, renders as bare option texts, and answers with
the expected level index, the reference entropy confidence and a legend;maxLen raises a RangeError — like the reference, nothing
is truncated (truncated is always false).Caveats: the browser export is text-only in v1 — the checkpoints'
image/video inputs run through a separate vision tower that the two shipped
graphs do not include; the exported window is capped by the browser budget
(maxLen, 8192 default) rather than the checkpoint's uncapped max_length;
and the fp16 torso needs an onnxruntime in the 1.31 generation (the same
requirement as clef — transformers.js 4.3 ships ORT-web 1.31 in the browser).
Lower-level helpers are exported for custom runtimes:
buildDecision3Prompt, decision3Messages, renderDecision3Chat,
normalizeDecision3Question, decision3OptionTexts,
decision3ProbsFromLogits, decision3Confidence, decision3Answers,
packDecision3Batch.
jev-web does not bundle a worker, so hosts can create one the way their
bundler prefers:
// host code
const worker = new Worker(new URL("./my-worker.mjs", import.meta.url), { type: "module" });
// my-worker.mjs
import { createDecider } from "jev-web";
createDecider(options) → open-jev family: { info, decide, decideMany, dispose }createLayaDecider(options) → Laya family: { info, decide, dispose } (same decide contract)createStrandsDecider(options) → Strands Decider family: { info, decide, dispose } (same decide contract)createBekkoDecider(options), createDecision2Decider(options), createKevDecider(options),
createVegaDecider(options), createClefDecider(options), createDecision3Decider(options) → the same decide contractregisterDecisionFamily, createDecisionRuntime, listDecisionFamilies,
getDecisionFamily.fetchCachedBytes,
fetchCachedJson, fetchOnnxExternalData, requestPersistentStorage,
MODEL_CACHE, and the HttpError / JsonParseError errors.normalizeQuestions, buildDecisionInput, typedMarkerIds, answersFromScores,
softmaxWithTemperature, resolveDevice, resolveDtype, detectWebGPU,
loadModelConfig, defaults (model/revision/temperature/caps).TypeScript definitions for the whole surface ship in the package
(src/index.d.ts); the resolution is nodenext-clean.
npm test — the offline unit suite (node:test, no network): protocol and
calibration maths per family, plus a warm-up → offline-reload cycle that
recreates every family from cache with zero requests.npm run typecheck — compiles types-test/consumer.ts against
src/index.d.ts with strict + nodenext and checks that every runtime
export is declared.npm run parity — runs one real example per family against its pinned
checkpoints and diffs the answers against parity/baselines.json. It runs
nightly (.github/workflows/nightly.yml); families without a baseline are
skipped, a drift exits non-zero. The baselines cover every family but
clef: its fused fp16 graph loads only inside a browser (onnxruntime-node
rejects it at load, and the 1.4 GB graph does not fit onnxruntime-web's
wasm32 heap on a headless host), so that family reports SKIP for now. Two
other headless accommodations are worth knowing: vega is recorded from its
split q8 export — the fused fp16 export is 1.7 GB and likewise does not fit
the wasm32 heap — and strands-decider (1.8 GB) runs through native
onnxruntime-node, installed by the workflow under an npm alias.demo/, deployed on every push)npm run dev → http://localhost:5188. Same ideas, plus
COOP/COEP headers that unlock SharedArrayBuffer for the threaded
onnxruntime-web build (GitHub Pages cannot set those headers, so the deployed
demo runs single-threaded).node tools/shot.mjs jobs.json drives headless
Chrome over CDP, waits on a real DOM condition, and reports the page's own
console errors. The GIF in this README was built with it. It exists because a
screenshot alone cannot tell you a promise is silently rejecting.Typed-decision models answer only by picking among your options; they do not explain or generate text.
Accuracy is domain-bound: questions and states unlike the model's training domain are out of distribution. Measure before relying on any answer.
English; state text capped at 256 tokens by default (512-token max sequence), one state per forward pass.
WebGPU support is uneven. A navigator.gpu adapter existing is not
evidence that inference will run: onnxruntime-web compiles WGSL per subgraph,
and Firefox currently rejects the reference DeBERTa-v3 export's Clip
subgraph (Failed to create a WebGPU compute pipeline: ShaderModule with 'Clip' label is invalid, surfaced as failed to call OrtRun()). The live
demo detects that signature and rebuilds on WASM, which works but is much
slower. If you hit it in your own app, fall back explicitly rather than
trusting adapter detection:
let decider;
try {
decider = await createDecider({ device: "webgpu" });
await decider.decide("warmup", [{ type: "noul", instructions: "Ready?" }]);
} catch {
decider = await createDecider({ device: "wasm", dtype: "q4" });
}
That warm-up call is the point: probe with inference, not with
requestAdapter().
MIT (runtime code). Model weights keep their own license (the reference export is Apache-2.0).