Development history

September 20, 2026 · View on GitHub

How the released weights were produced, stage by stage (v1 to v9), with the measurements taken at each stage. The reference implementation in this repository folds all of it into one data pipeline and one training recipe (see the README); module and script names mentioned below are the ones used at the time and no longer exist.

the v4 model playing ten games from text state descriptions, plus Mario with the RL checkpoint

The v4 model playing every game in decider/games.py from text state descriptions (one typed decision per step, ~4 ms each), plus Super Mario Bros with the RL checkpoint. Built by decider/montage.py.

Goal: reproduce the shape of TypeSafe AI's Jev (typed decisions with calibrated probabilities, all fields from one forward pass, no token generation) on a single GH200, starting from a modern small open base model.

Design

  • Backbone: Qwen/Qwen3.5-2B-Base (Feb 2026, hybrid linear-attention).
  • Interface: Context + N typed Questions, each with an explicit option list. Every question gets an answer slot Answer k: (; the logits at that slot are restricted to the option-letter tokens A..J and softmaxed. All N decisions come from one forward pass (no decoding).
  • Candidate conditioning: label sets larger than 10 are sub-sampled per example (gold always kept) and shuffled, so the model must read the options. From v6, up to 255 options can be offered at once (see "v6" below).
  • Training objective: cross-entropy (a proper scoring rule) on ~60 public decision datasets (intents, routing, moderation, NLI, MCQ, sentiment, ...). Optional Brier term. Post-hoc temperature is fitted on in-task data and tested on held-out tasks.
  • Evaluation: 47 in-task + 16 held-out (never trained) tasks. Metrics: accuracy, NLL, Brier, ECE, AURC, selective accuracy at 80% coverage. The held-out set is the important one: does calibration transfer to tasks the model never saw?

Serving

DECIDER_MODEL=runs/r3_v2/model .venv312/bin/uvicorn decider.serve:app --host 0.0.0.0 --port 8000
curl -s localhost:8000/decide -H 'content-type: application/json' -d '{"context": "My card was charged twice.",
  "schema": {"Which department?": {"type": "choice", "options": ["billing", "technical", "sales"]},
             "Refund needed?": {"type": "bool"},
             "Urgency?": {"type": "scale", "legend": {"0": "low", "1": "medium", "2": "high"}}}}'

Requests arriving within a few milliseconds are scored in one forward pass. decider.infer.Decider uses the same CUDA-graph engine in-process.

Measured on one GH200 (support-ticket contexts, ~230 tokens, 5 typed fields per request):

latency p50throughput
in-process Decider.decide, eager PyTorch49 ms
in-process Decider.decide, CUDA-graph engine6.6 ms
HTTP, 1 client10 ms97 req/s
HTTP, 4 clients25 ms150 req/s
HTTP, 64 clients231 ms263 req/s = 1314 decisions/s

The single-request cost was launch overhead (1742 kernels per forward, GPU busy 5 ms of 44); CUDA graphs remove it. Batched time was half elementwise kernels, a third matmul and 11% a cuDNN depthwise-conv fallback; torch.compile (with use_cache=False, which avoids the graph breaks) plus a fusable conv fixes the first and last, FP8 (decider/fp8.py, e4m3 weights with per-token activation scales on the Hopper tensor cores) the matmuls.

Engine configurations, same GH200, in-process (decider/engine.py):

enginesingle request (228 tok, 3 q)batch 32decisions/s at batch 32
eager49 ms117 ms837
CUDA graphs7.3 ms117 ms817
+ compile + fused conv (default)4.0 ms70 ms1367
+ FP8 linears (server default)4.1 ms58 ms1666

Accuracy is unchanged across all four (18-task check, 400 examples each: in-task accuracy 0.833 to 0.836, held-out ECE 0.070 to 0.072; see runs/engcmp).

HTTP server with the FP8 engine (default), same request mix, closed-loop clients:

clientsreq/sdecisions/sp50p99
11346696.8 ms7.9 ms
4259129315.5 ms17.5 ms
16334167247.5 ms70.9 ms
644312152126 ms267 ms

Startup captures 72 (batch, length) shapes, about 8 minutes with compile + FP8; set DECIDER_COMPILE=0 for a 30 s start at eager speed.

v4: situation-to-action data and multiple games

The v3 model had no "read a situation, pick the safe action" data, which is why it ran into the first goomba zero-shot. v4 continues training from v3 on 45k such examples plus a 2x replay of the general mixture (decider/data3.py, decider/build_v4.py; one epoch, 35 min):

  • AgentTraj-L (AgentGym): ALFWorld, BabyAI, WebShop, ScienceWorld trajectories -> next action among candidates (20k)
  • Mind2Web: which page element the next operation targets, among candidates (5.9k)
  • synthetic situations from a local Qwen3.5-27B teacher (decider/synth_gen.py): 32 domains, 3-6 options, best action, danger flag (1.5k)
  • teacher-labelled game states from the four training games below (12k) and Mario (6k)

On the 92-task set (T=1.15 fitted on in-task data, applied by default via decider_config.json): in-task acc 0.813 / ECE 0.033 (69 tasks), held-out acc 0.748 / ECE 0.076 (23 tasks), i.e. the same as v3 on the shared tasks, plus AgentTraj next-action 92%, Mind2Web 81%, synthetic 76%.

Ten games behind one interface

decider/games.py renders each game's state as text and provides a scripted teacher; decider/games_eval.py plays every game with the model, the teacher, and random. Four games contribute training data (Pong, Breakout, CliffWalking, MiniGrid Empty); six are never trained on. Three episodes each:

gamesplitrandomteacherzero-shot-r3v4-r7
pongtrain-20.008.00-21.008.00
freewayheld-out0.005.002.006.00
breakouttrain1.3322.007.0022.00
frozenlakeheld-out0.001.000.000.00
cliffwalkingtrain-720.00-13.00-60.00-13.00
blackjackheld-out-1.00-1.00-1.00-1.00
minigrid_emptytrain0.000.960.000.00
minigrid_lavagapheld-out0.000.000.000.00
minigrid_doorkeyheld-out0.000.000.000.00
babyai_gotoheld-out0.200.250.000.00

Atari and toy-text games reach teacher level from text alone; Freeway, never trained on, transfers (and beats its teacher). The grid worlds fail for everyone including the teachers: the egocentric 7x7 view rendered as text is a poor state description and needs a map-based rendering before it measures the model. Blackjack with three seeded hands is uninformative.

RL on the gym games

decider/games_rl.py runs the same PPO loop over the common game interface (text-state or pixels-only policy): 16 environment copies per game, batched sampling, per-game per-step baselines, a success bonus for goal-reaching games, clipped updates with a KL early stop. Starting from the v4 model, 30 iterations on Pong, Breakout and CliffWalking (about 90 s each), greedy evaluation every 5; the saved checkpoint is the best one (iteration 25):

gameSFT startafter RL
Breakout (train)2271
CliffWalking (train)-13-13 (optimal; a mid-run detour to -60 until a success bonus was added)
Pong (train)88
Freeway (held-out)67
MiniGrid Empty (held-out here)00

Breakout, the game with dense reward, tripled over the scripted teacher it had imitated; the others held. Without the success bonus the CliffWalking policy learned to avoid the cliff by never finishing, a standard failure of sparse-goal RL. The first version also produced NaN gradients from an entropy term over masked options (0 times -inf); masked entropy fixed it.

v5: the proper abstention fix

v3/v4 had learned the literal option "none of the above" as an abstain signal (offered verbatim, they abstained even on clear cases). Varying the wording (r9) did not help: the gold-removed augmentation itself teaches "abstain when the exact label is missing", so a coarser but correct option ("billing" for a card charged twice) was rejected. v5 (r10) redefines the augmentation: 75% add an abstain option (twelve wordings) with the answer unchanged; 25% replace the whole option list with labels from an unrelated task so the abstain option is right only when nothing fits; abstain-style options are always kept in prompts so their presence carries no information. Result: a routing battery passes with every wording without the helper rewrite; a held-out probe with off-topic option lists goes from 0.68 (v4) to 0.83; the 91 shared tasks are unchanged (in-task 0.815, held-out 0.742). decider_config.json now carries neutralize_none: false for v5 so the helper stops rewriting the option.

v6: the input shapes Jev accepts

TypeSafe released Jev on 2026-09-15 with public docs. Compared with them, v5 had the same output contract but a much narrower input: bare option labels, at most 10 options, a 1536-token context, plain-text state only, and answers that depended on which other questions shared the prompt. v6 (runs/r11_v6, continued from v5 for one epoch, 2.4 h) closes those:

Jevv5v6
options carry a description or a JSON rubric (criteria)bare labelsname: description / name: {"what", "not_for", "examples"}, trained with opaque names so the description is read
up to 255 options per Choice10 (letters A-J)255: one label token per option (A..Z, then two-letter tokens); 10 or fewer options render exactly as before
~32k-token budget for state + questions1536-token context32k accepted; trained to 16k, probed to 30k
state is a string, object or array; questions point into it by pathtextJSON states, `tickets[3].text` paths, several records per state
every answer independent of the other questionspacked prompt: question k sees questions 1..k-1one row per question; the state is run once and its cache forked to every question
confidence from the shape of the distributiontop probabilityconfidence = top probability (calibrated), plus certainty = 1 - normalised entropy
HTTP API + Python/JS SDKs, cookbooks/decidePOST /v1/systemone with the same wire format: TypeSafe's own SDK works against it (TYPESAFE_BASE_URL); examples/

Not copied: their training method (RLCD) is unpublished, and their workflow evals need their API.

Data (decider/build_v6.py, all derived from the existing mixture, 391k examples, 173M tokens): label descriptions for all 669 fixed labels written by a local Qwen3.5-27B from the label name and five training examples (decider/describe_labels.py; held-out tasks from the name only); described and opaque-named option lists (40k); full native label sets (CLINC 151, Banking 77, MASSIVE 60, ...) and small label sets padded with labels from unrelated task families (89k); JSON states holding 2-60 records with path questions, up to 14k tokens (32k); multi-question examples asked one question at a time or reordered (30k); 200k replay of the general mixture at the original 10-option protocol.

Regression set (94 tasks, original protocol, T fitted on in-task data: 1.15): in-task acc 0.813 / ECE 0.032 (v5 0.815 / 0.028), held-out acc 0.736 / NLL 0.664 / ECE 0.084 (v5 0.738 / 0.678 / 0.084), off-topic abstention 0.832 (0.829), abstain probe 0.63 (0.57). Largest moves: CB 0.89 to 0.80 (56 examples), TREC 0.80 to 0.75. Text games: trained games unchanged; held-out Freeway 6 (v4), 3 (v5), 0 (v6); BabyAI GoTo 0 to 0.31.

Full label sets (every label offered at once; --max_options 255; held-out = dataset never trained on):

taskoptionsv5 accv6 accv6 ECE
HWU64 (held-out)640.2450.8410.018
TREC fine (held-out)500.2920.7580.052
DBpedia level 2 (held-out labels)700.1730.7010.141
DBpedia level 3 (held-out labels)2190.0890.8710.055
CLINC1510.1100.8790.058
Banking77770.1860.8620.020
MASSIVE intent600.2910.8620.027
GoEmotions280.5290.6150.032

examples/hierarchical_beam.py (TypeSafe's taxonomy-walk pattern on DBpedia 9 -> 70 -> 219): v5 needed the walk (flat 0.13, beam-3 0.77); v6 answers the flat 219-way question directly (0.905) and the walk is no longer better (0.85).

Described options (mean accuracy; names replaced by opaque ids such as c7 means only the description identifies an option):

plain namesnames + descriptionsopaque + descriptionsopaque + JSON rubric
8 held-out tasks, v50.7690.7620.7290.691
8 held-out tasks, v60.7630.7840.7710.770
3 in-task (incl. Banking 77-way), v60.8720.8760.8480.848

Descriptions now help slightly over bare names, and an option is usable from its description alone (ECE with opaque names 0.097 to 0.057).

JSON state, question names one record by path (records from held-out tasks; ceiling = one record, 0.72):

records in the state41664~11k tokens20-30k tokens
v50.6000.5100.4320.4450.47
v60.6960.6440.4880.6400.57
v6, array positions written into the state0.6600.5740.665

Records addressed by key stay near the ceiling (0.73 at 16 records); records addressed by array position degrade because the model has to count, so decider.systemone.render_state writes "_index": i into arrays of 8 or more elements (not trained on; it helps anyway). Plain long reading already worked in v5: QuALITY with the whole article (5-8k tokens) 0.71 for both, against 0.51 with the article clipped to 5000 characters.

Independence (decider/independence_probe.py, 7 multi-question tasks). Packed into one prompt, reversing the question order flips up to 17% of v5's answers (up to 12% for v6). Scored one row per question there is nothing to flip, and v6 gives up little for it (accuracy within 1.5 points of packed on every task; v5 lost up to 4). The cost: 5 questions on a 190-token state take 23 ms instead of 10 ms over HTTP. For long states the state is run once and its cache (attention KV and delta-net states) forked per question (Engine.score_shared): 7 questions on an 11k-token state 252 ms instead of 1464 ms, same answers up to bf16 round-off (max |dp| 0.002 in fp32).

from decider.infer import Decider
d = Decider("runs/r11_v6/model")
d.system_one({"ticket": {"messages": [{"from": "customer", "text": "I was charged twice for order A-104. Please refund the duplicate."}]},
              "refund_policy": "Duplicate charges are eligible for a refund."},
             {"department": {"type": "choice", "instructions": "Which team should handle this?",
                             "criteria": {"returns": "Exchanges, refunds, wrong or damaged items", "billing": {"what": "Charges, invoices", "not_for": "delivery"}, "other": None}},
              "refund_requested": {"type": "noul", "instructions": "Does `ticket.messages[0].text` request a refund?"},
              "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["calm", "frustrated", "very frustrated"]}})
DECIDER_MODEL=runs/r11_v6/model .venv312/bin/uvicorn decider.serve:app --port 8000
TYPESAFE_BASE_URL=http://localhost:8000 TYPESAFE_API_KEY=local python your_typesafe_sdk_script.py     # typesafe-sdk 0.6.0, unchanged

v7 to v9: generic options, isolated levels, terse buckets

Each stage is one continuation epoch on the previous weights over the new data plus a replay sample of everything before (runs/r12_v7, r13_v8, r15_v9b; 2-3 h each). The 94-task regression set did not move across them (in-task 0.811-0.813, held-out 0.736-0.741).

v7: the generic option next to a catch-all. v6 sent "the app logs me out every time, fix it" to other when support was also offered, at high confidence: every catch-all in its training data had been the answer whenever nothing specific matched. v7 adds teacher-written data for exactly this case (Qwen3.5-27B writes messages for a domain, labels them over option lists that contain both a generic bucket and a catch-all, then re-checks its own labels from letter logits; labels it disagreed with are kept only under the trust rules in decider/data/mixture.py). Hand battery, generic right / catch-all right: 0.60 / 0.90 to 0.85 / 0.95; teacher-written routing, held-out domains, generic: 0.50 to 0.94. Free-form custom questions (noul / choice / score over teacher-written situations) went 0.94 / 0.96 / 0.74 to 0.96 / 0.98 / 0.83.

v8: isolated Score levels. Jev judges every Score level on its own. Done naively on v7 (one yes/no row per level, "Proposed answer: . Does the proposed answer fit?") the fits summed to 1.4-3.5 and accuracy fell up to 20 points, because the model had only ever seen levels in a list. v8 trains the isolated form (augment.isolated, half of the Score data) and matches listwise scoring within about a point with fits summing to 0.99-1.12. The same run trained the schema-first layout 50/50 with state-first, which is what makes the schema cache possible; its accuracy cost is in the README.

v9: terse buckets and shell commands. v8 needed the generic option to look like a bucket (general_support); a plain support next to other still lost. v9 adds teacher-written messages over terse option lists (support, help, account, no descriptions; lists kept only when the bucket name is a real bucket word) and labelled shell commands (safe / caution / destructive). Held-out terse-bucket messages, generic: 0.59 to 0.86, catch-all 0.93 to 0.88. A first version that up-weighted the generic option over-reached into the buckets on the catch-all side and was dropped in favour of weighting the catch-all examples 3x (r15_v9b, released as v9). Three zero-shot application probes (model router, command safety, browser agent) are in decider/probes/applications.py and the README.

v10: calibration-aware RL on live browser tasks and exact games

v10 continues the v8 weights (the ones on the Hub; v9 was described in the README but the Hub weights were v8) for 384 steps of reinforcement learning. The rewards are outcomes only: whether a live MiniWoB++ click task's own checker reports success, whether a 4x4 minesweeper board, a slippery 5x5 grid or a bag-draw game is won, and how well the model's stated belief about the next outcome of its action matches the exact law (a proper log score). A KL limit to v8 on replayed supervised rows (0.01 nats mean, 0.05 max per step, otherwise the step follows only the KL gradient) and a rendering-consistency term hold the original tasks in place. No gold labels. docs/RL.md has the recipe and the gates.

On the same rows and seeds as v8: live browser tasks 83.0% to 93.2% sampled success (six never-rewarded tasks 72.9% to 91.7%), belief excess over the exact laws 0.47 to 0.22 nats, click-outcome log score −0.35 to −0.03, Mind2Web 81.1% to 82.7%, bag-draw wins +6 points; TypeSafe rows +2 points within noise, 847 in-task validation rows and Bespoke's suite unchanged, OpenJev 64.1% to 63.3%. Greedy browser play barely moved (90.3% to 90.9%): the gain is in the served distribution. Tic-tac-toe, grid and minesweeper play did not change. Recordings: media/v10_browser_*.gif.

Two earlier attempts at this stage did not produce a checkpoint that passed every gate: at peak learning rate 2e-6 the model drifted on short replayed rows after step 384, and an equal-weight win gate over the four environments could not be reached because grid and bag play does not move at this size. v10 comes from the run with the rate halved, the consistency term in every arm and the win gate weighted toward the browser (0.5 browser, 0.25 minesweeper, 0.125 grid, 0.125 bags); all four arms of that run produced an eligible checkpoint and the one with the highest weighted sampled win was released.

decider-35b-a3b: the supervised recipe on a 35B mixture-of-experts base

Released 2026-09-20 as Mapika/decider-35b-a3b (bf16, 65 GB) and Mapika/decider-35b-a3b-nvfp4 (NVFP4 for vLLM and TensorRT-LLM). Scripts in moe/.

Base and what is trained. Qwen3.5-35B-A3B-Base: 34.7B parameters, 40 layers, 256 routed experts with 8 active plus a shared expert per layer, 10 full-attention and 30 gated delta-net layers, 3B active parameters per token. The routed experts (32.2B parameters) are frozen; the remaining 2.45B (attention, delta-net, shared experts, routers, norms, embeddings, LM head) are trained. Freezing the experts keeps optimizer state and gradients at 101 GB per GPU, so one B300 holds a full micro-batch; training the experts as well would need 133 GB with checkpointing and moves 13 times more optimizer state per step.

Throughput probe. With experts_implementation="grouped_mm" (one grouped matrix multiplication per layer for all experts, torch._grouped_mm) one B300 trains the 35B at 12.1k tokens/s with gradient checkpointing and 16.2k without; the eager per-expert loop is 13 times slower and the batched implementation allocates over 500 GB. FP8 linears through torchao were slower than bf16 without torch.compile. The dense 4B model trains at 21.2k tokens/s on the same GPU, so the 35B costs about 1.75x a 4B per token.

Data. The public full mixture of this repository (scripts/train.sh full settings: max_ctx 16384, none_prob 0.1, max_options 255, schema_first_prob 0.5), tokenized once: 1,543,567 items, 1,976,644 questions, 463M tokens. Every rank reads the same item order, so at any step every run has seen identical examples.

Optimizer comparison. Two arms on two GPUs each, the public recipe (cross-entropy on the slot readout, peak learning rate 1e-5, 150 warm-up steps, cosine, 32,768 tokens per step, clip 1.0, no weight decay, FP32 masters): AdamW (betas 0.9 / 0.95) on every trainable parameter against Muon on the 250 block matrices (1.41B parameters: momentum 0.95, Nesterov, 5 Newton-Schulz steps, update scaled by 0.2 sqrt(max(rows, cols)) so the same learning rate applies) with AdamW on embeddings, head, norms, routers, shared-expert gates, convolutions and 1-D parameters (1.04B). The AdamW arm was stopped at step 1,880 (11% of the epoch) on the training-loss evidence: Muon's cross-entropy was lower in 75 of 94 logged windows, 0.537 against 0.605 at step 1,880 and 0.557 against 0.614 averaged over steps 1,500 to 1,880. The preregistered decision rule (regression-set accuracy at the 50% and 100% marks) was therefore not applied and there is no AdamW row in the tables below. Muon was then run from the base on all four GPUs with the same tokens per step: 16,287 steps, 394 minutes, 22,000 to 25,000 tokens/s, 100 GB peak per GPU. Training cross-entropy 0.93 over the first 200 steps, 0.50 at 25%, 0.46 at 50%, 0.43 over the last 300 steps.

Regression set (rebuilt set, 67 in-task / 28 held-out tasks, temperature fitted on in-task data):

checkpointTin-task acc / NLL / ECEheld-out acc / NLL / ECE
base, zero-shot1.270.732 / 0.693 / 0.0960.749 / 0.650 / 0.082
25% of the epoch0.960.839 / 0.399 / 0.0320.803 / 0.522 / 0.073
50%1.080.850 / 0.370 / 0.0280.813 / 0.488 / 0.064
100% (released)1.080.855 / 0.357 / 0.0260.810 / 0.497 / 0.069
decider-2b v101.300.805 / 0.474 / 0.0370.755 / 0.622 / 0.084

Half the epoch gives 99% of the final in-task accuracy and all of the held-out accuracy. The released checkpoint is above v10 on 93 of 95 tasks (0.6 points below on counterfactual detection and offensive-tweet detection); the largest gains are MedQA +31 points, MedMCQA +24, TruthfulQA +22, Winogrande +20, MMLU +19, StrategyQA +19. Fixtures, external suites, browser and game results against v10 are in the README (section "decider-35b-a3b") and in the model card.

What the size does not buy. The model was not RL-trained. On live browser tasks its greedy play is above v10 (97.2% against 90.9%) but its sampled play is below (86.4% against 93.2%, held-out tasks 79.2% against 91.7%): the argmax is right more often, the served distribution is less sharp. Game play under sampling is level with v10 (24.1% against 23.7% wins) while greedy play is 11 points higher. Hard-tier JevBench items are answered with a top-label ECE of 0.15.

Serving. The merged checkpoint loads through the unchanged decider package (config.json carries experts_implementation: grouped_mm; transformers>=5.17, torch>=2.14); use_graphs=False, because the CUDA-graph engine and the FP8 path were not tested with this architecture. One B300, eager: 47 ms per request, 522 decisions/s in batches of 64 support tickets with three questions, 575 short single-question states/s.

NVFP4 build. moe/ptq_nvfp4.py quantizes the merged checkpoint with NVIDIA ModelOpt 0.46.1 (NVFP4_DEFAULT_CFG: 4-bit floating-point weights and activations with FP8 block scales, block 16, on the attention projections, the delta-net in_proj_qkv, in_proj_z and out_proj, the shared expert and all 256 routed experts of every layer; embeddings, head, routers, shared-expert gates, delta-net convolutions and in_proj_a / in_proj_b stay in bf16; KV cache unquantized), calibrated on 512 training prompts of at most 2,048 tokens, and exports the Hugging Face layout with hf_quant_config.json (19.6 GB, 5 shards). Fake quantization in PyTorch on a 150-row subset of every regression task: in-task accuracy 0.861 to 0.857, NLL 0.353 to 0.360; held-out 0.818 to 0.809, NLL 0.492 to 0.502; per task −0.55 points on average, the largest drops on the abstention probe (−6.0), StrategyQA (−5.3) and Social IQa (−4.7) at 150 rows each. moe/vllm_check.py serves a checkpoint through vLLM 0.29 with the label tokens as the only allowed ids and processed_logits as the logprob mode, which reproduces the package readout: the bf16 checkpoint through vLLM agrees with the in-process predictions on 99.0% of the TypeSafe rows and 99.8% of the validation rows (mean total variation 0.010 / 0.005), at 36 TypeSafe packets/s and 498 validation rows/s on one B300. The NVFP4 export through the same vLLM path: TypeSafe accuracy 0.843 (bf16 in vLLM 0.853), argmax agreement with the in-process bf16 predictions 96.1%, mean total variation 0.051, 51 packets/s; validation rows 0.882 (0.897), agreement 96.7%, total variation 0.030, 274 rows/s. The loss of 1.0 to 1.5 points is larger than the fake-quant estimate; the throughput numbers are from single batches of a few seconds.

Vision: decisions from pixels

Qwen3.5-2B is a vision-language model; decider/vision.py uses the full model with the same lettered prompt and slot logits, so an image (or a game frame) goes in front of the text and all answers still come from one forward pass. A 256x240 game frame costs 64 visual tokens. Training (decider/train_vision.py): frames from the games and Mario labelled by the scripted teachers (decider/frames_data.py, rare actions oversampled), DAgger frames from the model's own play (decider/frames_dagger.py), multiple-choice image tasks from The Cauldron (decider/data_vision.py: A-OKVQA, AI2D, ScienceQA, IconQA, TQA, Raven, Hateful Memes; Visual7W and VSR held out), and a text replay. v1 started from the base VLM; v2 from the v4 text weights transplanted into the VLM (decider/transplant.py), which keeps the text skills (abstention probe 84% vs 54% in v1).

Image tasks (v2, 300 items each): A-OKVQA 85%, AI2D 93%, ScienceQA 95%, IconQA 94%, Raven 80%, Hateful Memes 80%; held-out Visual7W 87% and VSR 75%, ECE 0.03 to 0.05. Frame-level agreement with the teachers: Pong 96%, Breakout 96%, CliffWalking 100%, MiniGrid Empty 100%, Mario 77%.

Playing from pixels only (no text state; decider/games_pixels.py, 3 episodes):

gamesplitteacher (text state)base VLM zero-shotvision v1vision v2
pongtrain8.00-21.00-21.003.00
breakouttrain22.000.000.001.00
cliffwalkingtrain-13.00-6000.00-60.00-13.00
minigrid_emptytrain0.960.000.960.96
freewayheld-out5.008.005.006.00
frozenlakeheld-out1.00nan0.000.00
blackjackheld-out-1.00nan-0.33-1.00
minigrid_lavagapheld-out0.00nan0.000.00
minigrid_doorkeyheld-out0.00nan0.000.00
babyai_gotoheld-out0.25nan0.000.30
mario 1-1 (px)train2023898315315

RL from pixels

games_rl.py --vision runs the same PPO loop with the vision model as the policy (frames only). From the v2 model, 16 iterations on Breakout and Pong (16 environments each, 300-step rollouts, about 3.5 minutes per iteration), greedy evaluation every 4:

iterationBreakoutPongFreeway (held-out)MiniGrid Empty
0 (v2 supervised)1360.96
419-970.96
811890.96
1216880.96
1626-580.96

Reward fixed what imitation could not: the relaunch after a lost life (the DAgger frames came from a policy that never launched), and by iteration 16 Breakout from pixels exceeds the RAM-state teacher (22). Pong moves with the same updates and is not stable across checkpoints. The published vision model uses the iteration-12 checkpoint (the balanced one).

v3 (vision release, built on the v5 text weights, then 12 iterations of pixel RL; best checkpoint iteration 4): Breakout 41, Pong 3, CliffWalking -13, MiniGrid Empty 0.96, Freeway 0, BabyAI 0, Mario 315. It trades v2's Pong (8) and Freeway (8) for Breakout and the corrected abstention.

Frame accuracy does not equal play: v1 never launched the ball in Breakout (a rare action in the training frames) and lost every Pong point; oversampling and DAgger fixed CliffWalking and got Pong to +3 and MiniGrid Empty to teacher level from the image (the text rendering of that game never worked). v2 still launches the ball only once per game (after a lost life it answers "stay"), because the DAgger frames came from a policy that never launched. Mario from pixels dies at the first goomba; the frame gives less warning than the RAM-derived text. Pixel RL on Breakout and Pong is the next step and runs the same games_rl.py --vision.

Demo: Super Mario Bros from typed decisions

decider/mario.py drives the NES emulator (gym-super-mario-bros) with the model: every 4 frames the emulator RAM is rendered as a short text state (ground, gaps, pipes, enemies ahead), the model answers one choice field ("What should Mario do right now?": run right / jump right / long jump / jump up / step left / wait) plus a bool ("Is Mario in immediate danger?"), and the action is held for the next frames. Decisions take ~4 ms, far below the 67 ms of 4 frames.

uv pip install -p .venv312/bin/python gym-super-mario-bros==7.4.0 nes-py "gym==0.23.1" imageio imageio-ffmpeg
.venv312/bin/python -m decider.mario runs/r3_v2/model --episodes 3 --video mario.mp4
.venv312/bin/python -m decider.mario --policy heuristic     # scripted baseline on the same state text

Results on World 1-1 (3200 px long, deterministic emulator, 3 episodes each):

policydistance
button spam (right + jump)698 px
decider-2b zero-shot315 px (runs into the first goomba; danger field 0.07)
scripted teacher on the same state text2023 px
decider-2b fine-tuned on 6k teacher-labelled states (2 epochs, 5 min)2023 px

Zero-shot, the classifier has no game sense: with a goomba one tile ahead it still says "run right" at 0.85. After the short fine-tune (decider/mario_data.py labels states with the scripted policy plus random-action noise for coverage, mixed with a replay of the general data so the model keeps its other abilities) it reproduces the teacher exactly, reading only the text, at 4.4 ms per decision. On seven levels never used for training (1-4, 2-2, 2-3, 3-2, 4-2, 7-1, 8-1) it matches the teacher's distance within a few pixels on six and beats it on 2-2, so it learned the state-to-action mapping rather than a trajectory; its ceiling is the teacher's rules (--level, --noop_start to desync the deterministic emulator). Videos: media/mario_zeroshot.gif, media/mario_finetuned.gif (media/mario_finetuned.mp4).

zero-shot fine-tuned

RL on top of imitation

The softmax over action options is a policy, so decider/mario_rl.py trains it with PPO-clip directly against the emulator: 48 emulators in parallel, one batched forward per decision, reward = tiles gained per decision, a death penalty and a flag bonus, per-level per-step baselines, 4 minibatch steps per iteration with a KL early stop. Warm-started from the imitation checkpoint, 40 iterations (~100 s each) on 8 training levels; greedy evaluation on those 8 and on 7 unseen levels:

train levels (mean px)unseen levels (mean px)
imitation start766774
RL, best checkpoint (iter 20)10171080
RL, final (iter 40)1072780

Individual levels moved a lot (6-1: 502 to 2812 at one checkpoint; 1-1 past the teacher's 2023 to 2226; 3-2: 1125 to 2013), and sampled rollouts finished level 1-1, which the teacher never did. Plain REINFORCE at a higher learning rate collapsed the policy within 10 iterations and at a lower one did not move it; the clipped update with per-level baselines was what made it learn. GIFs: media/mario_rl_1-1.gif, media/mario_rl_6-1.gif.

Layout

decider/data.py        task registry -> Example(context, [Q(text, options, gold)])
decider/prompt.py      prompt/slot construction, multi-question packing
decider/model.py       DecisionModel: hidden state at slots -> letter logits
decider/train.py       finetune (bucketed shapes, bf16, grad-ckpt)
decider/evaluate.py    per-task metrics, saves probs
decider/report.py      side-by-side comparison + temperature scaling
decider/engine.py      Engine: shape-bucketed CUDA graphs (7x lower single-request latency than eager)
decider/serve.py       micro-batching HTTP server (POST /decide {context, schema})
decider/loadtest.py    closed-loop load test against the server
decider/bench_engine.py  eager vs CUDA graph vs torch.compile on fixed shapes
decider/bench_latency.py  decisions/s and latency of the one-pass interface (eager)
data/tasks.pkl    cached examples (python -m decider.data)
runs/             zs_2b, zs_4b (zero-shot baselines), r1_200k, ...

Model weights: https://huggingface.co/Mapika/decider-2b (each release is staged with scripts/stage_release.py and uploaded with scripts/upload_hf.py). Setup: uv venv --python 3.12 .venv312 && uv pip install -p .venv312/bin/python torch transformers peft accelerate datasets pillow "numpy<2" scikit-learn flash-linear-attention fastapi "uvicorn[standard]" httpx.