Models
Every model measured
Jev scores 0.753 on the same benchmark. wemm-4b-4bit and minicpm5-2b are built in; any other is
one command away,
jul models add <name> --repo <repository>, which fits it on the dev sets.
| Name | Repository | Memory | Jev bench, zero-shot | + autotune |
|---|---|---|---|---|
wemm-4b-4bit |
usejul/WeMM-Embedding-4B-mlx-4bit |
2.6 GB | 0.857 | 0.897 |
wemm-4b |
tencent/WeMM-Embedding-4B |
4.5 GB | 0.877 | 0.862 |
wemm-9b |
tencent/WeMM-Embedding-9B |
9.0 GB | 0.863 | 0.857 |
wemm-2b |
hfadam/WeMM-Embedding-2B-MLX-4bit |
1.5 GB | 0.777 | 0.805 |
f2llm-8b |
converted locally, not published | 4.5 GB | 0.820 | 0.850 |
f2llm-4b |
fcmeyer/F2LLM-v2-4B-mlx-6bit |
3.0 GB | 0.840 | 0.853 |
f2llm-1.7b |
converted locally, not published | 1.0 GB | 0.817 | 0.833 |
f2llm-0.6b |
fcmeyer/F2LLM-v2-0.6B-bf16-mlx |
1.1 GB | 0.623 | 0.817 |
qwen3-embedding-8b |
mlx-community/Qwen3-Embedding-8B-4bit-DWQ |
4.0 GB | 0.773 | 0.806 |
qwen3-embedding-4b |
mlx-community/Qwen3-Embedding-4B-4bit-DWQ |
2.1 GB | 0.733 | 0.775 |
qwen3-embedding-0.6b |
mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ |
0.32 GB | 0.637 | 0.760 |
harrier-0.6b |
majentik/harrier-oss-v1-0.6b-MLX-4bit |
0.31 GB | 0.667 | 0.814 |
minicpm5-2b |
openbmb/MiniCPM5-2B-MLX |
2.7 GB | 0.617 | 0.757 |
jul-decision-minicpm5-2b (v1.0) |
usejul/jul-decision-minicpm5-2b-mlx-4bit |
1.3 GB | 0.796 | |
ternary-bonsai-1.7b |
prism-ml/Ternary-Bonsai-1.7B-mlx-2bit |
0.46 GB | 0.640 | 0.760 |
ternary-bonsai-8b |
prism-ml/Ternary-Bonsai-8B-mlx-2bit |
1.75 GB | 0.563 | 0.753 |
bitnet-2b |
mlx-community/bitnet-b1.58-2B-4T |
1.1 GB | 0.617 | 0.723 |
e5-small |
intfloat/multilingual-e5-small, onnx 8-bit |
0.09 GB | 0.543 | 0.713 (0.790 hybrid) |
+ autotune is a head on the vectors (features="vector"). With a hybrid head (vectors + TF-IDF, see
features=), e5-small — an encoder, see Micro models —
reaches 0.790, above Jev's 0.753 (AG News 0.95, Banking77 0.88, Emotion 0.54), at 6 ms per text on
an M4 Pro. The other models were not measured with a hybrid head.
Adding a model
jul models add my-model --repo org/Some-Instruct-3B # on the default backend
jul models add minicpm5-2b --backend torch # a known preset, on another backend
One command runs the protocol that produced the built-in presets, on the dev datasets only (never on the Jev benchmark):
- checks — the model loads, the prefix cache leaves the vectors unchanged, a call does not disturb the next one, the letters reading (Noul, Score) has single-token markers;
- extraction — 4 dev sets x 50 examples and 200 generic texts, every layer of the upper half read in the same pass;
- choice — layers and center by dev accuracy averaged over neighbouring layers (a plateau, not a lucky peak), then tau by pooled NLL;
- output —
~/.jul/presets/<name>@<backend>.jsonand its generic center, used from then on by--model <name>on that backend.jul modelslists it with what was measured.
The calibration data is downloaded once from BTZSC into ~/.jul/calibration-data (needs
pip install "jul[calibrate]"), or taken from --data <dir>. The dev accuracy it reports comes
with its standard error (±3.5 points at n=200): it orients, it does not rank close models. Measure
on the Jev bench separately, once.
Micro models: encoders
An encoder (BERT, XLM-R, multilingual-e5…) is a backbone like any other: jul models add,
autotune and jul pack run on it unchanged, on the torch and onnx backends. JuL recognizes one by
its model_type, or by use_bidirectional_attention in its config (a decoder turned into an embedding
model, as EmbeddingGemma; torch backend only for now),
and reads it as it was trained, not as a decoder (lib/jul/encoder.py):
- the vector is the mean of the layer over the whole sequence (the sentence embedding e5 was trained to produce), not a last token; zero-shot is plain embedding similarity between state and options;
- the prompts are the model's own input convention (
query: {state}for e5), no chat template and no "in one word" cue (Backbone.templates). A model declares it in itsconfig_sentence_transformers.json("prompts": {"query": "query: "}); for repos that do not, the official e5 ones included, JuL reads it from a list by repo name,lib/jul/assets/text_prefixes.json, which$JUL_HOME/text_prefixes.jsonextends or overrides; - attention is bidirectional, so no prefix can be cached: the prefix runs again with each query, and a sequence is cut to the model's positions (512), the input first, then the prefix;
- no logits: the letters reading and decision models need a decoder.
python -m jul.backends.onnx_export intfloat/multilingual-e5-small models/e5-small-onnx
python -m jul.backends.onnx_export models/e5-small-onnx models/e5-small-onnx-w8 --int8 # 86 MB
jul models add e5-small --repo models/e5-small-onnx-w8 --backend onnx
Why bother: on a support-triage task (jul-lambda, 2026-09-25), multilingual-e5-small (21 M parameters outside its embedding) with a hybrid head matched Harrier 0.6B (440 M) — emotion 0.787 against 0.791, Banking77 0.890 against 0.890 — in 4 ms per message against 37 ms on an M4 Pro, and 17 ms on a 1,769 MB AWS Lambda ($0.59 per million calls, cold start 2.4 s). Zero-shot, on the calibration dev sets, it scored 0.590 against 0.475 for Harrier 0.6B. The heads carry it: alone, zero-shot, a small encoder is no match for a 4B embedding model.
Embeddings APIs (--backend api)
Any embeddings endpoint is read like an encoder: the state's vector is the embedding of the prompt that holds
it, the options' are the embeddings of the options. Nothing is downloaded; jul models add fits the center
and tau on the calibration sets, as for a local model.
jul models add qwen3-emb --repo ollama:qwen3-embedding:0.6b --backend api # Ollama, no key
OPENAI_API_KEY=... jul models add oai-small --repo openai:text-embedding-3-small --backend api
jul models add my-emb --repo http://localhost:8000/v1#bge-m3 --backend api # any OpenAI-compatible server
jul ask choice "Which team?" -o billing -o technical --state "I was charged twice" --model qwen3-emb --backend api
| repo | endpoint | key |
|---|---|---|
ollama:MODEL |
$OLLAMA_HOST or http://localhost:11434, /v1/embeddings |
none |
openai:MODEL |
https://api.openai.com/v1/embeddings |
OPENAI_API_KEY |
mistral:MODEL |
https://api.mistral.ai/v1/embeddings |
MISTRAL_API_KEY |
voyage:MODEL |
https://api.voyageai.com/v1/embeddings |
VOYAGE_API_KEY |
http(s)://host/v1#MODEL |
that server's /embeddings |
none |
Against a local model: one layer (the API's output), no prefix cache (each state is one call per formulation;
the options are embedded once per question), no logits (Noul and Score are read as vectors, their default;
letters and pointer need the weights), and usage counts characters. With a hosted provider the state leaves
the machine.
Texts are sent in requests of JUL_API_BATCH (16), each cut to JUL_API_MAX_CHARS (8000) characters with a
warning, with a timeout of JUL_API_TIMEOUT (300 s). Only Ollama has been run for real; OpenAI, Mistral and
Voyage use the same OpenAI-format request (Voyage's input_type is not sent; Mistral caps a request's size).
Cross models: reading the question and the text together
The vector reading encodes the text and each option apart: the model never sees both at once. That is
what makes it fast and cacheable, and also why a question about how two things relate — is this a
paraphrase of that sentence, does it follow that…, does the request mention a place and not a date — is
answered near chance by an embedding model of any size. A cross model is a second small encoder,
fine-tuned on pairs: <s> query: question </s></s> text </s> goes through it once, and the mean of its
last layer feeds a head chosen by the question type (lib/jul/cross.py):
Noul: one pass, three logits (yes, no, unknown);noul = p(yes) + p(unknown) / 2. Its own descriptions of true and false, when given, are appended to the question;Score: one pass per level, one logit each;Choice: supported, not routed by default (the vector reading classifies as well and reads each option once for every call).
It sits next to a vector preset, not in place of it. jul models add attaches one; the types its
cross.json declares (Noul and Score) are then read by it, and everything else by the vectors. A
question with a tuned head or a calibration from autotune keeps the vector reading those were fitted on,
and method="vector" or method="cross" forces one reading for a call.
jul models add jul-decision-e5-small --repo usejul/jul-decision-e5-small-onnx --backend onnx # cross/ attached
# a cross model of your own: export it like any encoder, then attach it
python -m jul.backends.onnx_export <cross model> models/cross-onnx --layers 11
python -m jul.backends.onnx_export models/cross-onnx models/cross-onnx-w8 --int8 --embedding-bits 4 # 98 MB
cp <cross model>/cross.json <cross model>/cross_heads.npz models/cross-onnx-w8/
jul models add jul-decision-e5-small --backend onnx --cross models/cross-onnx-w8
cross.json holds the input prefix, the length the pairs are cut to (as in training), the layer, the
separator between the two segments and the types; jul.cross.write_spec writes it from a trained model.
Which reading answers what:
| no labeled examples | labeled examples (autotune) |
|
|---|---|---|
Choice |
vectors | a head on the vectors |
Noul, Score |
the cross model | the cross model, unless a head on the vectors beats it on those examples |
autotune judges a head for a Noul or Score against the cross model's zero-shot answers on the same
examples, not against the vectors' (which are weaker there): if the head wins, the question is answered
by it; otherwise it stays with the cross model. Choice stays on vectors because they carry to labels the
models never saw — read through a cross model trained on the same data, tool routing went from 0.54 to
0.76 and classification from 0.82 to 0.86, but the zero-shot Jev bench fell from 0.557 to 0.460
(Banking77, 77 intents, 0.59 to 0.35) — and because a vector reading is what heads are trained on. Its
own vectors do not survive the cross training (0.61 to 0.39 on the dev sets), which is why the cross model
is a second set of weights.
jul pack ships only what its questions need: a bundle of Noul and Score questions (with no tuned head)
needs the cross model alone, a bundle of Choice questions the vector model alone (bundle.json, models).
On onnx, JUL_ONNX_CROSS_MODEL points the cross graph at S3 as JUL_ONNX_MODEL does the vector one.
Measured on jul-decision-e5-small (its vectors and its cross model, both ONNX 8-bit, M4 Pro), on Kev's
typed-decision questions it never trained on (transfer-v9 development split, clean questions):
| Noul | Choice | Score | all | Noul p50 | |
|---|---|---|---|---|---|
| vectors only | 0.579 | 0.401 | 0.275 | 0.460 | 19 ms |
| with the cross model | 0.726 | 0.401 | 0.500 | 0.524 | 7 ms |
| Jev (published) | 0.847 | 0.833 | 0.950 | 0.854 | — |
Paraphrase goes from 0.50 to 0.79, QNLI from 0.55 to 0.70, offensive posts from 0.725 to 0.80. The cross model was trained on relational yes/no (paraphrase, inference, compositions, dates), single-text decisions and questions about the writer's tone: "is the customer angry?" gets 0.84–0.99 on angry support messages and 0.00–0.02 on calm ones. What it does not do well yet: urgency (no training data under a commercial license), hard but polite complaints read as offensive, sentences with the same words in another order, and knowledge questions (MMLU does not move — that needs a larger model, not another reading).
A cross model on the preset's own weights (LoRA)
A cross model can also be a set of adapters on the vector model itself instead of a second encoder: one
model in memory for both readings. Its cross.json says "method": "lora" and names LoRA adapters (A and
B for each Linear layer, by module path, as transformers and mlx-lm name them) and the heads. The engine
attaches them to the backbone it already holds and switches them on only while it reads a Noul or a
Score, so the vector reading is unchanged: the features are the same with the adapters attached
(tests/test_cross.py). The prompt is Text: "<text>", then Question: <question> (and, for a Score,
Candidate answer: <level>), then Verdict:, read on the last token of the last layer after the final
norm: one pass for a Noul, one per level for a Score. MLX and PyTorch.
# a directory with cross.json, adapter.npz and cross_heads.npz, on top of the default model
jul models add wemm-4b-4bit --backend mlx --cross models/jul-decision-wemm-4b-4bit
jul ask noul "Was it paid on time?" --state "Invoice due May 9; paid May 3."
jul-decision-wemm-4b-4bit is WeMM-Embedding-4B with such a cross model (rank-16 adapters on its 248 Linear layers: 32.5M parameters, 65 MB in
float16; trained on relational yes/no, single-text decisions and scores). Measured on the same Kev
questions as above, never trained on:
| Noul | Score | |
|---|---|---|
wemm-4b-4bit, vectors only (PyTorch bf16) |
0.762 | 0.325 |
jul-decision-wemm-4b-4bit, PyTorch bf16 |
0.859 | 0.550 |
jul-decision-wemm-4b-4bit, MLX 4-bit (M4 Pro, ~115 ms per Noul) |
0.841 | 0.300 |
Paraphrase goes from 0.69 to 0.93 and QNLI from 0.79 to 0.87 (MLX), and Noul's calibration error from 0.126 to 0.043. On date questions worded freely (warranties, trials, bookings, ages, deadlines: 390 cases in English and French), comparing two dates is solved — which comes first, same month, on time: 1.00, against 0.62–0.90 for the vectors — but a gap to compute (an age, a warranty in months, a trial in days) stays near chance, with or without the adapters. That is also why Score barely moves in 4-bit: its questions here are deadlines, close calls that 4-bit weights flip. Choice questions keep the vectors, so the Jev benchmark (0.857 zero-shot) is untouched by construction.
A LoRA cross model can also read a Choice, when its cross.json has a choice entry: the text, the
question and every option in one prompt, one pass for any number of options.
Text: "<text>"
Question: <question>
Options:
- <option 1>
- <option 2>
Answer:
Each option is read on the newline that closes it (it has seen the text, the question and the options
before it), the answer on Answer:; a bilinear head (choice_q, choice_k in the heads file) scores
each option against the answer. jul adds that score to the vector reading, log p = log p_vector + mix · log
p_listwise (mix in cross.json, 3 for the default model), because each fixes the other's errors: on
the four label sets of the dev, never trained on, the vectors score 0.707, the listwise reading alone
0.723 and both 0.730.
jul-decision-wemm-4b, the default model, is WeMM-Embedding-4B with such adapters for Noul and Choice
(usejul/jul-decision-wemm-4b, 65 MB); Score is read by the
vectors. They
were trained on the bf16 weights and are attached on PyTorch; on MLX the preset reads as wemm-4b-4bit
until they are measured in 4-bit. Decision bench, PyTorch on an A10G (2,108 questions, 12 task families,
English and French):
| All | Choice | Noul | Score | p50 | |
|---|---|---|---|---|---|
wemm-4b-4bit vectors + the adapters above for Noul and Score |
0.757 | 0.700 | 0.850 | 0.652 | 95 ms |
jul-decision-wemm-4b |
0.849 | 0.883 | 0.872 | 0.672 | 97 ms |
| Jev (API) | 0.873 | 0.924 | 0.878 | 0.699 | 655 ms |
Per family: README. The decision bench keeps the
questions and label sets of the adapters' training data (only its texts are new). On a bench of 300 typed questions written from scratch for it (sentiment, finance, support, agent routing,
moderation), Score
read by the adapters fell behind the vectors of the same weights, so since adapter 1525e34 their cross.json
no longer lists score:
| All | Choice | Noul | Score | |
|---|---|---|---|---|
wemm-4b-4bit vectors |
0.657 | 0.78 | 0.78 | 0.41 |
| adapters for Noul, Score and Choice | 0.627 | 0.82 | 0.79 | 0.27 |
jul-decision-wemm-4b (adapters for Noul and Choice) |
0.677 | 0.83 | 0.79 | 0.41 |
Decision models
A decision model is a model trained to answer questions about a state, rather than to write text. It
reads the state and the options through delimiter tokens it learned, and a small head scores each
option against the question. jul runs one with no code of its own: everything that model needs sits
next to its weights, in a decision.json (delimiters, layout, readout, head file, temperature, and the
longest state and question it was trained on).
jul models add jul-decision-minicpm5-2b --repo usejul/jul-decision-minicpm5-2b-mlx-4bit # MLX, 1.4 GB
jul models add jul-decision-minicpm5-2b --repo usejul/jul-decision-minicpm5-2b --backend torch
jul ask choice "Which team should handle this ticket?" -o billing -o shipping -o access \
--state "I was charged twice for order 4411" --model jul-decision-minicpm5-2b
jul-decision-minicpm5-2b is built in (alias fast): TypeSafeClient(model="fast") needs nothing else. Another
repo (or a local directory) holding a decision.json brings its own format: no layer to choose and no tau
for the pointer head. jul models add still fits the vector reading of the same weights, which long questions
(routing by option count) and the types the model routes (routing.types) are read with;
--no-routing skips it. The API is the same as for any other model.
jul-decision-minicpm5-2b is MiniCPM5-2B with a merged LoRA and a pointer head (Kev's architecture), trained
on human-written typed decisions; its pointer head reads Choice and Noul, and its decision.json routes Score
to the vectors, which read it better. On a bench of 300 typed questions written from scratch for it (sentiment, finance, support, agent routing,
moderation), PyTorch on an A10G:
| All | Choice | Noul | Score | p50 | |
|---|---|---|---|---|---|
minicpm5-2b, untrained (vectors) |
0.630 | 0.75 | 0.66 | 0.48 | 142 ms |
jul-decision-minicpm5-2b v1.1 (Kev's decision mix) |
0.637 | 0.82 | 0.66 | 0.43 | 85 ms |
| v2, the pointer head for every type | 0.647 | 0.87 | 0.76 | 0.31 | 85 ms |
jul-decision-minicpm5-2b v2 (Score on the vectors) |
0.680 | 0.87 | 0.76 | 0.41 | 85 ms |
jul-decision-wemm-4b, for comparison |
0.677 | 0.83 | 0.79 | 0.41 | 86–439 ms |
The older versions stay on the Hub as revision="v1.0" and "v1.1"; the old repo names redirect. The state is encoded once per call and every question
continues from it, so questions never see each other.
The state is paid once per call: a ticket with four questions (two Choice, a Noul and a Score)
answers in 180 ms on an M4 Pro, against 65 ms for the first question alone. What costs is the
options — they are re-read on every request — so a three-option question runs in 64 ms where a
fifty-nine-option one takes 596 ms (v1, M4 Pro). Weights: usejul/jul-decision-minicpm5-2b-mlx-4bit
(MLX, 1.4 GB) and usejul/jul-decision-minicpm5-2b
(PyTorch, bf16).
autotune(...) works on a decision model through that same vector reading. The head is trained on its
vectors, and a question with an active head is read through the vectors plus the head. Questions without
a head keep the pointer head. The safety net judges the head against what the question gets without one:
the pointer head for Choice and Noul, the vector reading for a routed question (Score on
jul-decision-minicpm5-2b). A head that does not beat it stays inactive. A decision model added with
--no-routing has no vector reading, and autotune says so.
A state longer than the limit in its decision.json is truncated rather than stretched.
Strands Decider (pointer head at <answer>)
Strands Decider checkpoints (Apache-2.0) are a LoRA on
Qwen/Qwen3.5-4B plus a pointer head, released as an archive rather than as a model repo. Their prompt is plain
text (<state>…</state><question type="…">…<options>1. name — description…</options></question><answer>), the
query is the last token of <answer> and each option is read at the last token of its line. Convert the archive
once, then add the directory like any decision model:
python scripts/convert_strands_decider.py strands-decider-4B-hobson-v22.tar ./strands-v22 --sha256 7305d520...
jul models add strands-v22 --repo ./strands-v22 --backend torch
The converter checks the archive, merges the LoRA into the pinned base tensor by tensor (the model is never
loaded: 21 s and 2.8 GB of memory for the 4B), and writes decision.json with the four options this format
needs: layout: "text" (positions from character spans), a LayerNorm in the head, a temperature per type, and
a JSON state rendered as indented JSON. Their defaults are the Kev format's behaviour, so other decision models
do not change. Text questions only (no image path). An unofficial candidate: its JevBench numbers are the
author's estimate, not ours; scripts/strands_parity.py compares jul with Strands Decider's own code.
Letter-readout decision models (JevK5, Plumb, Quyet, spark-s1, Decision 3.0)
Other open decision models answer with a letter: a causal LM with a merged LoRA, trained to write the letter
of an option after its own fixed prompt, read as the softmax of the option letters' logits divided by a
calibration temperature (SemIf's protocol). jul's own letters
reading uses jul's prompt, which they were not trained on, so jul models add recognises them and reads them
the way their own runtime does (lib/jul/letter_models.py). Nothing is fitted:
jul models add jevk5 --repo alibiserikbay/JevK5 # --backend torch or mlx
jul models add plumb-4b --repo crh225/plumb-4b
jul models add quyet-medium --repo chinhnc/Quyet-1.0-Medium
jul models add spark-s1-4b --repo abhishek085/spark-s1-4b-v6
jul models add d3-edge --repo vllm-sr/d3-edge
jul ask noul "Does the customer ask for money back?" --state "I was billed twice, please refund" --model jevk5
| Model | Its runtime | Read from | Prompt | Options per pass | Temperature |
|---|---|---|---|---|---|
| JevK5 v0.3 | jevk5 0.3 | jevk5_config.json |
SemIf (JSON evidence, criterion, options) | 16, then knockout | 1.22 (knockout 0.93) |
| plumb-4b | jevk5 | jevk5_config.json |
SemIf | 16, then knockout | 2.07 |
| Quyet-1.0-Medium | quyet | quyet_config.json |
Quyet (prompt version 1 or 2) | 10 | per type |
| spark-s1-4b-v6 | open-spark-jev | calibration.json |
fenced state, a menu per type | 26 | per type |
| d3-edge, d3-lite | d3_runtime.py in the repo |
decision_config.json |
State / Question / Options, codes A..Z, AA.. | 255 | 1.0 |
The spec is stored in the preset (letters): the prompt format, the answer letters, how many options one pass
reads, the temperature per question type and the input limits. A repo can also ship it as a decision.json
with "method": "letters" (same keys: format, letters, max_options, temperature, many_options,
prompt_version, limits), which wins over the runtime configs. Each format keeps its runtime's input rules:
SemIf refuses inputs over 16,384 tokens, Quyet cuts the state to its token limit (the head kept, the tail for a
list), spark-s1 cuts it to 12,000 characters (head and tail kept).
Parity with the runtimes themselves, on the examples of each README (scripts/letters_parity.py, float32 on
CPU, same weights): the probabilities agree to within 5e-5 on all four models (13 questions: Choice, Noul,
Score, and a 20-option Choice through the knockout). The prompts are the runtimes' token for token;
the questions of one call share the state, which runs once as a cached prefix.
Backends. PyTorch and MLX (--backend mlx, the weights read in bf16 as published). On MLX, against the
runtimes in float32 (same examples, scripts/letters_parity.py --backend mlx --reference):
| Model | MLX bf16 | torch bf16 (CPU) |
|---|---|---|
| JevK5 v0.3 | 2.0e-3 | 5.9e-3 |
| plumb-4b | 4.2e-3 | 4.2e-3 |
| Quyet-1.0-Medium | 1.9e-3 | 1.9e-3 |
| spark-s1-4b-v6 | 2.4e-4 | 2.4e-4 |
Largest probability gap per model, same answer everywhere. MLX is never further from the runtime than torch at the same dtype: the gap is bf16's, not the MLX path's. Quyet's prompts (state cut included) are token-identical with mlx-lm's tokenizer and with transformers'. MLX in float32 was not measured (4B in float32 is about 18 GB).
What they do not do: no vector reading is fitted on their weights, so method= cannot pick another reading,
autotune has nothing to train a head on, jul pack refuses them; plumb's JevBench v1.5 settings (yes/no
answers moved out of the 0.2–0.8 band, a score temperature of 1.2) are output transforms of its own server for
that benchmark, not part of the model, and are not applied. onnx and api are refused.
A state too long for the model's own prompt rule is cut as its runtime cuts it: Quyet keeps the head of the
state (the tail of a list) within max_state_tokens, cut once for the whole request so that every question
reads the same text; open-spark-jev keeps the head and tail of a state over max_state_chars characters. Like
every cut (input limits), it is logged (input cut: <model> letters …), counted in
usage.truncated_tokens (open-spark-jev's in the tokens of the dropped middle), and refused by
on_long="error". JevK5 and Plumb refuse a prompt over max_tokens (16384), like their runtime:
ValueError, HTTP 400 from jul serve. open-spark-jev's abstain is not read: its gateway does not add it on
/v1/systemone either.
Decision 3.0 (d3). vLLM Semantic Router's decision
models (collection, Apache-2.0) ship a Qwen3.5
backbone, a decision_config.json (format d3-code-readout-v1) and their own runtime as remote code
(d3_runtime.py). jul reads the weights without that code. The prompt is d3_format.py's: a fixed system
prompt, then State: / Question: / Options: with one line per option under a code (A..Z, AA..: 255
single-token codes, listed in the config), and a closing line. A Choice option without a description shows its
key alone, else key: description; a Noul lists false then true, described by the question's criteria or
No / false / Yes / true; a Score lists its levels (2 to 10, as the runtime accepts). The reading differs from
the other formats in two ways: the hidden state at the last position (after the final norm) is multiplied by
the model's readout.safetensors matrix instead of the LM head, and the full-attention layers see the whole
prompt (no causal mask; attention_mode: noncausal_full_attention), the Gated DeltaNet layers staying causal.
Each question runs its whole prompt (without the causal mask, no prefix can be shared). The checkpoint is the
bare multimodal Qwen3_5Model: jul loads its text weights only (the vision tower is not read; images and
video, permutation_average and the runtime's AMD kernels are out of scope). jul does not pin Hub revisions:
to read the commit measured below, pass the snapshot directory as --repo.
Parity with d3_runtime.D3 (scripts/d3_parity.py, runtime on CPU in float32), on the README's text requests,
a 30-option Choice (codes past Z) and 50 decision-bench questions (Choice of 2 to 21 options, Noul, Score,
states up to ~4,000 tokens); every prompt has the runtime's token count:
| Model (commit) | torch float32 | MLX float32 | MLX bf16 | torch bf16 (CPU) | runtime bf16 |
|---|---|---|---|---|---|
d3-edge (c322a0dd) |
2.5e-6 (62/62) | 5.1e-6 (62/62) | 2.1e-2 (62/62) | 2.1e-2 (61/62) | 2.1e-2 (61/62) |
d3-lite (ca41e954) |
2.6e-6 (62/62) | 1.1e-5 (62/62) | 1.4e-2 (62/62) | 1.7e-2 (62/62) | 1.7e-2 (62/62) |
Largest probability gap over 62 questions (argmax agreement in brackets). In float32 jul is the runtime to within float noise on both backends. In bf16 (the weights as published, MLX's default) the gap is bf16's: the runtime in bf16, its own default, is as far from its float32 reading. Against that bf16 runtime, torch bf16 gives the same probabilities (1.5e-7 on d3-edge) and MLX bf16 is 2.7e-2 (d3-edge) / 3.0e-2 (d3-lite) away, the same answer on 61/62 and 62/62 questions. MLX float32 is measured with the weights cast to float32 as they are read (mlx-lm adds 1 to Qwen3.5's norm weights while loading, in the stored dtype). The script's default tolerance follows the dtype: 5e-3 in float32, 5e-2 in bf16.
Laya
Laya (ConvAI Innovations, Apache-2.0) is a decision model
with its own format and its own runtime, the laya package (PyTorch). JuL does not read its weights: it
hands the questions to Laya and returns its answers as JuL's, so Laya takes the same calls, the same
response and the same commands as any other model (jul ask, run, serve, bench, an Escalation tier).
pip install "jul[laya]" # or: jul setup --model laya
jul ask choice "Which team should handle this ticket?" -o billing -o technical \
--state "I was charged twice" --model laya
--model |
Checkpoint |
|---|---|
laya (or laya:english) |
the repo root, English |
laya:multilingual |
the multilingual/ subfolder |
laya:typed-decisions |
the typed-decisions/ subfolder |
laya:<hub repo or directory> |
any other Laya checkpoint |
Laya picks the device (CUDA, MPS, then CPU) and pins the Hub revision it downloads; the first call
downloads the checkpoint. What JuL adds on its own readings does not apply: no method, no heads or
calibration from a context, no autotune, no pack (each raises a ValueError saying so), and
--backend other than torch is refused. The answers are Laya's own: its Choice and Score confidence
is calibrated by Laya, not the top probability JuL's readings report, so a bar set for one model in an
Escalation does not carry over to the other.
Contrastive heads (any backbone; CLM-8B)
A contrastive preset reads a frozen backbone through two small projection heads: the state (with the
question's instructions after a blank line) goes through the state head, each option through the action
head, the score is scale * cosine and a softmax gives the answer. It is a way of reading a model, not
a model: the backbone is any repo jul loads (MLX, torch or onnx, decoders and encoders), and
contrastive.json says how it is read:
| Field | Values |
|---|---|
backbone |
repo per backend |
layer |
"final" (last layer after the final norm, decoders on MLX/torch) or a layer index (any backend) |
pooling |
"last" token or "mean" (an encoder is always read as its mean) |
render |
"fields" (key: value, CLM's) or "json" (jul's) for a structured state |
noul, prefix |
Noul texts when the question gives none; the model's input convention (query:) |
max_tokens, scale |
tail kept; the learnt scale |
The heads only mean something on the backbone they were trained on. Train them on any backbone from
labeled rows ({state, type, question, options, answer}, or autotune's {state, answers} with
--questions); the backbone stays frozen and torch is needed for the training only:
jul models add my-heads --repo Qwen/Qwen3-0.6B --backend torch --train-heads rows.jsonl
python -m jul.contrastive train Qwen/Qwen3-0.6B rows.jsonl out/ # same, without a preset
The loss is CLM's InfoNCE (the question's own options, plus the batch's other options as negatives), the
epoch kept is the best on held-out rows, and the plain cosine of the same embeddings is reported next to
it (training.json).
Measured once (decision-bench v1, 2,108 scored items, torch CPU): heads trained on a frozen Qwen3-0.6B from
5,974 rows of the decision dataset's train split score 0.378, against 0.337 for the plain cosine of the
same embeddings (choice 0.218 / 0.151, noul 0.570 / 0.542, score 0.294 / 0.297), 542 ms p50. That is a real
gain over the cosine but far below the fitted presets (wemm-4b-4bit 0.682): the mode works on any
backbone, it has not yet been measured on an embedding model or with more data.
Known limits:
- Reading without
last_hidden:default_readingprobeslast_hiddenon one token. A backend that does not expose the final-norm hidden states (onnx) or returns something unusable is read at its last layer before the final norm, with a warning in thejul.contrastivelog. Heads trained that way only fit that reading; heads that need"final"on such a backend fail with an error naming the backend. - In-batch negatives: the other questions' options in a batch count as negatives with weight 0.5
(
train_heads(in_batch=...)), CLM's choice. Equivalent texts across questions become false negatives; the weight is not exposed in the CLI. --train-headswith--cross: the cross model is not attached to the trained preset.- Overwrite:
~/.jul/heads/<name>is replaced without warning when the same name is trained again.
CLM-8B
CLM-8B is not a model of its own: two small
projection heads (19 M parameters, 75 MB) trained with InfoNCE on top of a frozen Qwen3-8B, read at
its last token. The state, with the question's instructions after a blank line, goes through the state
head; each option through the action head; the score is scale * cosine and a softmax gives the answer.
The heads mean nothing without that exact backbone, so jul runs Qwen3-8B itself and reads the heads
in numpy (no torch at inference, no vLLM server):
jul models add clm-8b --repo Contrastive-LM/CLM-v0.1-8B # converts the .pt once (needs torch)
jul ask choice "Which team should handle this?" -o "billing:Charges, invoices, refunds" \
-o "technical:Bugs and outages" --state "my invoice was charged twice" --model clm-8b
jul models add recognises a CLM repo by its config.json ("model_type": "clm"), converts the
checkpoint into ~/.jul/heads/<name>/ (contrastive.json + heads.npz) and writes a preset whose
repos are the backbone's: Qwen/Qwen3-8B (bf16, 16 GB) on torch, mlx-community/Qwen3-8B-8bit
(8.7 GB) on MLX. python -m jul.contrastive convert <.pt or repo> <dir> --backbone-mlx <repo> picks
another backbone; jul models add <name> --repo <dir> then registers that directory.
The texts are CLM's own (schema.build_pairs), not jul's formulations: objects rendered as key: value
fields, options verbatim, a Noul without descriptions read as CLM's "true: Yes. This is true: <question>".
On CLM's reference requests the MLX 8-bit backbone answers as its bf16 vLLM server does (M1 Pro):
| Request | CLM, bf16 vLLM | jul, MLX 8-bit | jul, MLX 4-bit |
|---|---|---|---|
| "charged twice…", Noul urgent | 0.840-0.848 | 0.865 | 0.713 |
| same, Choice billing | 0.987-0.989 | 0.991 | 0.985 |
| same, Score frustration (0-2) | 2.000 | 2.000 | 2.000 |
| "I'm very calm.", Score frustration | 1.999 (issue #3) | 1.998 | 1.999 |
| tides, Choice "the Moon" | 0.993 | 0.993 | 0.994 |
4-bit keeps every argmax but moves the Noul by 0.13, hence 8-bit by default. A warm call with the
options cached takes ~210 ms on an M1 Pro: every question embeds state + instructions once, since the
state head reads the question too.
autotune works: the head is trained on the encoder embedding of state + instructions (the vector the
state head reads), judged against the CLM heads' own answers, and a question keeps CLM's answer when the
head does not beat it. jul pack does not apply (vector method only).
On the Jev bench (scripts/bench_jul.py clm-8b zero-shot,tuned, M1 Pro, MLX 8-bit):
| AG News | Banking77 | Emotion | Mean acc | Mean ECE | p50 | |
|---|---|---|---|---|---|---|
clm-8b zero-shot |
0.55 | 0.08 | 0.22 | 0.283 | 0.208 | 217 ms |
clm-8b + autotune (1000 labels) |
0.92 | 0.58 | 0.45 | 0.650 | 0.117 | 217 ms |
Zero-shot it collapses onto a few labels (Banking77's 77 options, Emotion), as it does behind its own
vLLM server (0.53 / 0.08 / 0.23 measured there, torch bf16 on CPU): the port is faithful, the heads are
what they are. Jev scores 0.753, wemm-4b-4bit 0.857.
The built-in presets
| Preset | Model | Layers | tau | p50, M4 Pro | p50, M5 Max | Jev bench, zero-shot |
|---|---|---|---|---|---|---|
jul-decision-wemm-4b (alias accurate, default) |
usejul/WeMM-Embedding-4B-mlx-4bit |
31 / 31 | 0.0553 | 146 ms | 55 ms | 0.857 (MLX) |
wemm-4b-4bit |
usejul/WeMM-Embedding-4B-mlx-4bit |
31 / 31 | 0.0553 | 146 ms | 55 ms | 0.857 |
minicpm5-2b |
openbmb/MiniCPM5-2B-MLX |
39 / 40 | 0.0413 | 64 ms | 0.617 | |
jul-decision-minicpm5-2b (alias fast) |
usejul/jul-decision-minicpm5-2b-mlx-4bit |
pointer; Score 38 / 37 | 0.0474 | ~90 ms | — (0.680 on our 300-question bench) |
wemm-4b-4bit is the most accurate vector reading: 10 points above Jev with no training. The default,
jul-decision-wemm-4b, is the same preset plus LoRA adapters
that read Noul and Choice with the question and the text together; they are attached on PyTorch, and
on MLX it reads exactly as wemm-4b-4bit until they are measured in 4-bit.
On the same M4 Pro it is 2.3 times slower than minicpm5-2b.
A third option does not read a general model at all: jul-decision-minicpm5-2b is MiniCPM5-2B trained
to answer typed questions (a merged LoRA and a pointer head), built in as the alias fast: as accurate as the
default on our 300-question bench (0.680 against 0.677) at half its size. Its pointer head brings its own format;
Score goes to the vector reading of the same weights, shipped fitted per backend (the layers and tau in the table
are that reading's, on MLX). autotune(...) trains its heads on that same vector reading: a question with an
active head is read through it, the others keep the pointer head (see Decision models).
Input limits
Every reading takes a bounded input. Past the limit the end of the text is dropped: an answer that
sits there is never seen by that reading. The cut is logged each time on the jul.truncation logger
(jul serve prints it), naming the reading, its limit and the tokens dropped, and
usage.truncated_tokens in the response is the largest cut of the call (0 when nothing was cut).
| Reading | Limit | Set by |
|---|---|---|
cross models (jul-decision-wemm-4b: Noul, Choice; PyTorch) |
the max_length / max_state tokens of its cross.json (2048 on the Hub today); max_option per option in a listwise Choice (48) |
its cross.json |
pointer models (jul-decision-minicpm5-2b and its MLX 4-bit) |
the limits.max_state_tokens of its decision.json (2048 on the Hub today) |
its decision.json |
| encoders (e5-small) | the model's positions, 512 | the model |
| onnx backend, decoders | 2048 tokens | JUL_ONNX_MAX_TOKENS |
--backend api |
8000 characters (a warning, not counted in usage) |
JUL_API_MAX_CHARS |
vector reading of the MLX / torch decoders (wemm-4b-4bit, minicpm5-2b, …) |
the model's positions (131k / 262k) | the model |
A state longer than its cross.json limit is therefore answered from its beginning only on
jul-decision-wemm-4b for Noul and Choice, while Score (vector reading) sees it whole. The limits come from the
files next to the weights, so they follow the Hub: read them there rather than from this page.
on_long decides what a cut of the state does, per call (system_one(..., on_long=), the on_long field
of a jul serve request), per client (TypeSafeClient(on_long=)) or by default (JUL_ON_LONG):
on_long |
What happens past the limit |
|---|---|
cut (default) |
answered on what was read; logged, usage.truncated_tokens says how much was not |
error |
the call is refused (ValueError, HTTP 400 from jul serve), naming the reading and its limit, so nothing is decided on a text the model did not read |
Only the state is refused: an option description cut to a cross model's max_option is logged and counted in
usage.truncated_tokens, the call is answered. error refuses after the call was read, so a refused call
costs as much as an answered one. The refusal is a jul.truncation.InputTooLong, a ValueError.
jul bench --on-long error counts the rows a model refuses in refused_rows, and computes accuracy (and n)
over the rows it answered. With an escalation (--escalate-to), a refusal by the local tier is raised, not
sent to the next tier: error means the call is refused.
How it answers
For each formulation of the preset, the state and every option go through the same prompt; the answer
is the option whose vector is closest (cosine, after subtracting a center). The scores of the two
formulations are averaged, then softmax(cosine / tau).
1. This text: "{state}" means in one word: "
2. {instructions}\nPossible answers: {options}.\nText: "{state}"\nIn one word, the answer is: "
The model never writes anything. Everything state-independent — the prompt prefix, the option vectors, the center — is computed once, so a call only pays for its own tokens.
Every reading, and every setting
Five ways to read a model. A preset picks one; a call may override it.
| Reading | What it compares | Chosen by | Available on |
|---|---|---|---|
| vector (default) | cosine between the state's hidden state and each option's, softmax(cos / tau) |
preset method: "vector" |
any model |
| letters | the logits of the option letters (A, B, C…) at the next position | method="letters", per call or per client |
any model; a tuned head overrides it |
| pointer | a trained pointer head, at the delimiter tokens of the format in decision.json |
preset method: "pointer" |
decision models only |
| letter-readout | the option letters' logits after the model's own prompt, at its own temperature (d3: its readout matrix at the last position) | preset method: "letter-readout" |
letter-readout decision models only (JevK5, Plumb, Quyet, spark-s1, Decision 3.0) |
| tuned head | a logistic head fitted by autotune on vector features |
autotune() plus a Context |
pins the reading to vectors (on a decision model, its vector fallback) |
A decision model may also route by option count: below the threshold the pointer head answers, above
it the vector reading does. The pointer reads every option on every call, so its cost grows with the option
list (65 ms at 3 options, 868 ms at 59) while the vector reading is flat; past ~30 options it stops earning
that latency. Routing is per question, so one call can mix both. Measured on massive, 59 options, same
weights: 868 ms → 77 ms at equal accuracy.
| Setting | Where it lives | Default | What it does |
|---|---|---|---|
formulations |
preset | 2 built in | the prompts, and the layer each is read at |
tau |
preset | per model | temperature of softmax(cos / tau) |
center |
preset | "options" |
what is subtracted before the cosine: options, generic, none |
one_word |
preset | — | layer and tau of the single-formulation variant (one_word_only=True) |
head.temperature |
decision.json |
1.954 for ours | divides the pointer logits; never changes an answer |
limits.max_state_tokens / max_branch_tokens |
decision.json |
2048 / 1024 | where a too-long state or question is cut |
routing.above_options |
preset, measured by jul models add |
measured | option count above which the vector reading answers |
routing formulations, tau, center |
preset, fitted by jul models add |
— | the fallback reading, fitted on these very weights |
routing.types |
decision.json |
none | question types the vector reading answers at any option count (e.g. ["score"]) |
route_above=N |
per call | the model's value | overrides that threshold; 0 disables routing |
method= |
per call or client | "vector" |
vector or letters; ignored on a pointer preset |
one_word_only= |
client | False |
one formulation instead of two: faster, a little less accurate |
backend= |
client, or JUL_BACKEND |
auto | mlx or torch; onnx only when asked |
JUL_BATCH_TOKENS / JUL_BATCH_SIZE |
environment | per backend | how many rows the backbone batches at once |
features= |
autotune |
"vector" |
what a tuned head reads: vector, lexical (TF-IDF) or hybrid |
formulations= |
autotune |
the preset's | the prompts a question is read with, by name, per question if a dict |
JUL_HOME |
environment | ~/.jul |
where presets, contexts and calibration data live |
JUL_ONNX_MODEL |
environment | the export's graph | another graph for the onnx backend: a path or s3://bucket/key |
JUL_ONNX_THREADS |
environment | ONNX Runtime's | intra-op threads (on Lambda: one per whole vCPU) |
JUL_ONNX_MAX_TOKENS / JUL_ONNX_BATCH_TOKENS |
environment | 2048 / 2048 | longest row, and rows × longest per run, on onnx |
Routing trades accuracy for speed, and the trade is not free. Measured on massive by subsampling one set's own options — so the option count is not confounded with the task — the pointer head is better at every count, by about 5 points, while costing 105 ms at 3 options and 832 ms at 59. There is no count above which it stops earning its answer; there is only a count above which its speed stops being worth those points.
So jul models add measures the threshold rather than guessing one: it takes the smallest option
count at which the vector reading is three times faster, and records in the preset what that costs in
accuracy. A model where that never happens gets no routing at all. --route-above N sets it by hand and
--no-routing skips the whole fitting; the model's own decision.json may also carry a threshold.
A decision model may also route by question type: its decision.json lists in routing.types the
types its pointer head reads worse than the vector reading of the same weights, and those questions go to
the vectors at any option count (route_above=0 does not change that). The fallback is the one jul models
add fits anyway.
For maximum accuracy, pass route_above=0 on the call: every question then goes to the pointer head,
whatever its option count, and you pay the latency in the table above. The default is a compromise, and
jul models add prints exactly what it costs on the dev set it measured.