xerj-decide-v1 is the trained local head of the decide ladder: the thing
xerj-server --decide-mode local --decide-model-dir <dir> loads so a noul
or choice question can be answered with no API key, no network call, and
no history — tier 2 in docs/DECISIONS.md's ladder (tier 1 = history vote,
tier 3 = hosted model). This card states what the model is, every number we
measured for it, and how those numbers compare with the ladder's other tiers.
Status: artifact built and verified against the existing loader; publication to a model hub is PENDING operator credentials — no published URL exists yet and none should be claimed.
- Weights: Apache-2.0 (xerj-org).
- Training data: two public CC-BY-4.0 datasets (details below) — recorded in the manifest as provenance with the attribution that licence asks for.
- Harness:
benchmarks/decide-model/(deterministic, seeds pinned, stdlib-plus-candle, CPU-only).
A ModernBertForSequenceClassification in the exact shape the existing
loader in engine/crates/xerj-ai/src/decide.rs parses — the harness trains
through the same candle_transformers::models::modernbert load call the
server loads with, against the same tensor names, so trainer and server run
one forward implementation, not two that might drift.
| property | value |
|---|---|
| architecture | ModernBERT-class sequence-pair classifier, CLS pooling |
| hidden × layers × heads | 256 × 6 × 4 |
| intermediate (FFN) | 768 |
| vocabulary | wordpiece fitted to the training corpora, frozen at 8,742 tokens (pinned/tokenizer.json) |
| parameters | 7,419,907 (F32) |
| weights file | model.safetensors, 29,683,844 bytes |
| directory layout | config.json + tokenizer.json + model.safetensors (+ VERSION.json, MANIFEST.sha256) |
It is deliberately small: this head is scored once per candidate label per question on CPU, so every parameter is paid N times per request. A 149M-parameter encoder would be un-servable at that duty cycle in-process.
A label L becomes a hypothesis through the loader's fixed templates,
applied verbatim:
-
positive:
This example is {L}. -
negation:
This example is not {L}. -
noul (
/_decidewith no index,/v1/systemonetypenoul): the positive hypothesis is scored against its own negation; the positive's share of the two-way entailment mass isp_raw. -
choice (type
choicewith N criteria): all N hypotheses are scored in one batched pass; the renormalised entailment shares are the per-option probabilities and the arg-max is the answer.
Training mirrors this exactly: each labelled row yields three pairs —
(text, hyp(gold)) → entailment, (text, hyp(random wrong label)) → neutral, (text, neghyp(gold)) → contradiction — the three-way NLI shape
that a choice among mostly-wrong hypotheses actually exercises.
All numbers below were produced by cargo run --release -- eval in
benchmarks/decide-model/ (command noted per table), scoring through
Head::score, the same computation the server performs. The ECE estimator
is 10 equal-width bins — identical to the history-vote benchmark's eval.py
and to xerj-common's calibration.rs (a unit test pins the same
hand-computed case through both).
Command: cargo run --release -- eval --data-dir data --workdir work
(checkpoint ckpt-v1-epoch004.safetensors, the run's final epoch; training
loss 1.099 → 0.198 over 5 epochs). Banking77 is scored as the full 3,080-row
test split, 77-way choice; SMS as the full 1,574 held-out rows, noul.
| dataset | mode | n | accuracy | ECE | conf≥0.8 | acc@≥0.8 | ms/item | P/R/F1 (spam) |
|---|---|---|---|---|---|---|---|---|
| Banking77 test | choice (77) | 3,080 | 0.1185 | 0.072 | 0.0% | — | 272 | — |
| SMS held-out | noul | 1,574 | 0.8850 | 0.310 | 19.3% | 0.6700 | 13.1 | 0.534 / 0.961 / 0.686 |
Same recipe, one dataset removed from training (--exclude); the excluded
dataset's held-out rows are scored by a head that never saw them. Commands:
train … --exclude X --epochs 5 --name loo-X then eval … --exclude X.
| head | dataset scored | in training? | n | accuracy | ECE | note |
|---|---|---|---|---|---|---|
| loo-banking (SMS only) | Banking77 test, 77-way | no | 3,080 | 0.0094 | 0.010 | chance is 1/77 ≈ 0.013; confidence ≈ uniform (mean 0.020) — the head does not pretend |
| loo-banking (SMS only) | SMS held-out, noul | yes | 1,574 | 0.9568 | 0.261 | in-domain binary works (F1 0.850) |
| loo-sms (Banking77 only) | SMS held-out, noul | no | 1,574 | 0.1982 | 0.693 | below always-ham (0.867): the label vocabulary never trained |
| loo-sms (Banking77 only) | Banking77 test, 77-way | yes | 3,080 | 0.0416 | 0.018 | see below — pair-level NLI does not scale to 77-way ranking at this size |
What these numbers say, plainly.
- Zero-shot transfer to an unseen label vocabulary is ≈ chance in both directions. The head answers hypotheses it was trained to rank; a domain with new labels needs those labels in training. That is the boundary of tier 2's "no history" claim, now measured rather than assumed.
- Binary
noulon a trained vocabulary is where the head works. SMS at 0.957 (single-dataset head) / 0.885 (both-dataset head) — the history vote's BM25 baseline is 0.983, so tier 1 stays ahead on its home ground, as it should: it has labelled history, tier 2 has none. - Wide
choiceis not solved by this recipe. 77-way Banking77 tops out at 0.119 (both-dataset head; 0.042 single-dataset) against the history vote's 0.819 BM25 / 0.937 hybrid. The training signal — one entailment, one randomly-sampled wrong label, one negation per row — teaches pair-level NLI (loss 0.198) but not sharp ranking across 76 competitors. The plausible next recipe (more sampled negatives per row, more epochs) is a follow-up, not a claim. - Calibration is honest but not good. ECE 0.072 on Banking77 comes with
near-uniform confidence (0% of items at ≥0.8); the SMS noul ECE of
0.31 with mean confidence 0.70 means the head is underconfident when
right and overconfident when wrong — exactly what the #1063 calibration
layer (
p_cal, isotonic/temperature) exists to correct on live nodes.
From benchmarks/decisions-as-retrieval/results/run-2026-09-18.txt — 1,000
test items per dataset, k=10 history neighbours:
| dataset | method | accuracy | ECE | conf≥0.8 | acc@≥0.8 | ms/item |
|---|---|---|---|---|---|---|
| Banking77 (77-way) | bm25 history vote | 0.8190 | 0.089 | 50.6% | 0.9960 | 2.2 |
| Banking77 (77-way) | minilm history vote | 0.9330 | 0.012 | 86.9% | 0.9793 | 8.6 |
| Banking77 (77-way) | hybrid rrf | 0.9370 | 0.052 | 79.2% | 0.9886 | 12.4 |
| SMS (noul) | bm25 history vote | 0.9830 | 0.017 | 95.4% | 0.9916 | 0.9 |
| SMS (noul) | minilm history vote | 0.9820 | 0.009 | 95.8% | 0.9885 | 74.5 |
| SMS (noul) | hybrid rrf | 0.9890 | 0.015 | 95.7% | 0.9969 | 67.2 |
Comparability caveats, stated plainly. The history-vote numbers score a
1,000-item sample drawn by Python's Mersenne-Twister shuffle; the
xerj-decide numbers score the full test split (every Banking77 test row,
every SMS held-out row) — a superset, not the same sample. The history vote
needs a populated history index; xerj-decide needs nothing. Both score
identical source rows from load.py's datasets.
Node: private port, throwaway data dir,
xerj --insecure --port 9410 --data-dir <throwaway> --decide-mode local --decide-model-dir artifact/xerj-decide-v1 (release build of this
4F73
branch,
decide-local feature, CPU, idle machine, single sequential client —
scripts/eval_latency.py --n 100). The "cold" column is the first request
after boot; p50/p99 exclude it.
| surface | hypotheses | cold | p50 | p99 |
|---|---|---|---|---|
POST /_decide (no index, positive_label=spam) |
2 | 15.1 ms | 13.0 ms | 13.9 ms |
POST /v1/systemone, one noul |
2 | 12.9 ms | 13.2 ms | 14.3 ms |
POST /v1/systemone, one 5-option choice |
5 | 20.5 ms | 21.6 ms | 23.5 ms |
POST /v1/systemone, one 77-option choice (full Banking77 vocabulary) |
77 | 232.2 ms | 228.1 ms | 237.1 ms |
Wire spot-checks from the same run (the response names the tier that
answered): the spam noul answered spam at 0.99973 with
"tier": "local", "model": "xerj-decide-local-1"; the ham noul answered
not spam at 0.574 — correct label, weak margin, consistent with the
calibration row above. A 5-way intent choice on "i topped up but the
balance still shows the old amount" picked top_up_failed at 0.837 over
balance_not_updating at 0.015 — defensible, not the label the probe
author expected.
cd benchmarks/decide-model
./scripts/fetch_data.sh data # download + sha256 pin check
mkdir -p work && cp pinned/tokenizer.json work/ # the tokenizer is a frozen input (see below)
cargo run --release -- train --data-dir data --workdir work --epochs 5 --name v1
cargo run --release -- eval --data-dir data --workdir work # the tables above
cargo run --release -- export --data-dir data --workdir work --out artifact/xerj-decide-v1
./scripts/publish_bundle.sh artifact/xerj-decide-v1 dist/xerj-decide-v1The training run is deterministic given the pinned tokenizer (splitmix64 for
every random choice including weight init — candle's CPU RNG cannot be
seeded, so init is written from the harness's own stream; epoch order from
stream_seed; no clock or environment reads in the artifact path). The one
step that is NOT stable across processes is the wordpiece fit itself: the
tokenizers crate's trainer iterates a randomly-seeded HashMap, so two fits
of the same corpus differ by a token or two (measured: 8,740/8,741/8,742 —
and it is not the rayon parallelism; the crate's sequential path varies the
same way). The recipe therefore ships one frozen fit
(pinned/tokenizer.json, committed and mirrored inside the artifact) and
every run — including the leave-one-out runs — trains from it.
Leave-one-out runs: add --exclude banking77 (or --exclude sms) to both
train and eval; the excluded dataset's held-out rows are then scored by
a head that never saw the dataset, with the same frozen tokenizer —
mirroring a published model whose vocabulary is fixed before it meets a new
domain.
Weights: Apache-2.0 from xerj-org. They were trained on:
- Banking77 —
PolyAI-LDN/task-specific-datasets,banking_data/(train 10,003 rows, test 3,080 rows, 77 intents). The repository's LICENSE is Creative Commons Attribution 4.0 International. Casanueva et al., Efficient Intent Detection with Dual Sentence Encoders, 2020 (arXiv:2003.04807). - SMS Spam Collection — UCI ML Repository (Almeida & Hidalgo, 2012, DOI
10.24432/C5CC84), fetched via the
justmarkham/pycon-2016-tutorialmirror thatbenchmarks/decisions-as-retrieval/load.pyuses. CC BY 4.0 per the UCI dataset page.
Those terms govern the datasets and ride with the manifest as the
provenance of what the weights were derived from — they are not a grant
xerj-org makes. MANIFEST.sha256 in the artifact records each training
file's sha256, licence, and citation beside the weights' own licence.
- No published download URL yet — publication needs operator credentials
this build environment does not have;
scripts/publish_bundle.shassembles the complete upload bundle (weights, licence, this card, digest-checked manifest) and prints the exact commands. - No tier-3 (hosted model) comparison here — the Banking77-replay and tier-3 gates are rc.80 scope, not this card's.
- Latency numbers are one machine, one client, CPU; they are measurements, not guarantees.