Mapika commited on
Commit
7614a6c
·
verified ·
1 Parent(s): ff99089

decider-2b v12: text and image decisions (vision/), Noul T 0.05; v11 under tag v11

Browse files
README.md CHANGED
@@ -3,516 +3,155 @@ license: apache-2.0
3
  base_model: Qwen/Qwen3.5-2B-Base
4
  language: [en]
5
  pipeline_tag: text-classification
6
- tags: [decision-model, calibrated, structured-output, multi-task, system-one, one-pass]
7
  ---
8
 
9
- # decider-2b: typed decisions with calibrated probabilities in one forward pass
10
 
11
  A language model that does not generate text. It reads a state and one or more typed questions, each with an explicit option
12
- list, and returns a probability distribution over the options for every question from one forward pass. There is no decoding,
13
- no parsing and no output outside the options you defined. It is called from software, not chatted with. It is an open
14
- reproduction of the "System One" model class (TypeSafe AI's Jev).
15
 
16
- Base model: [Qwen/Qwen3.5-2B-Base](https://huggingface.co/Qwen/Qwen3.5-2B-Base) (1.9B parameters). The supervised stages
17
- (v1 to v8) fine-tune it with cross-entropy, a proper scoring rule, on a mixture of about 95 public decision datasets, agent
18
- trajectories, web element choice, game states and teacher-written custom questions, in two prompt layouts and with isolated
19
- Score levels. v10 continued the v8 weights for 384 steps of calibration-aware reinforcement learning whose only rewards are
20
- outcomes (live browser task checkers and the exact probability laws of games), with a hard KL limit to the v8 weights on
21
- replayed training rows. **This repository holds v11**: v10 plus a LoRA of rank 64, trained for 2 epochs on 42,749 rows of harder
22
- decisions and replay, with the replay rows trained toward v10's own answer distribution, merged into the weights, and one
23
- temperature per answer type. v10 stays available under the Hub tag `v10` and v8 under `v8`. Code, data registry, training
24
- scripts and the recipe are at https://github.com/Mapika/decider; `decider/` in this repository is the inference subset of that
25
- package. The other sizes and the vision variant are listed under The decider family.
26
 
27
- v11 is better than v10 on hard decisions and worse on some everyday and game rows. On the same rows: held-out generated decision
28
- families 0.429 against 0.324, held-out document questions 0.753 against 0.646, JevBench public hard tier 0.577 against 0.459, and
29
- better calibrated on both hard sets than v10 (0.156 against 0.226 and 0.175 against 0.307), though still overconfident there.
30
- Losses: human-labelled public sets −2.2 points, a knowledge guard set −1.6, greedy bag-draw play −10.9, sampled slippery-grid
31
- play −4.3, sampled browser play −2.8 (interval includes zero), TypeSafe −4.9 (interval includes zero). Details under
32
- [Changes from v10](#changes-from-v10).
33
 
34
- **Contents:** [Changes from v10](#changes-from-v10) · [The decider family](#the-decider-family) · [Usage](#usage) · [How it works](#how-it-works) · [Field types](#field-types) · [Training](#training) · [Evaluation](#evaluation) · [Speed](#speed) · [Limitations](#limitations) · [Changelog](#changelog) · [Reproduction](#reproduction)
35
 
36
- ## Changes from v10
37
-
38
- v11 is v10 plus one supervised LoRA stage (described under Training) and a per-type temperature map:
39
-
40
- * **Harder decisions.** The same 22,649 labelled rows as the stage 2 of decider-4b v2.1: 8,000 rows from ten generated decision
41
- families whose answers the generating code computes, 11,356 questions over business documents written by Qwen3.6-27B and kept
42
- when two further independent answers agreed, and 3,293 human-labelled rows.
43
- * **Replay trained toward v10's own distribution.** 20,100 replay rows from the public decision mixture, trained toward v10's
44
- answer distribution (loss KL(p_v10 ‖ p_model) over the options) instead of their labels, so that everyday answers stay close
45
- to v10's. On 1,000 held-out replay rows the mean KL at temperature 1 is 0.047 nats and 94.9% of the argmax answers equal
46
- v10's. The fitted temperature is 1.145 (v10 1.30).
47
- * **One temperature per answer type.** `decider_config.json` has `temperature` 1.145 and `temperature_by_type`
48
- `{"choice": 1.164, "noul": 1.624, "score": 1.124}`, fitted by NLL with `decider.calibrate` (decider-ai 1.4.0). decider-ai 1.4.0
49
- and later use the map. **decider-ai 1.3.0 and earlier ignore the map and serve every answer at 1.145**; a temperature does not
50
- change which option is most probable, so the answers are the same either way (except exact ties between the level rows of an
51
- isolated Score answer: 1 row of 5,000 on the held-out generated families and 1 of 944 on the teacher validation rows changed);
52
- the probabilities differ, mostly on yes/no answers.
53
- * The model name in answers is `decider-2b-v11` (v10 reported `decider-v10`).
54
- * `decider_config.json` no longer marks the model as trained for the schema-first layout (`schema_first_trained: false`; v10:
55
- true), because the LoRA stage used only the state-first layout and no schema-first temperature was fitted. With
56
- `DECIDER_SCHEMA_CACHE=1` the server therefore does not turn the schema cache on for v11. `Decider.schema()` still runs; its
57
- accuracy was not measured on v11.
58
-
59
- All rows below are on identical inputs and seeds: v11 through decider-ai 1.4.0 with its map, v10 through decider-ai 1.3.0 at its
60
- stored temperature 1.30, in the same session on 2026-09-24 (so the v10 numbers can differ slightly from the ones first published
61
- for v10 further down). Intervals are 95% paired bootstrap intervals (rows for the fixtures, boards for the games, task-seed pairs
62
- for the browser). The regression set and our own sets were read from stored temperature-1 logits at each model's served
63
- temperatures. v10's JevBench file was read earlier through decider-ai 1.2.1; accuracy does not depend on the temperature.
64
-
65
- | set | v10 (T 1.30) | v11 (map) | v11 minus v10 |
66
- |---|---|---|---|
67
- | regression set, 67 in-task tasks, accuracy / NLL / ECE | 0.806 / 0.474 / 0.038 | 0.802 / 0.481 / 0.038 | −0.4 |
68
- | regression set, 28 held-out tasks | 0.755 / 0.622 / 0.084 | 0.752 / 0.626 / 0.083 | −0.3 |
69
- | held-out generated families (heldout_jb), 5,000 rows, accuracy / ECE | 0.324 / 0.226 | 0.429 / 0.156 | +10.5 |
70
- | held-out document questions (test_teacher2), 449 rows, accuracy / ECE | 0.646 / 0.081 | 0.753 / 0.075 | +10.7 |
71
- | human-labelled public sets (cal_human), 1,595 rows, accuracy | 0.803 | 0.781 | −2.2 |
72
- | knowledge guard (MMLU, ARC and others), 2,994 rows, accuracy | 0.732 | 0.715 | −1.6 |
73
- | 847 in-task validation rows, accuracy / NLL | 83.4% / 0.444 | 81.9% / 0.468 | −1.4 (−3.0 to +0.1); NLL +0.024 (+0.008 to +0.039) |
74
- | OpenJev, 5,252 rows, accuracy / NLL | 63.2% / 0.917 | 64.6% / 0.860 | +1.4 (+0.6 to +2.2); NLL −0.057 (−0.068 to −0.046) |
75
- | Mind2Web, 1,770 rows, accuracy / NLL | 82.6% / 0.543 | 83.9% / 0.495 | +1.3 (+0.1 to +2.5); NLL −0.048 (−0.074 to −0.023) |
76
- | TypeSafe workflow decisions, 102 rows, accuracy / NLL | 80.4% / 0.585 | 75.5% / 0.604 | −4.9 (−10.8 to +1.0); NLL +0.018 (−0.088 to +0.135) |
77
- | JevBench public items, easy / standard / hard accuracy | 1.000 / 0.889 / 0.459 | 1.000 / 0.889 / 0.577 | hard +13 items |
78
- | JevBench hard tier, top-label ECE | 0.307 | 0.175 | |
79
- | Bespoke's public suite, macro / micro | 0.703 / 0.711 | 0.706 / 0.711 | +0.3 macro |
80
- | live MiniWoB++, sampled, all 22 tasks | 93.2% | 90.3% | −2.8 (−7.4 to +1.1) |
81
- | live MiniWoB++, sampled, 16 rewarded tasks | 93.8% | 89.8% | −3.9 (−9.4 to +0.8) |
82
- | live MiniWoB++, sampled, 6 held-out tasks | 91.7% | 91.7% | 0.0 (−8.3 to +8.3) |
83
- | live MiniWoB++, greedy, all 22 tasks | 91.5% | 92.0% | +0.6 (−3.4 to +4.5) |
84
- | live MiniWoB++, greedy, 16 rewarded tasks | 91.4% | 92.2% | +0.8 (−4.7 to +6.2) |
85
- | live MiniWoB++, greedy, 6 held-out tasks | 91.7% | 91.7% | 0.0 (−6.2 to +6.2) |
86
- | zero-shot games, 234 boards, sampled, win rate | 23.9% | 22.6% | −1.3 (−3.4 to +0.9) |
87
- | bag-draw games, 64 boards, sampled, win rate | 41.8% | 41.8% | 0.0 (−5.5 to +5.5) |
88
- | slippery-grid games, 64 boards, sampled, win rate | 19.1% | 14.8% | −4.3 (−7.8 to −1.2) |
89
- | zero-shot games, 234 boards, greedy, win rate | 26.9% | 24.8% | −2.1 (−5.6 to +1.3) |
90
- | bag-draw games, 64 boards, greedy, win rate | 57.8% | 46.9% | −10.9 (−20.3 to −3.1) |
91
- | slippery-grid games, 64 boards, greedy, win rate | 15.6% | 15.6% | 0.0 (0.0 to +0.0) |
92
- | ten text games, greedy: Pong / Breakout / CliffWalking / BabyAI-GoTo / Freeway / Blackjack | 8 / 22 / −13 / 0.18 / 0 / −1 | 8 / 22 / −13 / 0.19 / 1 / −1 | |
93
- | behaviour probes: model-router tier / needs-live-data (31 items) | 0.903 / 0.806 | 0.935 / 0.774 | +1 / −1 item |
94
- | behaviour probes: command risk / touches-outside-project (45 items) | 0.733 / 0.556 | 0.822 / 0.556 | +4 / 0 items |
95
- | behaviour probes: generic bucket / catch-all / abstention battery / browser element and action | 0.85 / 0.95 / 8 of 8 / 0.938 and 0.875 | 0.85 / 0.95 / 8 of 8 / 0.938 and 0.875 | |
96
- | issue #9 form cases, right of 4 | 0 | 0 | |
97
-
98
- **Gains.** On the two held-out hard sets, whose families and business domains are not in the training data, v11 is 10.5 and 10.7
99
- points above v10, and on JevBench's public hard tier 13 items (11.7 points) above it. OpenJev +1.4 and Mind2Web +1.3 points
100
- (intervals exclude zero), command-risk probe +4 items. Calibration on hard items is better than v10's: 0.156 against 0.226 on the
101
- held-out generated families and 0.175 against 0.307 on the JevBench hard tier.
102
-
103
- **Losses, stated plainly.**
104
- * Human-labelled public sets (the validation halves of MMLU, ARC, CommonsenseQA, BoolQ, MNLI, SNLI, Banking77 and others): 0.781
105
- against 0.803, −2.2 points. A knowledge guard set (MMLU, ARC and similar): 0.715 against 0.732, −1.6 points.
106
- * Greedy bag-draw play: 46.9% against 57.8% wins, −10.9 points (interval −20.3 to −3.1). Sampled slippery-grid play: 14.8% against
107
- 19.1%, −4.3 points (interval −7.8 to −1.2).
108
- * Sampled browser play: 90.3% against 93.2%, −2.8 points (interval −7.4 to +1.1, includes zero); on the 16 rewarded tasks −3.9
109
- (interval −9.4 to +0.8). Greedy browser play and the six held-out tasks are level.
110
- * TypeSafe workflow decisions −4.9 points (interval −10.8 to +1.0), 847 validation rows −1.4 (−3.0 to +0.1), regression set −0.4
111
- in-task and −0.3 held-out; needs-live-data probe one item lower.
112
- * Still overconfident on hard items: calibration error 0.156 on the held-out generated families, where our release limit is 0.08.
113
- * The issue #9 form cases are 0 of 4, as for v10.
114
-
115
- **How v11 was chosen, and why it is released although it did not pass.** The run had a pre-registered release rule for the 2B.
116
- A checkpoint was eligible only if it lost at most 1 point on the human-labelled sets against v10; every checkpoint lost more
117
- (v11: −2.2), so no checkpoint was eligible. The rule's fallback candidate is v11 (two arms tied within 0.002 on the selection
118
- score; the tie went to the higher regression in-task accuracy). It passes the numeric items (regression accuracy at most 1.0 point
119
- in-task and 0.5 held-out under v10, held-out generated families at least 5 points and held-out document questions at least 3
120
- points above v10, regression ECE at most 0.01 above v10's, sampled browser, zero-shot and bag-draw play not shown to be below
121
- v10's: upper end of the 95% interval at least 0) and fails the calibration item:
122
- calibration error on the held-out generated families at most 0.08. v11 is at 0.171 at its global temperature and 0.156 with the
123
- map; v10 is at 0.226 and would fail the same item. A second pre-registered rule for the temperature map had the same 0.08 limit,
124
- so the map did not pass either. v11 is released with the map on a decision made after reading the full comparison above. The
125
- JevBench public items were read once for v11 at its global temperature and once with the map, after the rule decisions; they
126
- were not used for training, selection or the temperatures.
127
-
128
- **Which version to use.**
129
- * v11 (this revision): the default. Hard judgments, long policies, document questions.
130
- * v10 (`revision="v10"`): if you rely on sampled browser play, greedy bag-draw or slippery-grid play, knowledge multiple choice or
131
- TypeSafe-style workflow decisions, where v10 is ahead (see the losses above).
132
-
133
- The package loads a local folder, so download the revision first:
134
-
135
- ```python
136
- from huggingface_hub import snapshot_download
137
- from decider.infer import Decider
138
- d = Decider(snapshot_download("Mapika/decider-2b", revision="v10"))
139
- ```
140
-
141
- For the HTTP server, set `DECIDER_MODEL` to the same downloaded folder.
142
 
143
  ## The decider family
144
 
145
- All six repositories share one interface (`decider.infer.Decider`, `POST /v1/systemone` in TypeSafe's format) and one
146
- readout: the letter logits at an answer slot, softmaxed over the options. Pick by size and input.
147
-
148
- | model | base | weights | use it for | numbers |
149
- |---|---|---|---|---|
150
- | [decider-2b](https://huggingface.co/Mapika/decider-2b) v11 | Qwen3.5-2B-Base | 3.8 GB bf16 | the default: routing, classification, judgments, browser agents; 4 ms per request with CUDA graphs on one GPU; v10 under the tag `v10` | regression set 0.802 in-task / 0.752 held-out; JevBench hard 0.577; live browser 90% sampled; Bespoke suite 0.706 |
151
- | [decider-4b](https://huggingface.co/Mapika/decider-4b) v2.1 | Qwen3.5-4B-Base | 8.4 GB bf16 | the middle point: knowledge questions and hard judgments above the 2B in a dense 8.4 GB model; no RL stage; v2 and v1 under the tags `v2` and `v1` | 0.831 / 0.784; JevBench hard 0.649; live browser 93% sampled; Bespoke 0.756 |
152
- | [decider-35b-a3b](https://huggingface.co/Mapika/decider-35b-a3b) v1 | Qwen3.5-35B-A3B-Base (3B active) | 65 GB bf16 | when accuracy is worth 3 to 4 times the cost per decision: knowledge and multi-step questions, long policies | 0.855 / 0.810, above the 2B on 93 of 95 tasks; JevBench hard 0.676; Bespoke 0.774; no RL stage |
153
- | [decider-35b-a3b-nvfp4](https://huggingface.co/Mapika/decider-35b-a3b-nvfp4) | the 35B in NVFP4 | 19.6 GB | the 35B on Blackwell through vLLM or TensorRT-LLM | 1.0 to 1.5 points under bf16 on the measured fixtures |
154
- | [decider-0.8b](https://huggingface.co/Mapika/decider-0.8b) | Qwen3.5-0.8B-Base | 1.4 GB bf16 | the smallest: routing, yes/no and short-state lookups within 1 to 4 points of the 2B, 1.5x faster | 0.776 / 0.707 on the single-run protocol (2B: 0.809 / 0.739) |
155
- | [decider-2b-vision](https://huggingface.co/Mapika/decider-2b-vision) | Qwen3.5-2B vision-language, v5 text weights | 4.1 GB bf16 | decisions from an image plus a question; game frames | Visual7W 0.89; Breakout 41 from pixels |
156
 
157
- Code, data registry, training scripts, the changelog and the per-version history: https://github.com/Mapika/decider.
 
158
 
159
  ## Usage
160
 
 
 
161
  ```python
162
- from decider.infer import Decider # decider/ is included in this repo
163
  d = Decider("Mapika/decider-2b")
164
  d.decide("My card was charged twice for the same purchase.",
165
  [{"question": "Which department should handle this?", "options": ["billing", "technical support", "sales"]},
166
  {"question": "Does this need a refund action?", "options": ["no", "yes"]}])
167
- # [{'choice': 'billing', 'confidence': 0.93, 'probs': {...}}, {'choice': 'yes', 'confidence': 0.70, 'probs': {...}}] (v11, decider-ai 1.4.0, eager)
168
  ```
169
 
170
- `decide_batch` scores many states, each with many questions, in one call. `abstain_below=t` returns `None` for decisions with
171
- confidence under `t`. A question can have 2 to 255 options (more than 10 options use one label token per option, see
172
- `decider/prompt.py`).
173
-
174
- The same request shape as TypeSafe's Jev (`POST /v1/systemone`), in process or over HTTP:
175
 
176
- ```python
177
- d.system_one({"ticket": {"messages": [{"from": "customer", "text": "I was charged twice for order A-104. Please refund the duplicate."}]},
178
- "refund_policy": "Duplicate charges are eligible for a refund."},
179
- {"department": {"type": "choice", "instructions": "Which team should handle this?",
180
- "criteria": {"returns": "Exchanges, refunds, wrong or damaged items",
181
- "billing": {"what": "Charges, invoices", "not_for": "delivery"}, "other": None}},
182
- "refund_requested": {"type": "noul", "instructions": "Does `ticket.messages[0].text` request a refund?"},
183
- "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["calm", "frustrated", "very frustrated"]}})
184
- # {"model": "decider-2b-v11", "answers": {"department": {"type": "choice", "choice": "billing", "confidence": ..., "certainty": ..., "probabilities": {...}},
185
- # "refund_requested": {"type": "noul", "noul": ...}, "frustration": {"type": "score", "score": ..., "legend": {...}, ...}}, "usage": {...}}
186
  ```
187
 
188
- The state may be a string, object or array (up to 32k tokens with the questions). `instructions` and every option
189
- description may be a string or any JSON value. Question ids are never shown to the model. Each question is scored in its own
190
- row, so an answer does not depend on which other questions are asked (`independent=False` packs them into one row, about half
191
- the latency for short states). Each Score level is likewise judged in its own row, without its number or its neighbours, and the
192
- per-level fits are normalised (`"isolated": false` restores listwise scoring). The answer also reports `level_fit` and their
193
- sum `fit_mass`, which is near 1 when exactly one level fits.
194
-
195
- For a fixed set of questions, `s = d.schema(questions)` computes the question prefix once and `s(state)` / `s.batch(states)`
196
- then run only the state (1.2 to 2.4x faster per request, up to 19x per batch). It uses a questions-first prompt layout that
197
- costs accuracy: about 1.5 points on fixed label sets, 5 on per-example options, more on 50 or more options and on states of
198
- several thousand tokens. `decider.serve` exposes the same thing as `POST /v1/systemone`; the official `typesafe-sdk` works
199
- against it unchanged with `TYPESAFE_BASE_URL` pointing at the server.
200
-
201
- Requirements: `torch`, `transformers>=5`, and `flash-linear-attention` (Triton kernels for the Qwen3.5 linear-attention
202
- layers; the model runs without it but several times slower). Python 3.11 or newer lets those kernels use `torch.compile`.
203
-
204
- Without the helper package, the same computation in plain `transformers`:
205
-
206
  ```python
207
- import torch
208
- from transformers import AutoTokenizer, AutoModelForCausalLM
209
- tok = AutoTokenizer.from_pretrained(REPO); m = AutoModelForCausalLM.from_pretrained(REPO, dtype=torch.bfloat16).cuda().eval()
210
- prompt = ("Context:\nMy card was charged twice for the same purchase.\n\n"
211
- "Question: Which department should handle this?\nOptions:\n(A) billing\n(B) technical support\n(C) sales\nAnswer: (")
212
- ids = tok(prompt, return_tensors="pt").to("cuda")
213
- with torch.no_grad():
214
- logits = m(**ids).logits[0, -1]
215
- letters = [tok.encode(L, add_special_tokens=False)[0] for L in "ABC"]
216
- probs = torch.softmax(logits[letters].float() / 1.164, -1) # -> P(billing), P(technical support), P(sales); 1.164 is the stored choice temperature
217
  ```
218
 
219
- For several questions in one pass, append further `Question k: ... Answer k: (` blocks and read the logits at each `(`
220
- position (see `decider/prompt.py`).
221
-
222
- ## How it works
223
-
224
- The prompt is `Context: ...` followed by, for each question, the question text, the lettered options `(A) ... (B) ...` and an
225
- answer slot `Answer k: (`. The hidden state at each slot is projected with the option-letter rows of the LM head and softmaxed
226
- over the valid letters, divided by the temperature in `decider_config.json`. Letters are never generated, so all slots are read
227
- from one pass. From decider-ai 1.4.0 the config may also hold `temperature_by_type`, one temperature per answer type
228
- (`choice`, `noul`, `score`; a missing type uses `temperature`). This release's config has such a map: Choice answers use 1.164,
229
- yes/no (noul) answers 1.624, Score answers 1.124 on each of their level rows. Package versions before 1.4.0 use `temperature`
230
- (1.145) for every answer. `DECIDER_TEMPERATURE=T` or `Decider(path, temperature=T)` replaces `temperature` with T and switches the
231
- map off, so every answer then uses T. Large label sets were sub-sampled to at most 10 options per training example (gold always kept, order shuffled),
232
- so the model conditions on the supplied candidates rather than on a fixed head.
233
-
234
- ## Field types
235
-
236
- * **noul**: probability of "yes".
237
- * **choice** with `criteria` {name: description | JSON | null}: the argmax option, its probability (`x_p_max`, the calibrated
238
- number), `confidence` (TypeSafe's definition, `(n·x_p_max − 1)/(n − 1)` for n options), `certainty` (1 minus the normalised
239
- entropy) and the full distribution.
240
- * **score** with `criteria` [level descriptions]: the expected level, the probability of the most likely level (`x_p_max`),
241
- `confidence` (TypeSafe's definition: 1 minus the expected distance from the most likely level, divided by the mean distance of
242
- the levels from the middle of the scale, floored at 0), the distribution, and the per-level fits.
243
-
244
- Before decider-ai 1.3.0, `confidence` in `system_one` and `POST /v1/systemone` answers was the probability that is now `x_p_max`.
245
- The plain form (`decide`, `POST /decide`) still reports the top probability as `confidence`.
246
 
247
  ## Training
248
 
249
- **Supervised stages (v1 to v8).** One epoch on a mixture of public decision datasets (intent detection, ticket routing, topic
250
- classification, sentiment, emotion, moderation, NLI, paraphrase, fact verification, passage relevance, reading comprehension,
251
- multiple-choice QA, ordinal rating scales, pairwise response preference, tool selection), then continuation epochs that added
252
- next-action choice from agent trajectories (AgentGym), web element choice (Mind2Web), teacher-written situations and game states,
253
- the input shapes of the Jev API (described options, up to 255 options, JSON states with path references, long inputs), teacher-
254
- written custom questions with a generic option next to a catch-all, a second cacheable prompt layout, and isolated Score levels.
255
- In 10% of questions with three or more options an abstain option is added; in a quarter of those the option list is replaced by
256
- labels from an unrelated task so that the abstain option is correct. The full list of components with sizes is in
257
- `decider/data/mixture.py` of the GitHub repository; `scripts/train.sh full` reproduces the supervised stages in one run.
258
-
259
- **Reinforcement learning stage (v8 to v10).** 384 optimizer steps at a peak learning rate of 1e-6 (cosine, 16 warm-up steps),
260
- selected among the checkpoints of a 576-step run. Each of the 48 iterations plays 4 live MiniWoB++ click tasks, 4 minesweeper
261
- boards and 4 game boards (a 5x5 grid with a slippery move, draws from bags of known composition), 4 repeats each, through the
262
- same one-pass readout that serves requests. Three loss terms use those rollouts: a PPO clipped surrogate (clip 0.2) on the
263
- terminal outcome with a leave-one-replicate-out baseline; a proper log score of the model's stated belief about the immediate
264
- outcome of its action against the exact law (games, minesweeper) or the realised outcome (browser); and a rendering-consistency
265
- term that pulls the model's answer in the other prompt layout and the reversed option order toward its served answer. A fourth
266
- term keeps the model where it was: on 8 replayed supervised rows per step, KL(v8 ‖ student) on the served distribution must
267
- stay under 0.01 nats on average and 0.05 on any row, otherwise the step drops the reward terms and follows only the KL
268
- gradient. Six browser tasks were held out from reward and used for validation only. No gold labels were used. The recipe and
269
- every measurement are in `docs/RL.md` of the GitHub repository.
270
 
271
- **LoRA stage (v10 to v11).** A LoRA of rank 64 (alpha 128) on the attention and MLP weights of v10, trained for 2 epochs over
272
- 42,749 rows in the plain state-first layout with isolated Score levels, then merged into the bf16 weights:
 
 
 
 
 
273
 
274
- | source | rows | content | target |
275
- |---|---|---|---|
276
- | generated decision families | 8,000 | ten families (temporal and numeric decisions, subtle answer judgment, long policies, multi-hop lookup, abstention, probability, constrained trade-offs, safety judgment, paraphrase sensitivity, adversarial traps); the answers are computed by the generating code | the label |
277
- | questions over business documents, written by Qwen3.6-27B with thinking on | 11,356 | one realistic business document plus three or four typed questions per writer call; each question kept only when two further independent answers by the same model agreed with the writer's | the label |
278
- | human-labelled public sets (training halves) | 3,293 | MMLU, ARC, CommonsenseQA, BoolQ, MNLI, SNLI, Banking77, RACE, OpenBookQA, LogiQA 2, MedQA, Winogrande | the label |
279
- | replay of the public decision mixture | 20,100 | 100 rows from the training half of each of the 67 in-task regression tasks (6,700); 7,000 from the families closest to form filling, browser and agent actions, routing, tools, shell commands and situations (form rules 1,000, Mind2Web 800, agent trajectories 1,300, routing 1,000, custom questions 1,000, commands 600, tool selection 600, situations 700); 6,400 spread over every other family | v10's answer distribution: loss KL(p_v10 ‖ p_model) at temperature 1 |
280
 
281
- | LoRA stage | |
282
- |---|---|
283
- | trainable parameters | LoRA rank 64, alpha 128, on the attention and MLP projections; merged after training |
284
- | loss | cross-entropy on the slot readout for labelled rows; KL(p_v10 ‖ p_model) over the options for replay rows, with p_v10 from the frozen v10 weights |
285
- | schedule | learning rate 1e-4, 5% warm-up then cosine, 1,676 steps of 65,536 tokens (2 epochs), seed 0 |
286
- | hardware | one NVIDIA B300 shared with another job, 150 minutes |
287
-
288
- The replay comes from the public mixture (`scripts/train.sh full`), which also holds the v9 additions (shell commands, terse
289
- routing) that v8 and v10 were not trained on; those rows were trained toward v10's answers, not their labels. No JevBench item
290
- and no Decision Index item was used for training, for writing the generators or the document questions, for selecting the
291
- checkpoint, or for the temperatures. Every training row was checked against every evaluation file used for selection, the
292
- evaluation halves of the regression tasks, the four request fixtures and the issue #9 cases. Two arms were trained: one with labels on a replay of 6,700 rows,
293
- and this one, with v10's distribution on the three times larger replay described above; the rule's fallback selected this arm
294
- after epoch 2.
295
-
296
- **Temperatures.** `temperature` 1.145, fitted by NLL on the in-task half of the public regression set without Banking77,
297
- CLINC-OOS, MMLU, ARC, Winogrande and HellaSwag (61 tasks, 102,804 rows). `temperature_by_type` fitted by NLL per answer type with
298
- `decider.calibrate.fit_by_type` on a pool of those regression rows and our own validation rows: 108,910 Choice answers, 1,677 yes/no
299
- answers and 535 Score answers. v10's temperature (1.30) was one value fitted on the 67 in-task tasks.
300
 
301
  ## Evaluation
302
 
303
- **94 public tasks, original protocol.** Large label sets sub-sampled to 10 options; one temperature fitted on in-task data
304
- and stored in `decider_config.json`. "In-task" means the test splits of the training datasets; "held-out" means datasets never
305
- seen in training (TREC, BBC news, PAWS, SciQ, Social IQa, StrategyQA, PubMedQA, TruthfulQA, tweet irony, financial sentiment,
306
- ADE, MASSIVE scenario, student question categories, Dolly categories, CR reviews, Financial PhraseBank, CommitmentBank,
307
- QuALITY, XStoryCloze, RewardBench, Arena preferences, Hermes tool selection, and an abstention probe). ECE is the expected
308
- calibration error with 15 bins.
309
-
310
- | model | in-task (69 tasks) acc / NLL / ECE | held-out (24 tasks) acc / NLL / ECE |
311
- |---|---|---|
312
- | Qwen3.5-2B-Base, zero-shot | 0.620 / 0.908 / 0.121 | 0.642 / 0.853 / 0.105 |
313
- | decider-2b v8, T=1.30 | 0.811 / 0.460 / 0.037 | 0.741 / 0.655 / 0.088 |
314
- | decider-2b v9, T=1.36 | 0.812 / 0.464 / 0.041 | 0.741 / 0.655 / 0.087 |
315
- | decider-2b v8, rebuilt set (67 / 28 tasks, see note), T=1.30 | 0.806 / 0.473 / 0.038 | 0.757 / 0.622 / 0.083 |
316
- | decider-2b v10, rebuilt set, T=1.30 | 0.805 / 0.474 / 0.037 | 0.755 / 0.622 / 0.084 |
317
- | **decider-2b v11 (this repository), rebuilt set, per-type map** | 0.802 / 0.481 / 0.038 | 0.752 / 0.626 / 0.083 |
318
- | v8, questions-first layout (schema cache), T=1.18 | 0.790 / 0.500 / 0.038 | 0.707 / 0.757 / 0.104 |
319
-
320
- The two "rebuilt set" rows were measured after the data pipeline was rebuilt on another machine: two datasets no longer download
321
- (TREC-fine, the game states) and the current mixture adds held-out probes, so that set has 67 in-task and 28 held-out tasks. Its
322
- numbers are comparable to each other, not to the rows above. v10 matches v8 on it. The v10 row was measured again on 2026-09-24
323
- in the v11 session as 0.806 / 0.474 / 0.038 and 0.755 / 0.622 / 0.084. The regression rows are all Choice answers, so v11 reads
324
- them at the Choice temperature 1.164.
325
-
326
- <details>
327
- <summary><b>Per-task accuracy / ECE on the 28 held-out datasets, v8 against v10 (not measured per task for v11)</b></summary>
328
-
329
- Per-task accuracy / ECE on the held-out datasets of the rebuilt set, v8 against v10:
330
-
331
- | task | v8 acc / ECE | v10 acc / ECE |
332
- |---|---|---|
333
- | abstain_probe | 0.633 / 0.112 | 0.606 / 0.134 |
334
- | ade | 0.811 / 0.044 | 0.817 / 0.038 |
335
- | arena_pref | 0.487 / 0.173 | 0.483 / 0.189 |
336
- | bbc_news | 0.924 / 0.014 | 0.927 / 0.013 |
337
- | cb | 0.911 / 0.090 | 0.857 / 0.093 |
338
- | cr_reviews | 0.900 / 0.027 | 0.903 / 0.031 |
339
- | dbpedia_l2 | 0.948 / 0.017 | 0.950 / 0.018 |
340
- | dbpedia_l3 | 0.989 / 0.007 | 0.987 / 0.005 |
341
- | dolly_category | 0.291 / 0.209 | 0.299 / 0.203 |
342
- | fin_phrasebank | 0.684 / 0.043 | 0.694 / 0.042 |
343
- | fin_sentiment | 0.794 / 0.069 | 0.793 / 0.058 |
344
- | hermes_tools | 0.718 / 0.209 | 0.723 / 0.208 |
345
- | hwu64 | 0.964 / 0.031 | 0.961 / 0.030 |
346
- | massive_scenario | 0.766 / 0.040 | 0.756 / 0.041 |
347
- | offtopic_probe | 0.841 / 0.033 | 0.841 / 0.027 |
348
- | paws | 0.707 / 0.169 | 0.724 / 0.145 |
349
- | pubmedqa | 0.752 / 0.083 | 0.756 / 0.085 |
350
- | quality | 0.495 / 0.236 | 0.494 / 0.233 |
351
- | quality_full | 0.505 / 0.205 | 0.508 / 0.198 |
352
- | reward_bench | 0.825 / 0.042 | 0.819 / 0.045 |
353
- | sciq | 0.982 / 0.022 | 0.982 / 0.024 |
354
- | social_iqa | 0.698 / 0.072 | 0.708 / 0.077 |
355
- | strategyqa | 0.559 / 0.123 | 0.552 / 0.138 |
356
- | student_questions | 0.927 / 0.036 | 0.925 / 0.045 |
357
- | trec | 0.792 / 0.057 | 0.784 / 0.066 |
358
- | truthfulqa | 0.529 / 0.102 | 0.537 / 0.090 |
359
- | tweet_irony | 0.801 / 0.048 | 0.795 / 0.052 |
360
- | xstory_cloze | 0.962 / 0.017 | 0.962 / 0.017 |
361
-
362
- </details>
363
-
364
- **v11 against v10 on the same rows** is the table under [Changes from v10](#changes-from-v10).
365
-
366
- **Bespoke's public suite, v10 against v11** (13 human-labelled subsets, 3,880 records in Jev's wire format, answered through
367
- `system_one` as shipped; same session, v10 through decider-ai 1.3.0, v11 through 1.4.0 with the map):
368
-
369
- | subset (type) | decider-2b v10 | decider-2b v11 |
370
- |---|---|---|
371
- | vitaminc-dev (choice) | 0.639 | 0.639 |
372
- | massive-en-US (choice; trained) | 0.823 | 0.814 |
373
- | massive-de-DE (choice) | 0.797 | 0.783 |
374
- | boolq (noul; trained) | 0.803 | 0.850 |
375
- | squad2 (noul) | 0.776 | 0.743 |
376
- | paws (noul; trained) | 0.720 | 0.760 |
377
- | multinli (choice; trained) | 0.856 | 0.853 |
378
- | civil_comments (noul; trained) | 0.840 | 0.870 |
379
- | aegis2 (noul) | 0.728 | 0.716 |
380
- | helpsteer2 (score; trained) | 0.426 | 0.446 |
381
- | summeval-relevance (score) | 0.354 | 0.229 |
382
- | summeval-consistency (score) | 0.653 | 0.757 |
383
- | pubmedqa (choice; trained) | 0.724 | 0.712 |
384
- | **macro / micro** | 0.703 / 0.711 | 0.706 / 0.711 |
385
- | macro over the subsets not trained on | 0.658 | 0.645 |
386
-
387
- On all 9,815 MNLI validation_matched items, decider-2b v11 (revision `533964d`) scores **0.864** (entailment 0.844, neutral
388
- 0.853, contradiction 0.894), level with the multinli subset above. An independent run on 120 other validation_matched items
389
- measured 0.792 ([discussion #4](https://huggingface.co/Mapika/decider-2b/discussions/4)).
390
-
391
- **Calibration of v11 on our own sets** (10-bin top-label ECE on stored temperature-1 logits, read at the served temperatures;
392
- heldout_jb: held-out generated families, test_teacher2: held-out document questions, guard: knowledge guard, cal_human:
393
- human-labelled validation rows):
394
 
395
- | set | rows | accuracy | ECE at the global T / with the map | choice / noul / score ECE with the map |
396
- |---|---|---|---|---|
397
- | heldout_jb | 5,000 | 0.429 | 0.171 / 0.156 | 0.172 / 0.121 / 0.141 |
398
- | test_teacher2 | 449 | 0.753 | 0.083 / 0.075 | 0.074 / 0.093 / 0.131 |
399
- | guard | 2,994 | 0.715 | 0.050 / 0.046 | 0.046 / − / − |
400
- | cal_human | 1,595 | 0.781 | 0.030 / 0.030 | 0.030 / − / − |
401
-
402
- The map lowers the error of yes/no answers on the held-out generated families (0.171 to 0.121) and leaves Choice answers where
403
- they were (0.175 to 0.172), because the Choice temperature is fitted on a pool that is 94% everyday regression rows. A map fitted
404
- without the regression rows (Choice 1.385) would reach 0.126 there and would make the regression set less calibrated (in-task ECE
405
- 0.0510 against 0.0385); it was not used. On the JevBench hard tier, v11's top-label ECE with the map is 0.175 (0.195 at the global
406
- temperature; by type Choice 0.205, yes/no 0.243, Score 0.366). The yes/no Brier score on the probe batteries is 0.048 with the
407
- map and 0.043 at the global temperature (v10 0.046).
408
-
409
- **v10 against v8 on the same rows.** Every row below is scored by both models on identical inputs and seeds. Intervals are
410
- 95% bootstrap or paired intervals.
411
-
412
- | | v8 | v10 | difference |
413
- |---|---|---|---|
414
- | live MiniWoB++ click tasks, 22 tasks x 8 seeds, sampled play | 83.0% | 93.2% | +10.2 (+5.1 to +15.9) |
415
- | the 6 tasks never used for reward | 72.9% | 91.7% | +18.8 (+6.2 to +31.2) |
416
- | same tasks, greedy play | 90.3% | 90.9% | +0.6 |
417
- | Mind2Web element and action choice, 1,770 rows | 81.1% | 82.7% | +1.5 (+0.7 to +2.4) |
418
- | bag-draw games, win rate, 64 boards x 4 | 35.2% | 41.4% | +6.2 (+0.8 to +11.7) |
419
- | slippery-grid games, win rate, 64 boards x 4 | 14.1% | 18.8% | +4.7 (−2.0 to +11.3) |
420
- | stated belief, nats above the exact law (lower is better) | 0.473 | 0.219 | |
421
- | click-outcome prediction, log score (higher is better) | −0.349 | −0.034 | |
422
- | TypeSafe workflow decisions, 102 rows, accuracy / NLL | 78.4% / 0.594 | 80.4% / 0.585 | +2.0 (−2.0 to +5.9) |
423
- | 847 in-task validation rows, accuracy / NLL | 83.6% / 0.443 | 83.2% / 0.444 | −0.4 (−1.3 to +0.6) |
424
- | Bespoke's public suite, 13 subsets, macro accuracy | 0.706 | 0.704 | |
425
- | JevBench public items, easy / standard / hard accuracy | 1.000 / 0.875 / 0.441 | 1.000 / 0.889 / 0.459 | +1 / +2 items |
426
- | OpenJev, 5,252 rows, accuracy / NLL | 64.1% / 0.906 | 63.3% / 0.916 | −0.8 (−1.3 to −0.3) |
427
-
428
- The browser gain is in the served distribution rather than in the argmax: sampled play improves by ten points, greedy play by
429
- under one. Tic-tac-toe and minesweeper play did not change; a 2B model without search loses most of those games either
430
- way. The one measured regression is OpenJev, under one point. The JevBench row was read again for both versions on 2026-09-23, in process, bf16, decider-ai
431
- 1.2.1. The values first published here (standard / hard: v8 0.861 / 0.459, v10 0.847 / 0.459) came from the FP8 server of
432
- 2026-09-19 and do not reproduce item for item.
433
-
434
- **Bespoke's public suite** (13 human-labelled subsets, 3,880 records in Jev's wire format, answered through `system_one` as
435
- shipped). decider-2b v10 macro 0.704 / micro 0.711; v9 0.701 / 0.711; Nimble-9B 0.748 / 0.759; Jev 1.13.0 0.760 / 0.773
436
- (the last two copied from Bespoke's report). Per-subset numbers, the JevBench public-item comparison (decider-2b v10 is at 1.000 / 0.889 / 0.459 on the easy / standard /
437
- hard public items, against Jev 1.13.0 at 1.000 / 0.986 / 0.730) and recordings of both versions on the same browser pages and
438
- game boards are in the GitHub README.
439
-
440
- ## Speed
441
-
442
- v11 has v10's architecture and size, and the temperature map is a division per answer, so the speed was not measured again.
443
- The numbers below were measured on v10: one NVIDIA B300, decider-ai 1.2.1, 2026-09-23. Support-ticket states of about 230 tokens with 3 typed questions each
444
- (the first 64 `support_tickets` examples). `decider.infer.Decider` uses shape-bucketed CUDA graphs; the batching server is
445
- `decider/serve.py`, whose default since 1.1 is bf16.
446
-
447
- | setting | p50 latency | throughput |
448
- |---|---|---|
449
- | single request, eager PyTorch | 18.9 ms | |
450
- | single request, CUDA graphs + torch.compile (helper default) | 3.2 ms | |
451
- | batch of 32, in-process, bf16 | 35.5 ms | about 2,700 decisions/s |
452
- | batch of 32, in-process, FP8 linears | 32.3 ms | about 2,980 decisions/s |
453
- | HTTP server `/decide` (bf16, default), 1 client | 6.1 ms | 158 req/s |
454
- | HTTP server `/decide` (bf16, default), 64 clients | 134 ms | 436 req/s, 2,181 decisions/s |
455
- | HTTP server `/decide` with `DECIDER_FP8=1`, 64 clients | 212 ms | 286 req/s, 1,429 decisions/s |
456
-
457
- On the B300, FP8 is faster in process but slower through the server, so the server default is bf16. The schema-cache figures
458
- were measured earlier on one GH200 and not repeated: with `Decider.schema`, 10 described questions on short chat messages ran at
459
- 11,180 decisions/s in a batch, and one question with 151 options at 19x the full-forward rate. FP8 (e4m3 weights, per-token
460
- activation scales) changes accuracy and calibration by less than the evaluation noise.
461
 
462
  ## Limitations
463
 
464
- * v11's losses against v10 (see Changes from v10): human-labelled public sets −2.2 points, knowledge guard −1.6, greedy bag-draw
465
- play −10.9, sampled slippery-grid play −4.3, sampled browser play −2.8 (interval includes zero), TypeSafe −4.9 (interval includes
466
- zero).
467
- * Overconfident on hard multi-step items: calibration error 0.156 on our held-out generated families and 0.175 on the JevBench
468
- public hard tier. Better than v10 (0.226, 0.307), not calibrated.
469
- * The per-type map needs decider-ai 1.4.0 or later. With 1.3.0 or earlier, or with `DECIDER_TEMPERATURE` set, every answer uses
470
- 1.145: the answers are the same and yes/no answers are sharper.
471
- * The stated-belief and click-outcome calibration of v10's RL stage (below) was not measured again on v11.
472
- * A 2B model without reasoning. Knowledge-heavy multiple choice (MMLU, MedQA, ARC) improves little over the base model, and a
473
- judgment that needs several steps should be split into several questions.
474
- * English only. Calibration is measured on public datasets and teacher-labelled probes, not on your traffic. Check it on your
475
- own labels before using confidence for routing.
476
- * v10 and v11 continue the v8 weights. The v9 data for terse bucket names (`support`, `help`, `account` next to `other`) is not
477
- in them as labels (v11's replay contains those rows but trains them toward v10's answers):
478
- on held-out terse-bucket messages v8 chose the generic bucket correctly 59% of the time where v9 reached 86%. Name or
479
- describe the generic option as a bucket (`general_support`, or a description).
480
- * Rules written into the question ("fill if empty, otherwise skip") are not followed at this size. State the decision as a
481
- plain question with described options.
482
- * Picking one record out of a long JSON array by position is the least accurate input shape (0.51 with 64 records against
483
- 0.70 with one). Address records by key, or let the helper write the index into the array (0.62).
484
- * Full label sets cost accuracy against 10 sampled options: CLINC 151-way 0.88 against 0.98; DBpedia level 2 with 70 labels
485
- is the least calibrated case (ECE 0.14).
486
- * Questions packed into one row (`independent=False`) see the earlier question texts, and reversing their order changes up to
487
- 12% of answers. The default path scores each question alone.
488
- * The browser results are on 22 click-only MiniWoB++ tasks: small synthetic pages with the elements listed as text. Typing,
489
- scrolling and real websites were not tested.
490
- * Abstention: a catch-all option ("none of the above", "other", "unsure") is chosen when nothing on offer fits, not when the
491
- exact fine-grained label is merely absent. Wordings far from the training data remain the main risk.
492
- * One in-task dataset, `tweet_hate` (SemEval-2019 HatEval), stays near chance on its test split, whose collection and label
493
- definition differ from the training split. The number is reported as measured.
494
-
495
- ## Changelog
496
-
497
- | version | what changed |
498
- |---|---|
499
- | **v11** (2026-09-24, these weights) | v10 plus a merged LoRA (rank 64, attention and MLP, 2 epochs, 42,749 rows: generated decision families, document questions written by Qwen3.6-27B and kept when two independent answers agreed, human-labelled public sets, and a replay of the public mixture trained toward v10's own answers); temperature 1.145 and `temperature_by_type` {choice 1.164, noul 1.624, score 1.124} (decider-ai 1.4.0; older versions use 1.145). Held-out generated families 0.429 against 0.324, held-out document questions 0.753 against 0.646, JevBench hard 0.577 against 0.459; human-labelled sets −2.2, knowledge guard −1.6, greedy bag-draw −10.9, sampled slippery grid −4.3, sampled browser −2.8 points. Did not pass its pre-registered rule; released on the full comparison |
500
- | v10 (2026-09-19, Hub tag `v10`) | v8 plus 384 steps of calibration-aware RL on live browser tasks and exact games. Measured on the same rows: live browser click tasks 83% to 93% sampled success (held-out tasks 73% to 92%), stated beliefs about action outcomes 0.47 to 0.22 nats above the exact law, Mind2Web +1.5 points, general accuracy and Bespoke's public suite unchanged, OpenJev −0.8 points. |
501
- | v9 | terse-bucket routing messages and labelled shell commands in the data; described in the GitHub README, but the Hub weights stayed v8, so v10 does not contain it |
502
- | v8 (Hub tag `v8`) | isolated Score levels, teacher-written custom questions with a generic option next to a catch-all, the cacheable schema-first layout |
503
- | v6 to v7 | the input shapes Jev accepts: described options, up to 255 options, JSON states with path references, long inputs |
504
- | v4 to v5 | next-action choice from agent trajectories and game states; the proper abstention fix |
505
- | v1 to v3 | the one-pass readout on the public decision mixture, one fitted temperature |
506
-
507
- The full entries, with the browser and game recordings and the same-rows comparison against v8, are in
508
- [docs/CHANGELOG.md](https://github.com/Mapika/decider/blob/main/docs/CHANGELOG.md) of the GitHub repository;
509
- [docs/HISTORY.md](https://github.com/Mapika/decider/blob/main/docs/HISTORY.md) has how each stage was trained and measured.
510
-
511
- ## Reproduction
512
-
513
- Code, data registry, training and evaluation scripts, the RL recipe and the per-version history:
514
- https://github.com/Mapika/decider. Each release is staged with `scripts/stage_release.py` and uploaded with
515
- `scripts/upload_hf.py`; the previous weights are kept under the tags `v10` and `v8` in this repository. The LoRA stage of v11 was
516
- trained with a LoRA trainer in the research repository. `eval_results.json` has v11's regression metrics with the map and at the
517
- global temperature, our held-out sets by answer type, the fixtures, games, browser and Bespoke results with the paired
518
- comparisons against v10, the text games, the behaviour probes, the issue #9 cases and the JevBench public items.
 
3
  base_model: Qwen/Qwen3.5-2B-Base
4
  language: [en]
5
  pipeline_tag: text-classification
6
+ tags: [decision-model, calibrated, structured-output, multi-task, system-one, one-pass, vision]
7
  ---
8
 
9
+ # decider-2b: typed decisions with calibrated probabilities in one forward pass, 2B dense, text and images
10
 
11
  A language model that does not generate text. It reads a state and one or more typed questions, each with an explicit option
12
+ list, and returns a probability distribution over the options for every question from one forward pass. It is called from
13
+ software, not chatted with. From v12 the state may include images.
 
14
 
15
+ **This repository holds v12 (2026-10-20).** v11 stays available under the Hub tag `v11`, with its own card; v10 under `v10`.
 
 
 
 
 
 
 
 
 
16
 
17
+ - **Weights:** the text model at the repository root (Qwen3.5-2B-Base architecture, bf16, 3.8 GB), loaded as before by
18
+ `decider.infer.Decider`, `decider.serve` and the GGUF builds. `vision/` holds the image part: the vision tower of
19
+ Qwen3.5-2B-Base (unchanged, 0.6 GB) and the config of the image-text model. The image path reuses the text model's weights.
20
+ - **Code:** [decider-ai](https://github.com/Mapika/decider) 1.10.0 for images; text works with 1.4.0 and later.
 
 
21
 
22
+ ## Changes from v11
23
 
24
+ | | v11 | v12 |
25
+ |---|---|---|
26
+ | Decision Index 0.3, 4,500-request sample | 28.05 | **38.17** |
27
+ | Image questions, mean of 11 sets | 0.780 (v11 + base vision tower) | **0.847** |
28
+ | Visual7W / VSR (never trained on) | 0.863 / 0.713 | 0.863 / 0.717 |
29
+ | JevBench public items, hard tier | 0.577 | **0.595** |
30
+ | JevBench public items, standard tier | 0.889 | **0.917** |
31
+ | Fresh held-out yes/no set, scored the JevBench way | −27.9 | **29.6** |
32
+
33
+ - **Decision Index:** +10.1 on the sample, from the decider-31b training mixture (train splits of benchmarks the index also tests;
34
+ see Training).
35
+ - **Images:** new. The gain over the plain base vision tower is mostly on the kinds of image questions trained.
36
+ - **JevBench public items:** two hard items more, two standard items more.
37
+ - **Yes/no answers:** the config now uses temperature 0.05 for Noul answers, so they come out near 0 or 1. JevBench counts a yes/no
38
+ probability between 0.2 and 0.8 as wrong; at 0.05 almost none fall there, and our 399-item held-out yes/no set scores 29.6
39
+ instead of −40.1 at the fitted temperature. The cost: the yes/no probabilities are no longer calibrated (ECE 0.341 instead of
40
+ 0.095 on that set). For calibrated yes/no probabilities use `temperature_by_type={"choice": 2.465, "noul": 3.185, "score":
41
+ 2.833}` (the fitted values).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  ## The decider family
44
 
45
+ | model | base | use it for |
46
+ |---|---|---|
47
+ | **decider-2b** v12 | Qwen3.5-2B-Base | the fastest: routing, classification, judgments; text and images |
48
+ | [decider-4b](https://huggingface.co/Mapika/decider-4b) v3 | Qwen3.5-4B-Base | the middle point; text and images |
49
+ | [decider-12b](https://huggingface.co/Mapika/decider-12b) v2 | Gemma-4-12B-it | our JevBench model; text |
50
+ | [decider-12b-vision](https://huggingface.co/Mapika/decider-12b-vision) | Gemma-4-12B-it | the Decision Index task mix and images; calibrated yes/no |
51
+ | [decider-31b](https://huggingface.co/Mapika/decider-31b) v2 | Gemma-4-31B-it, NVFP4 | the strongest; text and images; Blackwell GPU, vLLM |
52
+ | [decider-35b-a3b](https://huggingface.co/Mapika/decider-35b-a3b) | Qwen3.5-35B-A3B-Base | text |
 
 
 
53
 
54
+ All share one interface (`POST /v1/systemone` in TypeSafe's format) and one readout. Code and changelog:
55
+ https://github.com/Mapika/decider.
56
 
57
  ## Usage
58
 
59
+ Text, as before:
60
+
61
  ```python
62
+ from decider.infer import Decider
63
  d = Decider("Mapika/decider-2b")
64
  d.decide("My card was charged twice for the same purchase.",
65
  [{"question": "Which department should handle this?", "options": ["billing", "technical support", "sales"]},
66
  {"question": "Does this need a refund action?", "options": ["no", "yes"]}])
 
67
  ```
68
 
69
+ Images, through the server (decider-ai 1.10.0; the config's `"images": true` turns image input on):
 
 
 
 
70
 
71
+ ```bash
72
+ pip install "decider-ai>=1.10.0" pillow
73
+ DECIDER_MODEL=Mapika/decider-2b uvicorn decider.serve:app --host 127.0.0.1 --port 8000
 
 
 
 
 
 
 
74
  ```
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  ```python
77
+ import base64, httpx
78
+ img = base64.b64encode(open("chart.png", "rb").read()).decode()
79
+ r = httpx.post("http://127.0.0.1:8000/v1/systemone", timeout=60, json={
80
+ "state": {"chart": "q3-chart", "note": "Escalate if any region fell by more than 10%."},
81
+ "assets": {"q3-chart": {"type": "image", "format": "png", "data_base64": img}},
82
+ "questions": {"escalate": {"type": "noul", "instructions": "Does the `chart` call for escalation?",
83
+ "criteria": {"true": "escalate", "false": "no escalation"}}}})
 
 
 
84
  ```
85
 
86
+ The asset's key is the reference; each place the state names it becomes "the image" in the prompt, and the image goes in front
87
+ of the prompt, as in training. PNG and JPEG; up to 4 images (`DECIDER_MAX_IMAGES`); references in question text and box
88
+ answers are not supported. Image rows are read one at a time, without batching or CUDA graphs. Requirements as before (`torch`,
89
+ `transformers>=5`, `flash-linear-attention`), plus `pillow` for images.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  ## Training
92
 
93
+ v12 starts from v11. Its text weights were placed into the Qwen3.5-2B-Base image-text model (vision tower from the base); the
94
+ language-model weights are identical to the text model's. Then a LoRA stage (rank 64, learning rate 1e-4, 2 epochs, 56 minutes
95
+ on one B300) on the language model only:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ - **Text, 39.5k rows:** the decider-31b training mixture (train splits of CLINC150, iSarcasmEval, VAST, ACOS, POP909-CL, GSM8K
98
+ and NLI4CT, decontaminated against every Decision Index 0.3 suite row; generated smart-home requests; generated
99
+ state-tracking decisions and decision families; document questions written by Qwen3.6-27B; labelled public rows), plus 6.7k
100
+ rows replayed from the decider-4b v2.1 training data.
101
+ - **Images, 18,129 rows:** train splits of seven the_cauldron subsets: AI2D, A-OKVQA, Hateful Memes, IconQA, RAVEN, ScienceQA,
102
+ TQA. Visual7W and VSR were not used.
103
+ - Not used: Decision Index test rows, JevBench or ImageJevBench items.
104
 
105
+ Temperatures were fitted on our own validation rows: Choice 2.465, Score 2.833, Noul 3.185 (the config uses 0.05 for Noul, see
106
+ above). They are about twice v11's because v12's raw answers are sharper.
 
 
 
 
107
 
108
+ We also trained the same recipe for one epoch. Our own validation rows preferred it slightly (Choice NLL 0.722 against 0.764);
109
+ the Decision Index sample (37.26 against 38.17) and the JevBench public items (hard 0.541 against 0.595) preferred two epochs, and
110
+ we released the two-epoch run. So the choice between the two runs looked at benchmark readouts; nothing else did.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  ## Evaluation
113
 
114
+ All numbers measured by us; none were verified by the benchmark maintainers.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
+ - **Decision Index 0.3,** 4,500-request sample of the public suite: 38.17 (v11 28.05); through `decider.serve` 1.10.0, 38.18.
117
+ Benchmark-weighted ECE on the sample: 0.164 (v11 0.161); with about 88 requests per benchmark this number is inflated for
118
+ every model. The full public suite was not run.
119
+ - **Image questions,** eval splits of 11 the_cauldron sets, 300 items each: mean 0.847; trained kinds 0.864, never-trained
120
+ (Visual7W, VSR) 0.790. v11 with the base vision tower and no image training: 0.778 / 0.788. Stock Qwen3.5-2B with our
121
+ readout: 0.774 mean. Through `decider.serve` 1.10.0 with the image sent as an asset: VSR 0.753, Visual7W 0.853.
122
+ - **JevBench public items** (read once, never used for training, fitting or selection): easy 1.000, standard 0.917, hard 0.595.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  ## Limitations
125
 
126
+ - **One pass only:** knowledge stays at the base model's level; multi-step tasks are not solved by reading harder.
127
+ - **Yes/no calibration** is traded for JevBench's rule in the default config (see Changes).
128
+ - **Images:** new image question types read about as well as the stock model. Not measured on ImageJevBench.
129
+ - **Latency** was not measured again; the text path is unchanged from v11.
130
+
131
+ ## License
132
+
133
+ The weights are released under Apache 2.0, as the base model. Training sources and their terms:
134
+
135
+ - Image questions, from the train splits of [the_cauldron](https://huggingface.co/datasets/HuggingFaceM4/the_cauldron) subsets
136
+ (the_cauldron licenses only its own prompts, CC BY 4.0; each source keeps its own terms):
137
+ - AI2D (Kembhavi et al., 2016): CC BY-SA 4.0.
138
+ - A-OKVQA (Schwenk et al., 2022): Apache 2.0 (repository); images from COCO.
139
+ - IconQA (Lu et al., 2021): CC BY-NC-SA 4.0, non-commercial.
140
+ - ScienceQA (Lu et al., 2022): CC BY-NC-SA 4.0, non-commercial research use.
141
+ - TQA (Kembhavi et al., 2017): CC BY-NC 3.0, non-commercial.
142
+ - RAVEN (Zhang et al., 2019): no data licence stated (the code is GPL-3.0).
143
+ - Hateful Memes (Kiela et al., 2020): Facebook's Hateful Memes dataset licence. It permits use of the data to research and
144
+ develop models designed to detect manipulated media, and states that models derived from it may be used for academic and
145
+ commercial purposes. This is a research project; check the licence for your own use.
146
+
147
+ - CLINC150: CC BY 3.0 (Larson et al., 2019). Credit: "An Evaluation Dataset for Intent Classification and Out-of-Scope
148
+ Prediction", clinc/oos-eval.
149
+ - iSarcasmEval: MIT (Abu Farha et al., 2022). The texts are tweets.
150
+ - POP909-CL: MIT; built on POP909 (Wang et al., 2020), MIT.
151
+ - VAST (Allaway and McKeown, 2020) and ACOS (Cai et al., 2021): no licence stated by the authors.
152
+ - GSM8K (Cobbe et al., 2021): MIT.
153
+ - NLI4CT (SemEval-2024 Task 2, Jullien et al.): no licence file in the task repository; released for the shared task.
154
+ - RACE: non-commercial research use. It is a small part of the replayed data (250 rows).
155
+ - ARC, SNLI, LogiQA 2.0 (copy dependent): CC BY-SA 4.0; BoolQ: CC BY-SA 3.0; BANKING77, MedQA: CC BY 4.0; MMLU,
156
+ CommonsenseQA: MIT; MNLI: mixed per genre.
157
+ - The sources of v11 and earlier, listed on the v11 card (Hub tag `v11`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/__init__.py DELETED
File without changes
decider/batching.py DELETED
@@ -1,95 +0,0 @@
1
- """Batch planning for the server's queue: which queued rows share one forward.
2
-
3
- Pure python, no torch, no engine: `plan_batches` takes the row lengths and two callables that describe the engine's
4
- bucket grid, and returns the groups to run. `decider.serve.batcher` calls it once per collection.
5
-
6
- Cost model. A forward of B rows padded to length T costs `overhead + B * T` token-units: `overhead` is the fixed cost of
7
- one forward (launch, replay, the slot gather and the device-to-host copy) expressed as the number of padded tokens that
8
- take the same time, and `B * T` is the padded token work. Two rows of different lengths are therefore worth merging
9
- into the longer row's bucket exactly when the padding they add is cheaper than a second forward's overhead.
10
- `DEFAULT_MERGE_OVERHEAD_TOKENS` is measured on decider-2b (docs/SERVING.md, section on the batching policy); the server
11
- reads `DECIDER_MERGE_OVERHEAD_TOKENS` over it.
12
-
13
- The partition is exact, not greedy. Rows are sorted by padded length descending (stable, so rows of the same bucket keep
14
- their arrival order) and split into consecutive groups; each group runs at the bucket of its longest member, so a group
15
- starting at position k costs `overhead + g * T[k]`. A dynamic program over the sorted sequence takes the cheapest split,
16
- subject to `g <= min(max_batch, max_rows(T))`, with ties going to the larger group. The per-bucket grouping the server
17
- did before is one of the partitions the program may choose (rows of equal length are adjacent in the sorted order), so
18
- the planned cost is never above it, and with `overhead = 0` the plan is exactly that grouping.
19
- """
20
-
21
- DEFAULT_MERGE_OVERHEAD_TOKENS = 512
22
-
23
-
24
- def group_cap(T, max_rows, max_batch):
25
- """Rows one forward may take at padded length T: the engine's widest captured batch bucket, and the server's cap."""
26
- return max(1, min(int(max_batch), int(max_rows(T))))
27
-
28
-
29
- def batch_cost(size, T, overhead=DEFAULT_MERGE_OVERHEAD_TOKENS):
30
- """Cost of one forward of `size` rows padded to `T`, in token-units."""
31
- return overhead + size * T
32
-
33
-
34
- def plan_cost(groups, overhead=DEFAULT_MERGE_OVERHEAD_TOKENS):
35
- """Cost of a plan: the sum over its forwards."""
36
- return sum(batch_cost(len(idx), T, overhead) for T, idx in groups)
37
-
38
-
39
- def per_bucket_groups(lengths, pad_len, max_rows, max_batch):
40
- """The grouping of 1.1.0: rows of the same padded length, chunked to the cap. The baseline `plan_batches` improves on."""
41
- return _exact_bucket(list(range(len(lengths))), [pad_len(n) for n in lengths], max_rows, max_batch)
42
-
43
-
44
- def _exact_bucket(idx, pads, max_rows, max_batch):
45
- buckets = {}
46
- for i in idx:
47
- buckets.setdefault(pads[i], []).append(i)
48
- out = []
49
- for T, members in buckets.items():
50
- cap = group_cap(T, max_rows, max_batch)
51
- for k in range(0, len(members), cap):
52
- out.append((T, members[k:k + cap]))
53
- return out
54
-
55
-
56
- def plan_batches(lengths, pad_len, max_rows, max_batch, overhead=DEFAULT_MERGE_OVERHEAD_TOKENS, mergeable=None):
57
- """Partition queued rows into forwards.
58
-
59
- lengths: token count of every queued row, in arrival order.
60
- pad_len(n): the engine's padded length for a row of n tokens (`EngineV2.pad_len`).
61
- max_rows(T):rows the engine runs in one forward at padded length T (`EngineV2.max_rows`).
62
- max_batch: the server's own cap (`DECIDER_MAX_BATCH`).
63
- overhead: fixed cost of a forward in token-units (see the module docstring).
64
- mergeable(n): False for a row that must not be padded into another row's bucket. Rows above the last captured
65
- length bucket run eager at a request-specific shape, so they are grouped by exact length as before.
66
-
67
- -> [(padded length, [row indices]), ...]. Every row appears in exactly one group; a group's padded length is at
68
- least every member's own padded length; a group holds at most `min(max_batch, max_rows(T))` rows.
69
- """
70
- n = len(lengths)
71
- if n == 0:
72
- return []
73
- pads = [pad_len(x) for x in lengths]
74
- merge = [True] * n if mergeable is None else [bool(mergeable(x)) for x in lengths]
75
- out = _exact_bucket([i for i in range(n) if not merge[i]], pads, max_rows, max_batch)
76
-
77
- order = sorted((i for i in range(n) if merge[i]), key=lambda i: -pads[i]) # stable: equal buckets keep arrival order
78
- m = len(order)
79
- dp = [0] * (m + 1) # dp[k]: cheapest cost of covering order[k:]
80
- take = [0] * (m + 1)
81
- for k in range(m - 1, -1, -1):
82
- T = pads[order[k]]
83
- cap = min(group_cap(T, max_rows, max_batch), m - k)
84
- best, best_g = None, 1
85
- for g in range(1, cap + 1):
86
- c = overhead + g * T + dp[k + g]
87
- if best is None or c <= best: # ties go to the larger group: same modelled cost, one fewer launch
88
- best, best_g = c, g
89
- dp[k], take[k] = best, best_g
90
- k = 0
91
- while k < m:
92
- g = take[k]
93
- out.append((pads[order[k]], sorted(order[k:k + g]))) # inside a group, arrival order
94
- k += g
95
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/calibrate.py DELETED
@@ -1,222 +0,0 @@
1
- """Fit decider_config.json "temperature_by_type" by NLL, one temperature per answer type (decider.temperature).
2
-
3
- python -m decider.calibrate records.jsonl [--min-rows 50]
4
- -> {"temperature_by_type": {"choice": 1.48, "noul": 2.22, "score": 1.38}, "temperature": 1.6, "rows": {...}, "nll": {...}}
5
-
6
- A record is one answer with its gold, read at temperature 1:
7
- {"type": "choice" | "noul" | "score", "logits": [one value per option], "gold": option index}
8
- {"type": "score", "level_logits": [[no, yes], one pair per level], "gold": level index} Score with isolated levels
9
- "probs" / "level_probs" (probabilities at temperature 1) may stand in for "logits" / "level_logits": log p is the logit up to a
10
- constant, which the softmax ignores. A noul gold is 1 for true, 0 for false. Every record is checked first (check_record):
11
- a malformed one (gold out of range or not an integer, wrong shape, NaN, a row without a finite logit, an isolated Score whose
12
- levels all have P(yes) = 0) raises ValueError naming its position.
13
-
14
- An isolated-levels Score answer is fitted through the readout the server uses: every level row is softmax(row / T), and the
15
- answer is P(yes) of each level divided by their sum (decider.systemone.combine_isolated). So the "score" temperature is fitted on
16
- Score answers whichever readout the model serves them with; fit it on records collected with the same isolated_levels setting.
17
-
18
- `collect(decider, examples)` produces records from a decider.infer.Decider and labelled /v1/systemone-shaped examples.
19
- "temperature" in the output is one temperature fitted on all records together, for comparison; a type with fewer than
20
- --min-rows records is left out of the map (it then uses "temperature" of the config).
21
- """
22
- import json, math, sys
23
- import numpy as np
24
-
25
- from decider.temperature import TYPES
26
-
27
- GRID = np.exp(np.linspace(math.log(0.05), math.log(20.0), 801)) # 0.05 .. 20, about 0.75 % apart
28
-
29
-
30
- def _log(x):
31
- x = np.asarray(x, dtype=np.float64)
32
- with np.errstate(divide="ignore"):
33
- return np.where(x > 0, np.log(np.clip(x, 1e-300, None)), -np.inf)
34
-
35
-
36
- def _logits(rec, key):
37
- if key in rec:
38
- return np.asarray(rec[key], dtype=np.float64)
39
- alt = {"logits": "probs", "level_logits": "level_probs"}[key]
40
- return _log(rec[alt])
41
-
42
-
43
- def _lse(z, axis=-1):
44
- m = np.max(z, axis=axis, keepdims=True)
45
- m = np.where(np.isfinite(m), m, 0.0)
46
- with np.errstate(divide="ignore"): # an all -inf row gives -inf, handled by the callers
47
- return (m + np.log(np.sum(np.exp(z - m), axis=axis, keepdims=True))).squeeze(axis)
48
-
49
-
50
- def nll(rec, T):
51
- """Negative log-likelihood of one record's gold at temperature T."""
52
- return float(_Batch([rec]).nll(T))
53
-
54
-
55
- def _isolated(rec):
56
- return "level_logits" in rec or "level_probs" in rec
57
-
58
-
59
- def check_record(rec, i=None):
60
- """Raise ValueError unless `rec` is a well-formed record (module docstring): an integral gold within the options or levels,
61
- a 1-D row of at least two options or an [levels, 2] array of at least two levels, logits that are not NaN or +inf,
62
- probabilities in [0, 1] with positive mass, and isolated levels only on a Score record."""
63
- where = f"record {i}" if i is not None else "record"
64
- if not isinstance(rec, dict):
65
- raise ValueError(f"{where}: not a JSON object")
66
- if rec.get("type") not in TYPES:
67
- raise ValueError(f"{where}: type {rec.get('type')!r}, expected one of {', '.join(TYPES)}")
68
- iso = _isolated(rec)
69
- keys = ("level_logits", "level_probs") if iso else ("logits", "probs")
70
- if sum(k in rec for k in keys) != 1 or (iso and ("logits" in rec or "probs" in rec)):
71
- raise ValueError(f"{where}: give exactly one of logits, probs, level_logits, level_probs")
72
- if iso and rec["type"] != "score":
73
- raise ValueError(f"{where}: isolated levels (level_logits / level_probs) belong to a score record, not {rec['type']!r}")
74
- key = keys[0] if keys[0] in rec else keys[1]
75
- try:
76
- a = np.asarray(rec[key], dtype=np.float64)
77
- except (TypeError, ValueError):
78
- raise ValueError(f"{where}: {key} is not a numeric array") from None
79
- if iso and (a.ndim != 2 or a.shape[1] != 2 or a.shape[0] < 2):
80
- raise ValueError(f"{where}: {key} must be one [no, yes] pair per level, at least two levels; got shape {a.shape}")
81
- if not iso and (a.ndim != 1 or a.shape[0] < 2):
82
- raise ValueError(f"{where}: {key} must be one value per option, at least two options; got shape {a.shape}")
83
- if key.endswith("probs"):
84
- if not np.all(np.isfinite(a)) or np.any(a < 0) or np.any(a > 1) or np.any(a.sum(-1) <= 0):
85
- raise ValueError(f"{where}: {key} must be probabilities in [0, 1] with positive mass per row")
86
- elif np.any(np.isnan(a)) or np.any(a == np.inf) or not np.all(np.any(np.isfinite(a), axis=-1)):
87
- raise ValueError(f"{where}: {key} contains NaN or +inf, or a row without a finite value")
88
- if rec["type"] == "noul" and a.shape[0] != 2:
89
- raise ValueError(f"{where}: a noul record has exactly two options (false, true); got {a.shape[0]}")
90
- if iso and not np.any(a[:, 1] > 0 if key == "level_probs" else np.isfinite(a[:, 1])):
91
- raise ValueError(f"{where}: no level has a yes probability above 0, so the served Score answer has no distribution")
92
- g = rec.get("gold")
93
- if isinstance(g, bool) or not isinstance(g, (int, np.integer)) or not 0 <= g < a.shape[0]:
94
- raise ValueError(f"{where}: gold must be an integer index in 0..{a.shape[0] - 1}, got {g!r}")
95
- return rec
96
-
97
-
98
- class _Batch:
99
- """Records packed into padded arrays, so one temperature is scored over all of them at once."""
100
- def __init__(self, records):
101
- records = [check_record(r, i) for i, r in enumerate(records)]
102
- lists = [r for r in records if not _isolated(r)]
103
- isos = [r for r in records if _isolated(r)]
104
- self.n = len(lists) + len(isos)
105
- self.L = self.Lg = self.I = self.Im = self.Ig = None
106
- if lists:
107
- zs = [_logits(r, "logits") for r in lists]
108
- K = max(len(z) for z in zs)
109
- self.L = np.full((len(zs), K), -np.inf)
110
- for i, z in enumerate(zs):
111
- self.L[i, :len(z)] = z
112
- self.Lg = np.array([int(r["gold"]) for r in lists])
113
- if isos:
114
- zs = [_logits(r, "level_logits").reshape(-1, 2) for r in isos]
115
- n = max(len(z) for z in zs)
116
- self.I = np.zeros((len(zs), n, 2)); self.Im = np.zeros((len(zs), n), dtype=bool)
117
- for i, z in enumerate(zs):
118
- self.I[i, :len(z)] = z; self.Im[i, :len(z)] = True
119
- self.Ig = np.array([int(r["gold"]) for r in isos])
120
-
121
- def nll(self, T):
122
- """Sum of the records' NLL at temperature T."""
123
- tot = 0.0
124
- if self.L is not None:
125
- z = self.L / T
126
- zg = z[np.arange(len(z)), self.Lg]
127
- tot += float(np.sum(np.where(np.isfinite(zg), _lse(z) - zg, 690.0)))
128
- if self.I is not None: # in log space: P(yes) of very confident rows underflows otherwise
129
- z = self.I / T
130
- with np.errstate(invalid="ignore"):
131
- lp = np.where(self.Im, z[..., 1] - _lse(z), -np.inf) # log P(yes) per level, -inf on padding
132
- norm = _lse(lp) # log of the summed P(yes)
133
- lg = lp[np.arange(len(z)), self.Ig]
134
- with np.errstate(invalid="ignore"):
135
- per = np.where(np.isfinite(lg), norm - lg, 690.0) # check_record guarantees a finite norm
136
- tot += float(np.sum(per))
137
- return tot
138
-
139
-
140
- def mean_nll(records, T):
141
- b = records if isinstance(records, _Batch) else _Batch(records)
142
- return b.nll(T) / b.n
143
-
144
-
145
- def fit(records, grid=GRID):
146
- """The temperature with the lowest mean NLL on the grid, refined by a golden-section search between its neighbours."""
147
- records = records if isinstance(records, _Batch) else _Batch(list(records))
148
- if not records.n:
149
- raise ValueError("no records to fit")
150
- vals = [mean_nll(records, T) for T in grid]
151
- i = int(np.argmin(vals))
152
- lo, hi = math.log(grid[max(i - 1, 0)]), math.log(grid[min(i + 1, len(grid) - 1)])
153
- r = (math.sqrt(5) - 1) / 2
154
- a, b = hi - r * (hi - lo), lo + r * (hi - lo)
155
- fa, fb = mean_nll(records, math.exp(a)), mean_nll(records, math.exp(b))
156
- for _ in range(40):
157
- if fa < fb:
158
- hi, b, fb = b, a, fa; a = hi - r * (hi - lo); fa = mean_nll(records, math.exp(a))
159
- else:
160
- lo, a, fa = a, b, fb; b = lo + r * (hi - lo); fb = mean_nll(records, math.exp(b))
161
- T = math.exp((lo + hi) / 2)
162
- return T if mean_nll(records, T) <= vals[i] else float(grid[i])
163
-
164
-
165
- def fit_by_type(records, min_rows=50):
166
- """-> {"temperature_by_type": {type: T}, "temperature": pooled T, "rows": {type: n}, "nll": {type: {"T=1": .., "fitted": ..}}}"""
167
- records = [check_record(r, i) for i, r in enumerate(records)]
168
- groups = {t: [] for t in TYPES}
169
- for r in records:
170
- groups[r["type"]].append(r)
171
- out = {"temperature_by_type": {}, "temperature": round(fit(records), 3) if records else None,
172
- "rows": {t: len(g) for t, g in groups.items()}, "nll": {}}
173
- for t, g in groups.items():
174
- if len(g) < max(1, min_rows):
175
- continue
176
- b = _Batch(g); T = fit(b)
177
- out["temperature_by_type"][t] = round(T, 3)
178
- out["nll"][t] = {"T=1": round(mean_nll(b, 1.0), 4), "fitted": round(mean_nll(b, T), 4)}
179
- return out
180
-
181
-
182
- def collect(decider, examples, independent=True):
183
- """Records for fit_by_type from a decider.infer.Decider, read at temperature 1 on the uncached state-first path.
184
- examples: iterable of (state, questions, golds) with questions as /v1/systemone takes them and golds {question id: gold},
185
- the gold being a Choice option name, a Noul true/false, or a Score level index. Questions without a gold are skipped.
186
- Score questions are read with the Decider's isolated_levels setting, as system_one serves them."""
187
- recs = []
188
- for state, questions, golds in examples:
189
- rqs, index, items = decider._system_one_items(state, questions, independent, layout="state_first")
190
- rows = decider._system_one_probs(items, "state_first", temperature=1.0)
191
- for k, kind, s, n in index:
192
- if k not in golds:
193
- continue
194
- rq, g = rqs[k], golds[k]
195
- if rq["type"] == "noul" and not (isinstance(g, bool) or g in (0, 1)):
196
- raise ValueError(f"gold of noul question {k!r} must be true or false, got {g!r}")
197
- if rq["type"] == "score" and (isinstance(g, bool) or not isinstance(g, int) or not 0 <= g < len(rq["names"])):
198
- raise ValueError(f"gold of score question {k!r} must be a level index in 0..{len(rq['names']) - 1}, got {g!r}")
199
- key = bool(g) if rq["type"] == "noul" else g
200
- if key not in rq["names"]:
201
- raise ValueError(f"gold of choice question {k!r} is not one of its options: {g!r}")
202
- gold = rq["names"].index(key)
203
- if kind == "iso":
204
- recs.append({"type": rq["type"], "level_probs": [rows[s + j][:2] for j in range(n)], "gold": gold})
205
- else:
206
- recs.append({"type": rq["type"], "probs": rows[s][:len(rq["options"])], "gold": gold})
207
- return recs
208
-
209
-
210
- def main(argv=None):
211
- import argparse
212
- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
213
- ap.add_argument("records", help="JSON lines, one record per line (see the module docstring)")
214
- ap.add_argument("--min-rows", type=int, default=50, help="fit a type only when it has at least this many records")
215
- a = ap.parse_args(argv)
216
- with open(a.records) as f:
217
- recs = [json.loads(line) for line in f if line.strip()]
218
- print(json.dumps(fit_by_type(recs, a.min_rows), indent=1))
219
-
220
-
221
- if __name__ == "__main__":
222
- main(sys.argv[1:])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/engine.py DELETED
@@ -1,216 +0,0 @@
1
- """Low-latency inference engine: shape-bucketed CUDA graphs over the one-pass decision model.
2
-
3
- Right padding + causal layers => pad positions never influence earlier slots, so no attention
4
- mask is needed and every (B, T) bucket can be captured once and replayed. The graph outputs
5
- option-letter logits for all positions [B, T, K]; slots are gathered outside.
6
- """
7
- import time, torch, torch._dynamo, torch.nn.functional as F
8
- from decider.model import DecisionModel, collate
9
- from decider.prompt import build, MAX_OPTIONS
10
- from decider.temperature import scaled_softmax, slot_temperatures
11
-
12
- T_BUCKETS = [64, 128, 192, 256, 320, 384, 512, 640, 768, 1024, 1280, 1536, 2048]
13
- B_BUCKETS = [1, 2, 4, 8, 16, 32, 64]
14
- GRAPH_MAX_T = 2048 # longer inputs (up to the 32k request budget) run eagerly: compute dominates there, and one graph
15
- LONG_STEP = 1024 # per (B, T) shape would cost a compile + capture for every new length
16
-
17
-
18
- def _bucket(x, buckets):
19
- for b in buckets:
20
- if x <= b:
21
- return b
22
- return None
23
-
24
-
25
- def fused_causal_conv1d_fn(hidden_states, weight, bias=None, activation=None, **kwargs):
26
- """Depthwise causal conv (kernel k) as k shifted multiply-adds: fuses under torch.compile,
27
- unlike the cuDNN grouped conv fallback (which was ~11% of batched GPU time)."""
28
- B, C, T = hidden_states.shape; k = weight.shape[-1]
29
- x = F.pad(hidden_states.to(weight.dtype), (k - 1, 0))
30
- out = x[:, :, k - 1:k - 1 + T] * weight[:, k - 1][None, :, None]
31
- for j in range(k - 1):
32
- out = out + x[:, :, j:j + T] * weight[:, j][None, :, None]
33
- if bias is not None:
34
- out = out + bias[None, :, None]
35
- if activation == "silu":
36
- out = F.silu(out)
37
- elif activation is not None:
38
- from transformers.activations import ACT2FN
39
- out = ACT2FN[activation](out)
40
- return out.to(hidden_states.dtype)
41
-
42
-
43
- def patch_conv():
44
- from transformers.models.qwen3_5 import modeling_qwen3_5 as mq
45
- mq.causal_conv1d_fn = fused_causal_conv1d_fn
46
-
47
-
48
- def read_slots(out, rows, slots, nopts, temperature, n_per_item):
49
- """One gather + one softmax + one device-to-host copy for the whole batch (was: three small kernels and a sync per item).
50
- out [B, T, K] logits; rows/slots/nopts: flat python lists, one entry per question; n_per_item: questions per item.
51
- temperature: a number for every question, or a flat list with one temperature per question (decider.temperature)."""
52
- dev = out.device; idx = torch.tensor([rows, slots, nopts], dtype=torch.long).to(dev, non_blocking=True)
53
- lg = out[idx[0], idx[1]] # [N, K]
54
- lg = lg.masked_fill(torch.arange(lg.shape[1], device=dev)[None, :] >= idx[2][:, None], float("-inf"))
55
- p = scaled_softmax(lg, temperature).cpu()
56
- return list(torch.split(p, n_per_item))
57
-
58
-
59
- def fill_ids(items_ids, B, T, pad):
60
- import numpy as np
61
- a = np.full((B, T), pad, dtype=np.int64)
62
- for b, x in enumerate(items_ids): a[b, :len(x)] = x
63
- return torch.from_numpy(a)
64
-
65
-
66
- def set_attention_backend_policy():
67
- """Turn off the cuDNN scaled-dot-product-attention backend. On Blackwell with torch 2.14 / CUDA 13 it returns wrong,
68
- finite output for masked rectangular attention, which is what the shared-state path (`Engine.score_shared`) and the schema
69
- cache run when a suffix is scored against a cached prefix; the math and memory-efficient backends are correct. Measured on
70
- a Decision Index row: the cached path answered a wrong option at p=0.93 where the full forward and the corrected cached path
71
- both give option_4 at p=0.95 (decider2/SERVING_V2_REVIEW.md in the research notes). Must run before torch.compile and CUDA
72
- graph capture: captured graphs keep the backend they were captured with."""
73
- if hasattr(torch.backends.cuda, "enable_cudnn_sdp"):
74
- torch.backends.cuda.enable_cudnn_sdp(False)
75
-
76
-
77
- class Engine:
78
- """compile: torch.compile the forward (needs use_cache=False; ~1.4x batched, fuses elementwise work).
79
- fp8: e4m3 weights + per-token activation scaling on the big linears (Hopper tensor cores).
80
- conv_patch: fusable depthwise causal conv instead of the cuDNN fallback."""
81
- def __init__(self, path, device="cuda", dtype=torch.bfloat16, use_graphs=True, max_ctx_tokens=1536,
82
- compile=True, fp8=False, conv_patch=True):
83
- set_attention_backend_policy()
84
- if conv_patch:
85
- if str(device).startswith("mps"):
86
- from decider.mps_ops import patch_mps
87
- patch_mps()
88
- else:
89
- patch_conv()
90
- self.m = DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
91
- use_graphs = use_graphs and torch.device(device).type == "cuda"
92
- self.tok = self.m.tok; self.dev = device; self.use_graphs = use_graphs; self.max_ctx = max_ctx_tokens
93
- self.core, self.W = self.m.lm.model, self.m.lm.lm_head.weight[self.m.letters].detach().clone()
94
- self.cfg = dict(compile=compile, fp8=fp8, conv_patch=conv_patch, graphs=use_graphs)
95
- if fp8:
96
- from decider.fp8 import convert_to_fp8
97
- self.cfg["fp8_layers"] = convert_to_fp8(self.core)
98
- if compile:
99
- torch._dynamo.config.cache_size_limit = 128
100
- self._fwd_impl = torch.compile(self._fwd_eager, dynamic=False)
101
- else:
102
- self._fwd_impl = self._fwd_eager
103
- self.graphs = {} # (B, T) -> (static_ids, static_out, graph)
104
- self.pool = torch.cuda.graph_pool_handle() if (use_graphs and str(device).startswith("cuda")) else None
105
- self.stats = dict(graph_captures=0, forwards=0)
106
-
107
- def _fwd_eager(self, ids):
108
- h = self.core(input_ids=ids, use_cache=False).last_hidden_state
109
- return F.linear(h, self.W).float() # [B, T, K]
110
-
111
- @torch.no_grad()
112
- def _fwd(self, ids):
113
- return self._fwd_impl(ids)
114
-
115
- def _capture(self, B, T):
116
- s_ids = torch.full((B, T), self.tok.pad_token_id, dtype=torch.long, device=self.dev)
117
- st = torch.cuda.Stream(); st.wait_stream(torch.cuda.current_stream())
118
- with torch.cuda.stream(st):
119
- for _ in range(3): self._fwd(s_ids) # warm-up: compile / triton autotune
120
- torch.cuda.current_stream().wait_stream(st)
121
- g = torch.cuda.CUDAGraph()
122
- with torch.cuda.graph(g, pool=self.pool):
123
- s_out = self._fwd(s_ids)
124
- self.stats["graph_captures"] += 1
125
- return s_ids, s_out, g
126
-
127
- @torch.no_grad()
128
- def logits_all(self, ids):
129
- """ids: [B, T] long on device (already right-padded to a bucket). Returns [B, T, K] float."""
130
- B, T = ids.shape; self.stats["forwards"] += 1
131
- if T > GRAPH_MAX_T:
132
- self.stats["long_forwards"] = self.stats.get("long_forwards", 0) + 1
133
- return self._fwd_eager(ids)
134
- if not self.use_graphs:
135
- return self._fwd(ids)
136
- key = (B, T)
137
- if key not in self.graphs:
138
- self.graphs[key] = self._capture(B, T)
139
- s_ids, s_out, g = self.graphs[key]
140
- s_ids.copy_(ids); g.replay()
141
- return s_out
142
-
143
- @torch.no_grad()
144
- def score_items(self, items, temperature=1.0):
145
- """items: list of dicts from prompt.build. Returns list of [n_q, MAX_OPTIONS] prob tensors (cpu).
146
- temperature: a number, or one entry per item (a number or one number per slot; decider.temperature.for_items)."""
147
- Tmax = max(len(it["ids"]) for it in items)
148
- T = _bucket(Tmax, T_BUCKETS) or -(-Tmax // LONG_STEP) * LONG_STEP
149
- B = (_bucket(len(items), B_BUCKETS) or len(items)) if T <= GRAPH_MAX_T else len(items)
150
- ids = fill_ids([it["ids"] for it in items], B, T, self.tok.pad_token_id)
151
- out = self.logits_all(ids.to(self.dev, non_blocking=True))
152
- return read_slots(out, [b for b, it in enumerate(items) for _ in it["slots"]], [s for it in items for s in it["slots"]],
153
- [n for it in items for n in it["nopts"]], slot_temperatures(temperature, items), [len(it["slots"]) for it in items])
154
-
155
- @torch.no_grad()
156
- def score_shared(self, items, temperature=1.0, min_prefix=192):
157
- """Rows that start with the same tokens (one state, one question per row): run the shared prefix once, fork its
158
- cache (attention KV + delta-net conv/recurrent states), and run only the question suffixes.
159
- Same answers as score_items up to kernel round-off; cost ~ state + sum(questions) instead of n * state.
160
- The fork is made in chunks that fit `DECIDER_SHARED_FORK_GB`, so the peak memory does not grow with the question
161
- count; the implementation is decider.shared_prefix, shared with EngineV2."""
162
- from decider import shared_prefix # imported here: decider.shared_prefix imports this module
163
- out = shared_prefix.score_shared(self, items, temperature, min_prefix)
164
- if out is None:
165
- return self.score_items(items, temperature)
166
- self.stats["shared_prefix_calls"] = self.stats.get("shared_prefix_calls", 0) + 1
167
- return out
168
-
169
- def warmup(self, shapes=((1, 128), (1, 256), (1, 384), (1, 512), (8, 256), (8, 512), (32, 256), (32, 512))):
170
- t = time.time()
171
- for B, T in shapes:
172
- self.logits_all(torch.full((B, T), self.tok.pad_token_id, dtype=torch.long, device=self.dev))
173
- torch.cuda.synchronize(); return time.time() - t
174
-
175
-
176
- if __name__ == "__main__":
177
- import sys, random, numpy as np
178
- from decider import data as D
179
- from decider.infer import Decider
180
- path = sys.argv[1] if len(sys.argv) > 1 else "runs/r3_v2/model"
181
- cfg = dict(compile="nocompile" not in sys.argv[2:], fp8="fp8" in sys.argv[2:], conv_patch="noconv" not in sys.argv[2:])
182
- _, evals = D.load_cache("data/tasks.pkl")
183
- eng = Engine(path, **cfg); print("engine cfg", eng.cfg)
184
- rng = random.Random(0)
185
- exs = evals["support_tickets"][:64] + evals["clinc_oos"][:64] + evals["race"][:32]
186
- from decider.prompt import chat_for_model
187
- chat = chat_for_model(path, eng.tok)
188
- items = [build(e, eng.tok, rng, max_ctx_tokens=1536, chat=chat) for e in exs]
189
- # correctness vs eager masked forward (DecisionModel.slot_logits)
190
- ref = []
191
- with torch.no_grad():
192
- for i in range(0, len(items), 16):
193
- b = collate(items[i:i + 16], eng.tok.pad_token_id)
194
- lg = eng.m.slot_logits(b["input_ids"].cuda(), b["attention_mask"].cuda(), b["slot_idx"].cuda(), b["slot_batch"].cuda(), b["nopts"].cuda())
195
- ref.append(torch.softmax(lg, -1).cpu())
196
- ref = torch.cat(ref)
197
- got = torch.cat(eng.score_items(items))
198
- print(f"max |p_graph - p_eager| = {(ref - got).abs().max():.4f} over {len(ref)} questions; argmax agreement {(ref.argmax(1) == got.argmax(1)).float().mean():.4f}")
199
- print(f"warmup capture of 8 buckets: {eng.warmup():.1f}s; captures so far {eng.stats['graph_captures']}")
200
- # latency: single real requests
201
- for name, pool in [("support_tickets", exs[:64]), ("clinc_oos", exs[64:128]), ("race", exs[128:])]:
202
- its = [build(e, eng.tok, rng, chat=chat) for e in pool]
203
- ts = []
204
- for it in its[:40]:
205
- torch.cuda.synchronize(); t = time.time(); eng.score_items([it]); torch.cuda.synchronize(); ts.append(time.time() - t)
206
- ts = np.array(ts[5:]) * 1000
207
- print(f"single request {name:16s}: p50 {np.median(ts):5.1f} ms p90 {np.percentile(ts, 90):5.1f} ms (avg {np.mean([len(i['ids']) for i in its]):.0f} tok, {len(its[0]['slots'])} q)")
208
- for bs in (8, 32):
209
- ts = []
210
- for i in range(0, min(len(its), bs * 6), bs):
211
- chunk = its[i:i + bs]
212
- if len(chunk) < bs: break
213
- torch.cuda.synchronize(); t = time.time(); eng.score_items(chunk); torch.cuda.synchronize(); ts.append(time.time() - t)
214
- ts = np.array(ts[1:]) * 1000
215
- print(f" batch {bs:2d}: p50 {np.median(ts):6.1f} ms -> {bs/np.median(ts)*1000:6.0f} ctx/s, {bs*len(its[0]['slots'])/np.median(ts)*1000:6.0f} decisions/s")
216
- print("stats", eng.stats, "graphs", len(eng.graphs), f"mem {torch.cuda.memory_reserved()/1e9:.1f} GB")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/engine_v2.py DELETED
@@ -1,213 +0,0 @@
1
- """Serving engine: every CUDA graph is keyed on (batch bucket, padded length bucket) only.
2
-
3
- Difference from decider.engine.Engine (the library engine behind decider.infer.Decider):
4
- * the full bucket grid is captured at start-up by `warmup()`, then `seal()` forbids any further capture, so no request
5
- can ever pay a graph capture or a torch.compile;
6
- * the length ladder reaches 8192 instead of 2048, so long rows replay a graph instead of running eager;
7
- * a batch larger than the widest captured batch bucket for its length is split into captured chunks rather than
8
- capturing a new shape; rows longer than the last bucket run eager in chunks of at most `token_budget` padded tokens;
9
- * torch.compile and FP8 are off by default.
10
-
11
- Numerics are the same as Engine: right padding + causal layers means padded positions never influence earlier slots, so
12
- the answer for a row does not depend on which (B, T) bucket it was padded into, up to kernel reduction order.
13
-
14
- The attention backend policy of decider.engine (cuDNN SDPA off) is applied in __init__, before compile and capture. It
15
- is what makes `score_shared` (a suffix scored against a cached prefix) return the same answers as the full forward; see
16
- docs/CHANGELOG.md 1.0.2 and tests/test_engine_v2_cuda.py.
17
- """
18
- import time, torch, torch.nn.functional as F
19
- from decider import shared_prefix
20
- from decider.engine import read_slots, fill_ids, patch_conv, set_attention_backend_policy
21
- from decider.model import DecisionModel
22
- from decider.temperature import item_slice, slot_temperatures
23
-
24
- T_BUCKETS = [64, 128, 192, 256, 320, 384, 512, 640, 768, 1024, 1280, 1536, 2048, 3072, 4096, 6144, 8192]
25
- B_BUCKETS = [1, 2, 4, 8, 16, 32]
26
- TOKEN_BUDGET = 32768 # capture (B, T) only when B * T fits this; B = 1 is always captured
27
- LONG_STEP = 1024 # rows longer than the last bucket: pad to a multiple of this and run eager
28
-
29
-
30
- class EngineV2:
31
- """compile / fp8 default to False. `model` lets a test inject a stand-in DecisionModel instead of loading one."""
32
-
33
- def __init__(self, path=None, device="cuda", dtype=torch.bfloat16, use_graphs=None, compile=False, fp8=False,
34
- conv_patch=None, t_buckets=None, b_buckets=None, token_budget=TOKEN_BUDGET, max_ctx_tokens=32768,
35
- model=None):
36
- set_attention_backend_policy() # before compile and capture: captured graphs keep their backend
37
- if conv_patch is None:
38
- conv_patch = bool(compile) # the unrolled depthwise conv only pays off inside a compiled region
39
- if str(device).startswith("mps"):
40
- from decider.mps_ops import patch_mps # the Apple Silicon path of decider.infer.Decider
41
- patch_mps()
42
- elif conv_patch:
43
- patch_conv()
44
- self.m = model if model is not None else DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
45
- self.tok = self.m.tok; self.dev = device; self.max_ctx = max_ctx_tokens
46
- self.core = self.m.lm.model
47
- self.W = self.m.lm.lm_head.weight[self.m.letters].detach().clone()
48
- self.t_buckets = sorted(t_buckets or T_BUCKETS)
49
- self.b_buckets = sorted(b_buckets or B_BUCKETS)
50
- self.token_budget = int(token_budget)
51
- self.use_graphs = (str(device).startswith("cuda") if use_graphs is None else bool(use_graphs))
52
- self.cfg = dict(compile=compile, fp8=fp8, conv_patch=conv_patch, graphs=self.use_graphs,
53
- t_buckets=self.t_buckets, b_buckets=self.b_buckets, token_budget=self.token_budget)
54
- if fp8:
55
- from decider.fp8 import convert_to_fp8
56
- self.cfg["fp8_layers"] = convert_to_fp8(self.core)
57
- if compile:
58
- from torch import _dynamo # not `import torch._dynamo`: that rebinds `torch` locally
59
- n = len(self.graph_shapes())
60
- # one specialisation per captured shape, times the frames inside the model. Without the accumulated
61
- # limits dynamo stops compiling part way through warm-up and silently leaves the rest eager, which with
62
- # conv_patch on is slower than not compiling at all.
63
- _dynamo.config.cache_size_limit = max(256, n + 16)
64
- _dynamo.config.accumulated_cache_size_limit = max(1 << 16, 64 * n)
65
- for k in ("accumulated_recompile_limit", "recompile_limit"):
66
- if hasattr(_dynamo.config, k):
67
- setattr(_dynamo.config, k, max(1 << 16, 64 * n))
68
- self._fwd_impl = torch.compile(self._fwd_eager, dynamic=False)
69
- else:
70
- self._fwd_impl = self._fwd_eager
71
- self.graphs = {} # (B, T) -> (static ids, static out, graph)
72
- self.b_for_t = {} # T -> sorted captured batch buckets
73
- self.pool = torch.cuda.graph_pool_handle() if (self.use_graphs and str(device).startswith("cuda")) else None
74
- self.sealed = False
75
- self.stats = dict(graph_captures=0, forwards=0, replays=0, eager_forwards=0, eager_rows=0,
76
- shared_calls=0, unbucketed_requests=0)
77
-
78
- # ---- bucket arithmetic -------------------------------------------------
79
- def graph_shapes(self):
80
- """The full grid captured at start-up: every (B, T) whose padded token count fits the budget, plus all of B = 1."""
81
- return [(B, T) for T in self.t_buckets for B in self.b_buckets if B == 1 or B * T <= self.token_budget]
82
-
83
- def t_bucket(self, n):
84
- """Padded length bucket for a row of n tokens, or None when it is longer than the last bucket."""
85
- for t in self.t_buckets:
86
- if n <= t:
87
- return t
88
- return None
89
-
90
- def pad_len(self, n):
91
- return self.t_bucket(n) or -(-n // LONG_STEP) * LONG_STEP
92
-
93
- def max_rows(self, T):
94
- """Rows per forward at padded length T: the widest captured batch bucket, or, when nothing is captured at T, as
95
- many rows as fit the token budget (at least 1)."""
96
- av = self.b_for_t.get(T)
97
- return av[-1] if av else max(1, self.token_budget // max(T, 1))
98
-
99
- def b_plan(self, n, T):
100
- """Batch sizes covering n rows at length T, each one a captured bucket. One chunk when a bucket fits."""
101
- av = self.b_for_t.get(T)
102
- if not av:
103
- return [n]
104
- fit = next((b for b in av if b >= n), None)
105
- if fit is not None:
106
- return [fit]
107
- out = []; big = av[-1]
108
- while n > big:
109
- out.append(big); n -= big
110
- out.append(next(b for b in av if b >= n))
111
- return out
112
-
113
- # ---- forward -----------------------------------------------------------
114
- def _fwd_eager(self, ids):
115
- h = self.core(input_ids=ids, use_cache=False).last_hidden_state
116
- return F.linear(h, self.W).float() # [B, T, K]
117
-
118
- @torch.no_grad()
119
- def _fwd(self, ids):
120
- return self._fwd_impl(ids)
121
-
122
- def _capture(self, B, T):
123
- s_ids = torch.full((B, T), self.tok.pad_token_id, dtype=torch.long, device=self.dev)
124
- st = torch.cuda.Stream(); st.wait_stream(torch.cuda.current_stream())
125
- with torch.cuda.stream(st):
126
- for _ in range(3): self._fwd(s_ids)
127
- torch.cuda.current_stream().wait_stream(st)
128
- g = torch.cuda.CUDAGraph()
129
- with torch.cuda.graph(g, pool=self.pool):
130
- s_out = self._fwd(s_ids)
131
- self.stats["graph_captures"] += 1
132
- return s_ids, s_out, g
133
-
134
- @torch.no_grad()
135
- def logits_all(self, ids):
136
- """ids: [B, T] long on device, already right-padded to a captured shape where one exists."""
137
- B, T = ids.shape; self.stats["forwards"] += 1
138
- if self.use_graphs:
139
- g = self.graphs.get((B, T))
140
- if g is None and not self.sealed: # only reachable before seal(); a sealed engine never captures
141
- g = self.graphs[(B, T)] = self._capture(B, T)
142
- self.b_for_t.setdefault(T, [])
143
- if B not in self.b_for_t[T]: self.b_for_t[T] = sorted(self.b_for_t[T] + [B])
144
- if g is not None:
145
- s_ids, s_out, gr = g
146
- s_ids.copy_(ids); gr.replay(); self.stats["replays"] += 1
147
- return s_out
148
- self.stats["eager_forwards"] += 1; self.stats["eager_rows"] += B
149
- return self._fwd_eager(ids) # never the compiled callable: a new shape must not compile
150
-
151
- # ---- scoring -----------------------------------------------------------
152
- @torch.no_grad()
153
- def score_items(self, items, temperature=1.0):
154
- """items: dicts from prompt.build / build_rows. -> one [n_q, MAX_OPTIONS] cpu probability tensor per item.
155
- temperature: a number, or one entry per item (a number or one number per slot; decider.temperature.for_items)."""
156
- if not items:
157
- return []
158
- slot_temperatures(temperature, items) # a length mismatch fails before any forward
159
- Tmax = max(len(it["ids"]) for it in items)
160
- T = self.t_bucket(Tmax)
161
- if T is None:
162
- self.stats["unbucketed_requests"] += 1
163
- T = -(-Tmax // LONG_STEP) * LONG_STEP
164
- per = self.max_rows(T); plan = [per] * (len(items) // per) + ([len(items) % per] if len(items) % per else [])
165
- else:
166
- plan = self.b_plan(len(items), T)
167
- out = []; i = 0
168
- for B in plan:
169
- chunk = items[i:i + B]; i += len(chunk)
170
- ids = fill_ids([it["ids"] for it in chunk], B, T, self.tok.pad_token_id)
171
- lg = self.logits_all(ids.to(self.dev, non_blocking=True))
172
- out += read_slots(lg, [b for b, it in enumerate(chunk) for _ in it["slots"]],
173
- [s for it in chunk for s in it["slots"]], [n for it in chunk for n in it["nopts"]],
174
- slot_temperatures(item_slice(temperature, i - len(chunk), i), chunk), [len(it["slots"]) for it in chunk])
175
- return out
176
-
177
- @torch.no_grad()
178
- def score_shared(self, items, temperature=1.0, min_prefix=192, budget_bytes=None, rows_per_fork=None):
179
- """Rows that start with the same tokens (one state, one question per row): run the shared prefix once, fork its
180
- cache in chunks that fit a byte budget, run only the question suffixes. The algorithm of Engine.score_shared,
181
- shared with it in decider.shared_prefix: it is per request, never per schema, so it adds no state that outlives
182
- the request and no shape that depends on the question set. The prefix and suffix forwards are eager
183
- (request-specific shapes). Correct only with the cuDNN SDPA backend off, which __init__ arranges."""
184
- out = shared_prefix.score_shared(self, items, temperature, min_prefix, budget_bytes, rows_per_fork)
185
- if out is None:
186
- return self.score_items(items, temperature)
187
- self.stats["shared_calls"] += 1
188
- return out
189
-
190
- # ---- start-up ----------------------------------------------------------
191
- def warmup(self, shapes=None, log=None):
192
- """Capture the whole grid. Call seal() afterwards: from then on an unknown shape runs eager, never captures."""
193
- t = time.time(); shapes = list(shapes if shapes is not None else self.graph_shapes())
194
- for j, (B, T) in enumerate(shapes):
195
- self.logits_all(torch.full((B, T), self.tok.pad_token_id, dtype=torch.long, device=self.dev))
196
- if log and (j + 1) % 10 == 0:
197
- log(f"[engine_v2] {j + 1}/{len(shapes)} graphs, {time.time() - t:.0f}s")
198
- if self.use_graphs:
199
- torch.cuda.synchronize()
200
- return time.time() - t
201
-
202
- def seal(self):
203
- self.sealed = True
204
- self.b_for_t = {}
205
- for B, T in self.graphs:
206
- self.b_for_t.setdefault(T, []).append(B)
207
- for T in self.b_for_t:
208
- self.b_for_t[T].sort()
209
- return self
210
-
211
- def describe(self):
212
- return dict(self.cfg, graphs=len(self.graphs), sealed=self.sealed,
213
- grid={str(T): self.b_for_t.get(T, []) for T in self.t_buckets})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/fp8.py DELETED
@@ -1,54 +0,0 @@
1
- """FP8 (e4m3) linear layers for Hopper via torch._scaled_mm.
2
- Weights: per-output-channel scales, quantised once. Activations: per-token dynamic scales.
3
- Under torch.compile the quantisation ops fuse into the surrounding elementwise work."""
4
- import torch, torch.nn as nn
5
-
6
- E4M3_MAX = 448.0
7
-
8
-
9
- def _quant_rowwise(x):
10
- s = x.abs().amax(dim=-1, keepdim=True).float().clamp(min=1e-12) / E4M3_MAX
11
- return (x.float() / s).clamp(-E4M3_MAX, E4M3_MAX).to(torch.float8_e4m3fn), s
12
-
13
-
14
- class FP8Linear(nn.Module):
15
- def __init__(self, lin: nn.Linear):
16
- super().__init__()
17
- wq, sw = _quant_rowwise(lin.weight.detach()) # [N,K] fp8, [N,1]
18
- self.register_buffer("wq", wq.contiguous()) # [N,K]; passed as wq.t() -> [K,N] column-major, as _scaled_mm wants
19
- self.register_buffer("sw_t", sw.t().contiguous()) # [1,N]
20
- self.bias = None if lin.bias is None else nn.Parameter(lin.bias.detach().clone(), requires_grad=False)
21
- self.in_features, self.out_features = lin.in_features, lin.out_features
22
- self.out_dtype = lin.weight.dtype
23
-
24
- def forward(self, x):
25
- shp = x.shape[:-1]
26
- x2 = x.reshape(-1, self.in_features)
27
- xq, sx = _quant_rowwise(x2)
28
- y = torch._scaled_mm(xq, self.wq.t(), scale_a=sx, scale_b=self.sw_t, bias=self.bias, out_dtype=self.out_dtype)
29
- return y.reshape(*shp, self.out_features)
30
-
31
-
32
- def convert_to_fp8(model, skip=("lm_head",), min_dim=1024):
33
- """Replace nn.Linear (with in/out >= min_dim) by FP8Linear in place. Returns count."""
34
- n = 0
35
- for name, mod in list(model.named_modules()):
36
- for cname, child in list(mod.named_children()):
37
- full = f"{name}.{cname}" if name else cname
38
- if isinstance(child, nn.Linear) and not any(s in full for s in skip) and min(child.in_features, child.out_features) >= min_dim:
39
- setattr(mod, cname, FP8Linear(child)); n += 1
40
- return n
41
-
42
-
43
- if __name__ == "__main__":
44
- import time
45
- lin = nn.Linear(2048, 6144, bias=False).cuda().to(torch.bfloat16)
46
- f8 = FP8Linear(lin)
47
- x = torch.randn(8192, 2048, device="cuda", dtype=torch.bfloat16)
48
- ref = lin(x); got = f8(x)
49
- print("rel err", ((ref.float() - got.float()).abs().mean() / ref.float().abs().mean()).item())
50
- for f, name in [(lin, "bf16 linear"), (f8, "fp8 linear (eager)"), (torch.compile(f8), "fp8 linear (compiled)")]:
51
- for _ in range(3): f(x)
52
- torch.cuda.synchronize(); t = time.time()
53
- for _ in range(20): f(x)
54
- torch.cuda.synchronize(); print(f"{name:24s} {(time.time()-t)/20*1000:.3f} ms")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/infer.py DELETED
@@ -1,357 +0,0 @@
1
- """Usable inference API: typed decisions with probabilities, all from one forward pass.
2
-
3
- from decider.infer import Decider
4
- d = Decider("runs/r2_full/model")
5
- out = d.decide("My card was charged twice for the same purchase.",
6
- [{"question": "Which department should handle this?", "options": ["billing", "technical", "sales"]},
7
- {"question": "How urgent is this?", "options": ["low", "medium", "high"]}])
8
- # -> [{'choice': 'billing', 'confidence': 0.97, 'probs': {...}}, {...}]
9
- """
10
- import logging
11
-
12
- import torch
13
- from decider.model import DecisionModel, collate
14
- from decider.prompt import build, MAX_OPTIONS, resolve_layout, chat_template
15
- from decider import temperature as TT
16
- from dataclasses import dataclass
17
-
18
-
19
- @dataclass
20
- class Q:
21
- text: str; options: list; gold: int = 0
22
-
23
-
24
- @dataclass
25
- class Example:
26
- context: str; qs: list; task: str = "infer"; image: bytes = None
27
-
28
-
29
- logger = logging.getLogger(__name__)
30
- NEUTRAL_NONE = "not listed here"
31
-
32
-
33
- def neutralize_options(options):
34
- """The training augmentation used the literal 'none of the above', and the model learned that exact string as an
35
- abstain signal (it abstains even on clear cases when the string is offered). Any option that reads like it is
36
- rewritten to a neutral phrasing for the model and mapped back in the output."""
37
- out, back = [], {}
38
- for o in options:
39
- key = o.strip().lower()
40
- if key.startswith("none of the above") or key in ("none of the above", "none", "n/a", "none of these"):
41
- out.append(NEUTRAL_NONE); back[NEUTRAL_NONE] = o
42
- else:
43
- out.append(o)
44
- return out, back
45
-
46
-
47
- class _NoShuffle: # keep option order as given
48
- def shuffle(self, x): pass
49
- def sample(self, xs, k): return xs[:k]
50
-
51
-
52
- class CompiledSchema:
53
- def __init__(self, d, rqs, h, index):
54
- from decider.systemone import row_types
55
- self.d, self.rqs, self.h, self.index = d, rqs, h, index
56
- self.types = row_types(rqs, index) # answer type of every schema row (decider.temperature)
57
-
58
- def batch(self, states, max_state_tokens=32768):
59
- from decider.systemone import render_state, assemble
60
- T = TT.for_types(self.d.T_schema, self.d.T_schema_by_type, self.types)
61
- probs = self.d._se.score(self.h, [render_state(s) for s in states], temperature=T, max_ctx_tokens=max_state_tokens)
62
- return [{"model": self.d.name, "answers": assemble(self.rqs, self.index, [p.tolist() for p in pr])} for pr in probs]
63
-
64
- def __call__(self, state, max_state_tokens=32768):
65
- return self.batch([state], max_state_tokens)[0]
66
-
67
-
68
- class Decider:
69
- """One-pass decisions with automatic CUDA, MPS, or CPU device selection.
70
-
71
- CUDA uses shape-bucketed graphs by default. MPS defaults to float16 and uses
72
- the optional MPS patch; CPU defaults to bfloat16. Set ``use_graphs=False``
73
- for eager execution or debugging.
74
- """
75
- def __init__(self, path, device=None, dtype=None, temperature=None, abstain_below=0.0, use_graphs=None, temperature_by_type=None):
76
- """The prompt layout comes from decider_config.json: "layout": "chat" (chat-trained checkpoints) wraps every prompt in the
77
- tokenizer's chat template (decider.prompt.build_chat); no "layout" key is the plain layout of every earlier model.
78
- An unknown layout raises ValueError before the weights are loaded.
79
-
80
- Temperatures (decider.temperature): decider_config.json "temperature" and the optional "temperature_by_type"
81
- {"choice": T, "noul": T, "score": T}. temperature= overrides "temperature" and switches the config's by-type map off;
82
- temperature_by_type= sets the map explicitly. An invalid temperature or map key raises ValueError before the weights
83
- are loaded."""
84
- if device is None:
85
- device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
86
- if dtype is None:
87
- dtype = torch.float16 if str(device).startswith("mps") else torch.bfloat16
88
- logger.info("Decider device=%s dtype=%s", device, dtype)
89
- import json, os
90
- cfg = {}
91
- try: # model folder may carry decider_config.json (temperature, flags)
92
- from huggingface_hub import hf_hub_download
93
- cfg_path = os.path.join(path, "decider_config.json") if os.path.isdir(path) else hf_hub_download(path, "decider_config.json")
94
- cfg = json.load(open(cfg_path))
95
- except Exception:
96
- pass
97
- self.layout = resolve_layout(cfg)
98
- (temperature, self.T_by_type), (T_schema, self.T_schema_by_type) = TT.from_config(cfg, temperature, temperature_by_type)
99
- self.neutralize_none = bool(cfg.get("neutralize_none", True)) # v4 and earlier learned the literal string as an abstain signal
100
- if use_graphs is None:
101
- use_graphs = str(device).startswith("cuda")
102
- if use_graphs:
103
- from decider.engine import Engine
104
- self.eng = Engine(path, device=device, dtype=dtype); self.m = self.eng.m
105
- else:
106
- if str(device).startswith("mps"):
107
- from decider.mps_ops import patch_mps
108
- patch_mps()
109
- self.eng = None; self.m = DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
110
- self.chat = chat_template(self.m.tok) if self.layout == "chat" else None # None: plain layout, prompts as in 1.1.x
111
- self.dev = device; self.T = temperature; self.abstain_below = abstain_below
112
- self.name = "decider-" + str(cfg.get("version", "dev"))
113
- self.schema_first = bool(cfg.get("schema_first", False)) and self.eng is not None # default layout. Questions-first (the cacheable one) costs accuracy
114
- self.T_schema = T_schema # (about 1.5 points on fixed label sets, more elsewhere): opt in with schema()
115
- self.isolated_levels = bool(cfg.get("isolated_levels", False)) # Score levels judged one per row (v8+)
116
- self._se = None; self._schemas = {}
117
-
118
- def _decide_items(self, requests, max_ctx_tokens=1536):
119
- """The prompt rows of decide_batch: -> (requests with neutralized options, one item per request)."""
120
- exs = []
121
- if self.neutralize_none:
122
- requests = [(context, [dict(q, options=neutralize_options(q["options"])[0], _back=neutralize_options(q["options"])[1]) for q in qs]) for context, qs in requests]
123
- for context, qs in requests:
124
- for q in qs:
125
- assert 2 <= len(q["options"]) <= MAX_OPTIONS, f"2..{MAX_OPTIONS} options required"
126
- exs.append(Example(context, [Q(q["question"], list(q["options"]), 0) for q in qs], "infer"))
127
- items = [build(e, self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS, max_ctx_tokens=max_ctx_tokens, chat=self.chat) for e in exs]
128
- for it, (_, qs) in zip(items, requests): # answer type per slot: a plain question is "choice"
129
- it["types"] = [q.get("_type", "choice") for q in qs]
130
- return requests, items
131
-
132
- @torch.no_grad()
133
- def decide_batch(self, requests, max_ctx_tokens=1536):
134
- """requests: list of (context:str, questions:list[dict(question, options)]). One forward pass for everything."""
135
- requests, items = self._decide_items(requests, max_ctx_tokens)
136
- T = TT.for_items(self.T, self.T_by_type, items) # the scalar self.T when there is no by-type map
137
- if self.eng is not None:
138
- probs = torch.cat(self.eng.score_items(items, temperature=T))
139
- else:
140
- b = collate(items, self.m.tok.pad_token_id)
141
- logits = self.m.slot_logits(b["input_ids"].to(self.dev), b["attention_mask"].to(self.dev), b["slot_idx"].to(self.dev),
142
- b["slot_batch"].to(self.dev), b["nopts"].to(self.dev))
143
- probs = TT.scaled_softmax(logits, TT.slot_temperatures(T, items)).cpu()
144
- out, k = [], 0
145
- for context, qs in requests:
146
- res = []
147
- for q in qs:
148
- p = probs[k, :len(q["options"])].tolist(); k += 1
149
- j = max(range(len(p)), key=p.__getitem__); back = q.get("_back", {})
150
- names = [back.get(o, o) for o in q["options"]]
151
- res.append(dict(choice=names[j] if p[j] >= self.abstain_below else None, confidence=p[j],
152
- probs={o: pi for o, pi in zip(names, p)}, probs_list=p))
153
- out.append(res)
154
- return out
155
-
156
- def decide(self, context, questions, **kw):
157
- return self.decide_batch([(context, questions)], **kw)[0]
158
-
159
- # ---- Jev-shaped interface (decider.systemone): state + {id: Choice | Score | Noul with criteria}
160
- # ---- schema cache (v7+): the questions are run once, requests only run the state (decider.schema_engine)
161
- def schema(self, questions, independent=True, isolated=None, compile=False):
162
- """Compile a fixed set of Jev-shaped questions: schema(state) -> answers; schema.batch([state, ...]) -> [answers]."""
163
- import json
164
- from decider.schema_engine import SchemaEngine
165
- from decider.systemone import render_question
166
- isolated = self.isolated_levels if isolated is None else isolated
167
- key = (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent, isolated)
168
- if key not in self._schemas:
169
- if self._se is None: self._se = SchemaEngine(self.eng, chat=self.chat)
170
- if len(self._schemas) >= 64: # drop the oldest schema and its graphs
171
- old = next(iter(self._schemas)); hid = self._schemas.pop(old)[1].id
172
- for k in [k for k in self._se.graphs if k[0] == hid]: del self._se.graphs[k]
173
- from decider.systemone import plan_rows
174
- rqs = {k: render_question(v) for k, v in questions.items()}
175
- rows, index = plan_rows(rqs, isolated and independent)
176
- h = self._se.prepare(rows, independent=independent, compile=compile) # compile=True: ~25 s per (batch, length) shape, 1.6x faster after
177
- self._schemas[key] = (rqs, h, index)
178
- return CompiledSchema(self, *self._schemas[key])
179
-
180
- def system_one(self, state, questions, independent=True, max_state_tokens=32768, max_fwd_tokens=65536, layout=None, isolated=None):
181
- layout = layout or ("schema_first" if self.schema_first else "state_first")
182
- isolated = (self.isolated_levels if isolated is None else isolated) and independent
183
- if layout == "schema_first" and self.eng is not None:
184
- return self.schema(questions, independent, isolated)(state, max_state_tokens)
185
- """independent=True scores every question in its own row (state + that question only), so adding, removing or
186
- reordering questions cannot change any other answer; the state is run once and its cache forked to every
187
- question (Engine.score_shared). independent=False packs all questions behind one copy of the state in one row
188
- (later questions can then see earlier question texts)."""
189
- from decider.systemone import unique_tokens, assemble
190
- rqs, index, items = self._system_one_items(state, questions, independent, max_state_tokens, layout, isolated)
191
- flatp = self._system_one_probs(items, layout, max_fwd_tokens, TT.for_items(self.T, self.T_by_type, items))
192
- return {"model": self.name, "answers": assemble(rqs, index, flatp),
193
- "usage": {"input_tokens": unique_tokens(items), "output_tokens": 0}}
194
-
195
- @torch.no_grad()
196
- def _system_one_probs(self, items, layout, max_fwd_tokens=65536, temperature=None):
197
- """Score system_one's uncached rows. temperature: as Engine.score_items takes it (a number, or one entry per item).
198
- -> one probability list per question row, in row order."""
199
- if self.eng is not None and len(items) > 1 and layout == "state_first":
200
- probs = self.eng.score_shared(items, temperature=temperature)
201
- else:
202
- probs = []; per = max(1, max_fwd_tokens // max(len(it["ids"]) for it in items))
203
- for i in range(0, len(items), per):
204
- T = TT.item_slice(temperature, i, i + per)
205
- if self.eng is not None:
206
- probs += self.eng.score_items(items[i:i + per], temperature=T)
207
- else:
208
- bt = collate(items[i:i + per], self.m.tok.pad_token_id)
209
- lg = self.m.slot_logits(*[bt[k].to(self.dev) for k in ("input_ids", "attention_mask", "slot_idx", "slot_batch", "nopts")])
210
- pr = TT.scaled_softmax(lg, TT.slot_temperatures(T, items[i:i + per])).cpu(); c = 0
211
- for it in items[i:i + per]:
212
- probs.append(pr[c:c + len(it["slots"])]); c += len(it["slots"])
213
- return [p.tolist() for ps in probs for p in ps]
214
-
215
- def _system_one_items(self, state, questions, independent=True, max_state_tokens=32768, layout=None, isolated=None):
216
- """The prompt rows of system_one's uncached path: -> (rendered questions, answer index, items)."""
217
- from decider.systemone import render_state, render_question, plan_rows, row_types
218
- layout = layout or ("schema_first" if self.schema_first else "state_first")
219
- isolated = (self.isolated_levels if isolated is None else isolated) and independent
220
- ctx = render_state(state); rqs = {k: render_question(v) for k, v in questions.items()}
221
- opts = (lambda r: neutralize_options(r["options"])[0]) if self.neutralize_none else (lambda r: list(r["options"]))
222
- flat, index = plan_rows(rqs, isolated)
223
- rows = [[r] for r in flat] if independent else [flat]
224
- items = [build(Example(ctx, [Q(r["question"], opts(r), 0) for r in row]), self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS,
225
- max_ctx_tokens=max_state_tokens, layout=layout, chat=self.chat) for row in rows]
226
- types = row_types(rqs, index) # answer type per row (decider.temperature)
227
- for it, ts in zip(items, [[t] for t in types] if independent else [types]):
228
- it["types"] = ts
229
- return rqs, index, items
230
-
231
- # ---- typed schema interface: {question: {"type": "bool"} | {"type": "choice", "options": [...]}
232
- # | {"type": "scale", "legend": {"0": "none", "1": "low", ...}}}
233
- SCHEMA_FORM = ('schema: a map {question: field}, each field one of {"type": "choice", "options": [option strings]}, '
234
- '{"type": "bool"} or {"type": "scale", "legend": [descriptions] or {level number: description}}')
235
-
236
- @staticmethod
237
- def _check_schema(schema):
238
- """Raise ValueError naming the expected form when `schema` does not have it (the HTTP server turns this into a 422).
239
- Every schema that 1.1.2 answered stays accepted, except options or a legend given as a bare string (1.1.2 split it into
240
- characters); an empty schema is answered with {}."""
241
- import json, math
242
- form = Decider.SCHEMA_FORM
243
- if schema is None:
244
- raise ValueError("schema is required; " + form)
245
- if not isinstance(schema, dict):
246
- raise ValueError(f"schema is a {type(schema).__name__}; " + form)
247
- for qtext, spec in schema.items():
248
- where = f"schema[{qtext!r}]"
249
- if not isinstance(spec, dict):
250
- raise ValueError(f"{where} is a {type(spec).__name__}, not a field object; " + form)
251
- t = spec.get("type", "choice")
252
- if t == "choice":
253
- opts = spec.get("options")
254
- if not isinstance(opts, (list, tuple, dict)) or not opts or not all(isinstance(o, str) for o in opts):
255
- raise ValueError(f'{where}: "options" must be a non-empty list of strings; ' + form)
256
- elif t == "scale":
257
- leg = spec.get("legend")
258
- if not isinstance(leg, (list, tuple, dict)) or not leg:
259
- raise ValueError(f'{where}: "legend" must be a non-empty list or {{level number: description}} map; ' + form)
260
- try: # the legend is echoed in the answer, which must serialise
261
- json.dumps(leg, allow_nan=False)
262
- except (TypeError, ValueError):
263
- raise ValueError(f'{where}: the "legend" contains a value that is not finite JSON (NaN or Infinity); ' + form)
264
- if isinstance(leg, dict):
265
- try:
266
- ok = all(math.isfinite(float(k)) for k in leg)
267
- except (TypeError, ValueError, OverflowError):
268
- ok = False
269
- if not ok:
270
- raise ValueError(f'{where}: the keys of a "legend" map must be finite numbers, e.g. {{"0": "none", "1": "low"}}; ' + form)
271
- elif t != "bool":
272
- raise ValueError(f"{where}: unknown field type {t!r}; " + form)
273
-
274
- @staticmethod
275
- def _schema_to_questions(schema):
276
- Decider._check_schema(schema)
277
- qs = []
278
- for qtext, spec in schema.items():
279
- t = spec.get("type", "choice")
280
- if t == "bool":
281
- qs.append(dict(question=qtext, options=["no", "yes"], _type="noul"))
282
- elif t == "choice":
283
- qs.append(dict(question=qtext, options=list(spec["options"]), _type="choice"))
284
- elif t == "scale":
285
- leg = spec["legend"]
286
- keys = sorted(leg, key=lambda k: float(k)) if isinstance(leg, dict) else list(range(len(leg)))
287
- labels = [f"{k}: {leg[k]}" if isinstance(leg, dict) else f"{i}: {leg[i]}" for i, k in enumerate(keys)]
288
- qs.append(dict(question=qtext, options=labels, _keys=keys, _legend=leg, _type="score"))
289
- else:
290
- raise ValueError(f"unknown field type {t}")
291
- return qs
292
-
293
- def decide_json_batch(self, requests, **kw):
294
- """requests: list of (context, schema). Returns one dict per context keyed by question."""
295
- qss = [self._schema_to_questions(schema) for _, schema in requests]
296
- raw = self.decide_batch([(ctx, qs) for (ctx, _), qs in zip(requests, qss)], **kw)
297
- out = []
298
- for (ctx, schema), qs, res in zip(requests, qss, raw):
299
- o = {}
300
- for (qtext, spec), q, r in zip(schema.items(), qs, res):
301
- t = spec.get("type", "choice")
302
- if t == "bool":
303
- o[qtext] = {"noul": round(r["probs"]["yes"], 4), "type": "noul"}
304
- elif t == "choice":
305
- o[qtext] = {"choice": r["choice"], "confidence": round(r["confidence"], 4), "type": "choice",
306
- "probabilities": {k: round(v, 4) for k, v in r["probs"].items()}}
307
- else:
308
- p = [r["probs"][lab] for lab in q["options"]]
309
- keys = q["_keys"]; n = len(p)
310
- score = sum(float(k) * pi for k, pi in zip(keys, p)) # expected level on the legend scale
311
- j = max(range(n), key=p.__getitem__)
312
- o[qtext] = {"score": round(score, 2), "confidence": round(p[j], 4), "type": "scale", "legend": q["_legend"],
313
- "probabilities": {str(keys[i]): round(pi, 4) for i, pi in enumerate(p)}}
314
- out.append(o)
315
- return out
316
-
317
- def decide_json(self, context, schema, **kw):
318
- return self.decide_json_batch([(context, schema)], **kw)[0]
319
-
320
-
321
- if __name__ == "__main__":
322
- import sys, json, time
323
- d = Decider(sys.argv[1] if len(sys.argv) > 1 else "runs/r1_200k/model")
324
- demo = [
325
- ("My card was charged twice for the same purchase and I want the extra charge refunded.",
326
- [{"question": "Which department should handle this?", "options": ["billing", "technical support", "sales"]},
327
- {"question": "What is the customer's sentiment?", "options": ["angry", "neutral", "happy"]},
328
- {"question": "Does this need a refund action?", "options": ["no", "yes"]}]),
329
- ("hey can u turn the lights off in the kitchen",
330
- [{"question": "What is the intent?", "options": ["smart home control", "set alarm", "play music", "none of the above"]},
331
- {"question": "Is this request toxic?", "options": ["no", "yes"]}]),
332
- ("The quarterly report shows revenue fell 12% while costs rose sharply.",
333
- [{"question": "What is the financial sentiment?", "options": ["bearish", "neutral", "bullish"]}]),
334
- ]
335
- t = time.time(); res = d.decide_batch(demo); dt = time.time() - t
336
- for (ctx, qs), r in zip(demo, res):
337
- print("\n>>", ctx)
338
- for q, a in zip(qs, r):
339
- print(f" {q['question']:45s} -> {a['choice']!s:22s} p={a['confidence']:.2f} " + " ".join(f"{o}:{p:.2f}" for o, p in a['probs'].items()))
340
- print(f"\n{sum(len(q) for _, q in demo)} decisions in {dt*1000:.0f} ms (one forward pass)")
341
- schema = {
342
- "Revenue currently impacted?": {"type": "bool"},
343
- "What business impact?": {"type": "choice", "options": ["none", "degraded", "outage"]},
344
- "Integration issue present?": {"type": "bool"},
345
- "Account health status?": {"type": "choice", "options": ["healthy", "watch", "at risk"]},
346
- "Which incident scope?": {"type": "choice", "options": ["single_account", "multi_account", "platform_wide"]},
347
- "Security concern present?": {"type": "bool"},
348
- "Duplicate charge reported?": {"type": "bool"},
349
- "Churn likelihood level?": {"type": "scale", "legend": {"0": "none", "1": "low", "2": "medium", "3": "high"}},
350
- "Human attention needed?": {"type": "bool"},
351
- "Immediate feature request?": {"type": "bool"},
352
- }
353
- ctx = ("Hi, since this morning our Stripe webhook integration stopped firing and our checkout is down for all customers. "
354
- "We are losing orders every minute and our partner launch is on Thursday. Also I think we got billed twice last week. "
355
- "If this is not fixed today we will have to look at other providers.")
356
- t = time.time(); js = d.decide_json(ctx, schema); dt = time.time() - t
357
- print(f"\n>> {ctx[:80]}...\n" + json.dumps(js, indent=1)[:3000]); print(f"{len(schema)} typed fields in {dt*1000:.0f} ms (one forward pass)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/metrics.py DELETED
@@ -1,37 +0,0 @@
1
- import numpy as np
2
-
3
-
4
- def ece(conf, correct, bins=15):
5
- conf = np.asarray(conf); correct = np.asarray(correct, dtype=float)
6
- edges = np.linspace(0, 1, bins + 1); e = 0.0
7
- for lo, hi in zip(edges[:-1], edges[1:]):
8
- m = (conf > lo) & (conf <= hi)
9
- if m.any():
10
- e += m.mean() * abs(conf[m].mean() - correct[m].mean())
11
- return float(e)
12
-
13
-
14
- def aurc(conf, correct):
15
- """Area under risk-coverage curve (lower is better)."""
16
- order = np.argsort(-np.asarray(conf)); c = np.asarray(correct, dtype=float)[order]
17
- risk = np.cumsum(1 - c) / np.arange(1, len(c) + 1)
18
- return float(risk.mean())
19
-
20
-
21
- def sel_acc(conf, correct, coverage):
22
- order = np.argsort(-np.asarray(conf)); c = np.asarray(correct, dtype=float)[order]
23
- n = max(1, int(round(coverage * len(c))))
24
- return float(c[:n].mean())
25
-
26
-
27
- def summarize(probs, golds, nopts):
28
- """probs [N,K] (masked entries 0), golds [N], nopts [N]."""
29
- probs = np.asarray(probs); golds = np.asarray(golds); nopts = np.asarray(nopts)
30
- pred = probs.argmax(1); conf = probs.max(1); correct = (pred == golds)
31
- p_gold = probs[np.arange(len(golds)), golds]
32
- nll = -np.log(np.clip(p_gold, 1e-12, 1)).mean()
33
- onehot = np.zeros_like(probs); onehot[np.arange(len(golds)), golds] = 1
34
- brier = ((probs - onehot) ** 2).sum(1).mean()
35
- return dict(n=int(len(golds)), acc=float(correct.mean()), nll=float(nll), brier=float(brier), ece=ece(conf, correct),
36
- aurc=aurc(conf, correct), acc_at_80=sel_acc(conf, correct, 0.8), acc_at_50=sel_acc(conf, correct, 0.5),
37
- chance=float((1.0 / nopts).mean()), mean_conf=float(conf.mean()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/model.py DELETED
@@ -1,49 +0,0 @@
1
- """Backbone -> slot hidden states -> restricted logits over option letters."""
2
- import torch, torch.nn as nn, torch.nn.functional as F
3
- from transformers import AutoModelForCausalLM, AutoTokenizer
4
- from decider.prompt import letter_ids, MAX_OPTIONS
5
-
6
-
7
- class DecisionModel(nn.Module):
8
- def __init__(self, name, dtype=torch.bfloat16, grad_ckpt=True):
9
- super().__init__()
10
- if hasattr(torch.backends.cuda, "enable_cudnn_sdp"): # as decider.engine.set_attention_backend_policy: the cuDNN SDPA
11
- torch.backends.cuda.enable_cudnn_sdp(False) # backend is wrong for masked attention on Blackwell (torch 2.14)
12
- self.tok = AutoTokenizer.from_pretrained(name)
13
- self.lm = AutoModelForCausalLM.from_pretrained(name, dtype=dtype)
14
- if grad_ckpt:
15
- self.lm.gradient_checkpointing_enable()
16
- self.register_buffer("letters", torch.tensor(letter_ids(self.tok)), persistent=False)
17
-
18
- def slot_logits(self, input_ids, attention_mask, slot_idx, slot_batch, nopts):
19
- """input_ids [B,T]; slot_idx/slot_batch [N] flat slot positions; nopts [N].
20
- Returns [N, MAX_OPTIONS] logits with invalid options masked to -inf."""
21
- h = self.lm.model(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
22
- hs = h[slot_batch, slot_idx] # [N,H]
23
- W = self.lm.lm_head.weight[self.letters] # [K,H]
24
- logits = F.linear(hs, W).float() # [N,K]
25
- ar = torch.arange(MAX_OPTIONS, device=logits.device)[None, :]
26
- logits = logits.masked_fill(ar >= nopts[:, None], float("-inf"))
27
- return logits
28
-
29
- def forward(self, batch):
30
- return self.slot_logits(batch["input_ids"], batch["attention_mask"], batch["slot_idx"], batch["slot_batch"], batch["nopts"])
31
-
32
-
33
- def collate(items, pad_id):
34
- """items: list of dicts from prompt.build (+ 'task', 'ex_id'). Right-pad."""
35
- T = max(len(it["ids"]) for it in items)
36
- T = ((T + 63) // 64) * 64 # few distinct shapes -> fewer kernel (re)compiles
37
- B = len(items)
38
- input_ids = torch.full((B, T), pad_id, dtype=torch.long)
39
- attn = torch.zeros((B, T), dtype=torch.long)
40
- slot_idx, slot_batch, golds, nopts, tasks, qidx = [], [], [], [], [], []
41
- for b, it in enumerate(items):
42
- n = len(it["ids"])
43
- input_ids[b, :n] = torch.tensor(it["ids"])
44
- attn[b, :n] = 1
45
- for k, s in enumerate(it["slots"]):
46
- slot_idx.append(s); slot_batch.append(b); golds.append(it["golds"][k]); nopts.append(it["nopts"][k])
47
- tasks.append(it.get("task", "")); qidx.append(k)
48
- return dict(input_ids=input_ids, attention_mask=attn, slot_idx=torch.tensor(slot_idx), slot_batch=torch.tensor(slot_batch),
49
- golds=torch.tensor(golds), nopts=torch.tensor(nopts), tasks=tasks, qidx=qidx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/mps_moe.py DELETED
@@ -1,102 +0,0 @@
1
- """Two MPS slow paths in transformers' Qwen3.5-MoE reference code, replaced in-process (decider-35b-a3b on Apple Silicon).
2
-
3
- Contributed by @nassersala in issue #6 under the project's Apache-2.0 licence; adapted for the package (out-of-range expert
4
- ids are dropped as `histc` drops them, and the replacements are installed from `decider.mps_ops.patch_mps`).
5
-
6
- On an M-series Mac (torch 2.14, transformers 5.17) `torch.histc` takes about 45 ms a call on MPS (once per MoE layer, to
7
- count tokens per expert) and `torch.linalg.solve_triangular` about 17 ms (twice per linear-attention layer): most of a
8
- 2.8-4 s decision. With both replaced the 35B takes 0.23-0.5 s a decision for typical inputs (reported in issue #6).
9
- The `histc` replacement is an exact count. The block inverse is within 1e-6 of the MPS solver on real systems; on six items
10
- the probabilities moved at most 0.033, less than switching to the exact CPU solver does (0.042), and no argmax changed.
11
-
12
- The replacements are visible only inside those two transformers modules (install() gives each its own view of `torch`), and
13
- there they act only on MPS tensors of the matching call shape; everything else goes to torch.
14
- """
15
- import torch
16
-
17
- _histc, _solve = torch.histc, torch.linalg.solve_triangular
18
-
19
-
20
- def count_ids(x, bins, min=0):
21
- """histc for integer-valued ids binned one per bin: a count of ids min..min+bins-1; other values are dropped as histc
22
- drops them (transformers passes expert-parallel sentinels >= num_experts and relies on that)."""
23
- idx = x.long() - int(min)
24
- keep = (idx >= 0) & (idx < bins)
25
- out = torch.zeros(bins, device=x.device, dtype=x.dtype)
26
- return out.scatter_add_(0, idx.clamp(0, bins - 1), keep.to(x.dtype))
27
-
28
-
29
- def histc(input, bins=100, min=0, max=0, *, out=None):
30
- # expert ids 0..n-1 into n bins over [0, n-1]: bin k holds id k exactly, so this is a count (histc: ~45 ms on MPS)
31
- if input.device.type == "mps" and input.dim() == 1 and out is None and min == 0 and max > 0 and bins == max - min + 1:
32
- return count_ids(input, bins, min)
33
- return _histc(input, bins=bins, min=min, max=max) if out is None else _histc(input, bins=bins, min=min, max=max, out=out)
34
-
35
-
36
- def unit_lower_inverse(A):
37
- """Inverse of unit lower-triangular A (last dim a power of two) by block doubling:
38
- [[A11, 0], [A21, A22]]^-1 = [[X11, 0], [-X22 A21 X11, X22]]."""
39
- n = A.shape[-1]
40
- inv = torch.ones(*A.shape[:-2], n, 1, 1, device=A.device, dtype=A.dtype) # n diagonal 1x1 blocks
41
- b = 1
42
- while b < n:
43
- nb = n // (2 * b)
44
- blocks = A.reshape(*A.shape[:-2], nb, 2 * b, nb, 2 * b).diagonal(dim1=-4, dim2=-2).movedim(-1, -3)
45
- x11, x22, a21 = inv[..., 0::2, :, :], inv[..., 1::2, :, :], blocks[..., b:, :b]
46
- new = torch.zeros(*A.shape[:-2], nb, 2 * b, 2 * b, device=A.device, dtype=A.dtype)
47
- new[..., :b, :b], new[..., b:, b:], new[..., b:, :b] = x11, x22, -(x22 @ a21 @ x11)
48
- inv, b = new, 2 * b
49
- return inv[..., 0, :, :]
50
-
51
-
52
- def solve_unit_lower(A, B):
53
- """solve_triangular(A, B, upper=False, unitriangular=True): only the strict lower triangle of A is read."""
54
- n = A.shape[-1]
55
- return unit_lower_inverse(A.tril(-1) + torch.eye(n, device=A.device, dtype=A.dtype)) @ B
56
-
57
-
58
- def solve_triangular(A, B, *, upper, left=True, unitriangular=False, out=None):
59
- # unit lower-triangular: invert by block doubling, all batched matmuls (the MPS solver: ~17 ms per call)
60
- n = A.shape[-1]
61
- if A.device.type == "mps" and not upper and left and unitriangular and out is None and n > 0 and n & (n - 1) == 0:
62
- return solve_unit_lower(A, B)
63
- return _solve(A, B, upper=upper, left=left, unitriangular=unitriangular, out=out)
64
-
65
-
66
- class _Proxy:
67
- """A module's view of `torch` (or `torch.linalg`) with some attributes replaced; everything else is the real one."""
68
- def __init__(self, real, **over):
69
- self._real, self._over = real, over
70
-
71
- def __getattr__(self, name):
72
- return self._over[name] if name in self._over else getattr(self._real, name)
73
-
74
-
75
- _installed = {}
76
-
77
-
78
- def install():
79
- """Replace the two operations inside the two transformers modules that call them, and nowhere else: the grouped-MoE
80
- expert count in `transformers.integrations.moe` and the gated delta rule in `modeling_qwen3_5_moe` see a `torch` whose
81
- `histc` / `linalg.solve_triangular` are the replacements; every other caller keeps torch's. -> names patched."""
82
- import importlib
83
- targets = {"transformers.integrations.moe": dict(histc=histc),
84
- "transformers.models.qwen3_5_moe.modeling_qwen3_5_moe": dict(linalg=_Proxy(torch.linalg, solve_triangular=solve_triangular))}
85
- for name, over in targets.items():
86
- if name in _installed:
87
- continue
88
- try:
89
- mod = importlib.import_module(name)
90
- except ImportError:
91
- continue
92
- if getattr(mod, "torch", None) is not torch:
93
- continue # not the layout this was written for: leave it alone
94
- _installed[name] = mod
95
- mod.torch = _Proxy(torch, **over)
96
- return sorted(_installed)
97
-
98
-
99
- def uninstall():
100
- for name, mod in list(_installed.items()):
101
- mod.torch = torch
102
- del _installed[name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/mps_ops.py DELETED
@@ -1,268 +0,0 @@
1
- """MPS kernels for Qwen3.5 gated-delta attention.
2
-
3
- The optimization is measured against Transformers' PyTorch reference fallback for
4
- this kernel; it is not an end-to-end model speedup claim.
5
-
6
- The gated-delta and L2-normalization code follows Transformers 5.17's
7
- ``modeling_qwen3_5.py`` under the Apache License 2.0. The MPS inversion and
8
- backend dispatch are Decider additions.
9
- """
10
- import functools
11
- import inspect
12
- import logging
13
- import warnings
14
-
15
- import torch, torch.nn.functional as F
16
- from decider.engine import fused_causal_conv1d_fn
17
-
18
-
19
- # Adapted from transformers 5.17.0's modeling_qwen3_5.py (Apache-2.0).
20
- # The original license applies to the gated-delta and normalization portions;
21
- # the MPS inversion and dispatch below are Decider additions.
22
- # Native Metal Shading Language (MSL) JIT kernel via MLX/Metal
23
- _metal_invert_kernel = None
24
- _metal_failure_reported = False
25
- _compat_warning_reported = False
26
- try:
27
- import mlx.core as mx
28
- import mlx.core.fast as fast
29
-
30
- _METAL_INVERT_SRC = """
31
- uint col = thread_position_in_threadgroup.x;
32
- uint mat_idx = threadgroup_position_in_grid.x;
33
- threadgroup float s_inv[4096];
34
-
35
- for (uint r = 0; r < 64; ++r) {
36
- s_inv[r * 64 + col] = (r == col) ? 1.0f : 0.0f;
37
- }
38
- threadgroup_barrier(mem_flags::mem_threadgroup);
39
-
40
- uint mat_offset = mat_idx * 4096;
41
- for (uint r = 1; r < 64; ++r) {
42
- float sum = 0.0f;
43
- if (col < r) {
44
- for (uint k = col; k < r; ++k) {
45
- sum += L[mat_offset + r * 64 + k] * s_inv[k * 64 + col];
46
- }
47
- }
48
- threadgroup_barrier(mem_flags::mem_threadgroup);
49
- if (col < r) {
50
- s_inv[r * 64 + col] = -sum;
51
- }
52
- threadgroup_barrier(mem_flags::mem_threadgroup);
53
- }
54
- for (uint r = 0; r < 64; ++r) {
55
- out_inv[mat_offset + r * 64 + col] = s_inv[r * 64 + col];
56
- }
57
- """
58
- _metal_invert_kernel = fast.metal_kernel(
59
- name="invert_unitriangular_64",
60
- input_names=["L"],
61
- output_names=["out_inv"],
62
- source=_METAL_INVERT_SRC,
63
- )
64
- except (ImportError, OSError, RuntimeError, AttributeError):
65
- logging.getLogger(__name__).debug("MLX Metal kernel unavailable; using PyTorch MPS fallback", exc_info=True)
66
- _metal_invert_kernel = None
67
-
68
-
69
- def l2norm(x: torch.Tensor, dim: int = -1, eps: float = 1e-6) -> torch.Tensor:
70
- return x * torch.rsqrt((x * x).sum(dim=dim, keepdim=True) + eps)
71
-
72
-
73
- def fast_invert_unitriangular_64(L: torch.Tensor) -> torch.Tensor:
74
- """Invert batches of 64x64 unit lower-triangular matrices."""
75
- global _metal_invert_kernel, _metal_failure_reported
76
- if _metal_invert_kernel is not None and L.is_mps and not L.requires_grad:
77
- assert L.dtype == torch.float32, "Metal inversion requires float32 input"
78
- try:
79
- orig_shape = L.shape
80
- L_flat = L.reshape(-1, 64, 64).contiguous()
81
- num_m = L_flat.shape[0]
82
- torch.mps.synchronize()
83
- L_mx = mx.from_dlpack(torch.to_dlpack(L_flat))
84
- mx.eval(L_mx)
85
- out_mx = _metal_invert_kernel(
86
- inputs=[L_mx],
87
- grid=(num_m * 64, 1, 1),
88
- threadgroup=(64, 1, 1),
89
- output_shapes=[(num_m, 64, 64)],
90
- output_dtypes=[mx.float32],
91
- )[0]
92
- mx.eval(out_mx)
93
- torch.mps.synchronize()
94
- return torch.from_dlpack(out_mx).to(L.device).reshape(orig_shape)
95
- except Exception:
96
- # Optional acceleration must not turn a recoverable backend issue into
97
- # a failed request; disable the failing kernel for this process.
98
- _metal_invert_kernel = None
99
- if not _metal_failure_reported:
100
- logging.getLogger(__name__).warning("MLX Metal inversion failed; using the PyTorch MPS fallback", exc_info=True)
101
- _metal_failure_reported = True
102
-
103
- N = 64
104
- inv = torch.eye(N, device=L.device, dtype=L.dtype).expand_as(L).clone()
105
-
106
- # b = 1: 32 blocks of 2x2. For [[1, 0], [l, 1]], inverse is [[1, 0], [-l, 1]]
107
- idx_row = torch.arange(1, 64, 2, device=L.device)
108
- idx_col = torch.arange(0, 64, 2, device=L.device)
109
- inv[..., idx_row, idx_col] = -L[..., idx_row, idx_col]
110
-
111
- # b = 2: 16 blocks of 4x4
112
- for j in range(16):
113
- r1, r2, r3 = j * 4, j * 4 + 2, j * 4 + 4
114
- inv[..., r2:r3, r1:r2] = -inv[..., r2:r3, r2:r3] @ L[..., r2:r3, r1:r2] @ inv[..., r1:r2, r1:r2]
115
-
116
- # b = 4: 8 blocks of 8x8
117
- for j in range(8):
118
- r1, r2, r3 = j * 8, j * 8 + 4, j * 8 + 8
119
- inv[..., r2:r3, r1:r2] = -inv[..., r2:r3, r2:r3] @ L[..., r2:r3, r1:r2] @ inv[..., r1:r2, r1:r2]
120
-
121
- # b = 8: 4 blocks of 16x16
122
- for j in range(4):
123
- r1, r2, r3 = j * 16, j * 16 + 8, j * 16 + 16
124
- inv[..., r2:r3, r1:r2] = -inv[..., r2:r3, r2:r3] @ L[..., r2:r3, r1:r2] @ inv[..., r1:r2, r1:r2]
125
-
126
- # b = 16: 2 blocks of 32x32
127
- for j in range(2):
128
- r1, r2, r3 = j * 32, j * 32 + 16, j * 32 + 32
129
- inv[..., r2:r3, r1:r2] = -inv[..., r2:r3, r2:r3] @ L[..., r2:r3, r1:r2] @ inv[..., r1:r2, r1:r2]
130
-
131
- # b = 32: 1 block of 64x64
132
- inv[..., 32:64, 0:32] = -inv[..., 32:64, 32:64] @ L[..., 32:64, 0:32] @ inv[..., 0:32, 0:32]
133
- return inv
134
-
135
-
136
- def mps_chunk_gated_delta_rule(
137
- query: torch.Tensor,
138
- key: torch.Tensor,
139
- value: torch.Tensor,
140
- g: torch.Tensor,
141
- beta: torch.Tensor,
142
- chunk_size: int = 64,
143
- initial_state: torch.Tensor | None = None,
144
- output_final_state: bool = False,
145
- use_qk_l2norm_in_kernel: bool = False,
146
- **kwargs,
147
- ) -> tuple[torch.Tensor, torch.Tensor | None]:
148
- """Optimized chunk-gated delta rule for Apple Silicon (MPS)."""
149
- assert chunk_size == 64, f"MPS optimized delta rule requires chunk_size=64, got {chunk_size}"
150
- initial_dtype = query.dtype
151
- batch_size, sequence_length, _, k_head_dim = key.shape
152
- num_v_heads, v_head_dim = value.shape[-2:]
153
- recurrent_state_shape = (batch_size, num_v_heads, k_head_dim, v_head_dim)
154
- padded_output_shape = (batch_size, num_v_heads, -1, v_head_dim)
155
- decay = g
156
-
157
- query, key, value, beta, decay = [
158
- x.transpose(1, 2).to(torch.float32, memory_format=torch.contiguous_format)
159
- for x in (query, key, value, beta, decay)
160
- ]
161
- if use_qk_l2norm_in_kernel:
162
- query = l2norm(query, dim=-1, eps=1e-6)
163
- key = l2norm(key, dim=-1, eps=1e-6)
164
- scaling = query.shape[-1] ** -0.5
165
- query = query * scaling
166
-
167
- pad_size = (chunk_size - sequence_length % chunk_size) % chunk_size
168
- query = F.pad(query, (0, 0, 0, pad_size))
169
- key = F.pad(key, (0, 0, 0, pad_size))
170
- value = F.pad(value, (0, 0, 0, pad_size))
171
- beta = F.pad(beta, (0, pad_size))
172
- decay = F.pad(decay, (0, pad_size))
173
-
174
- v_beta = value * beta.unsqueeze(-1)
175
- k_beta = key * beta.unsqueeze(-1)
176
-
177
- query, key, k_beta, v_beta = [
178
- x.reshape(x.shape[0], x.shape[1], -1, chunk_size, x.shape[-1])
179
- for x in (query, key, k_beta, v_beta)
180
- ]
181
- decay = decay.reshape(decay.shape[0], decay.shape[1], -1, chunk_size)
182
-
183
- strictly_upper_mask = torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device).triu(1)
184
- cum_decay = decay.cumsum(dim=3)
185
- pairwise_decay = (cum_decay.unsqueeze(4) - cum_decay.unsqueeze(3)).masked_fill(strictly_upper_mask, float("-inf")).exp()
186
-
187
- ut_system = (k_beta @ key.transpose(-1, -2)) * pairwise_decay
188
- intra_chunk_attn = (query @ key.transpose(-1, -2)) * pairwise_decay
189
- decayed_k_beta = k_beta * cum_decay.exp().unsqueeze(-1)
190
-
191
- # Fast block divide-and-conquer unitriangular inverse on MPS
192
- L = ut_system.tril(-1)
193
- inv = fast_invert_unitriangular_64(L)
194
- new_values = inv @ v_beta
195
- k_cumdecay = inv @ decayed_k_beta
196
-
197
- if initial_state is None:
198
- last_recurrent_state = torch.zeros(recurrent_state_shape, dtype=new_values.dtype, device=new_values.device)
199
- else:
200
- last_recurrent_state = initial_state.to(new_values)
201
- core_attn_out = torch.zeros_like(new_values)
202
-
203
- query = query * cum_decay.exp().unsqueeze(-1)
204
- key = key * (cum_decay[..., -1:] - cum_decay).exp().unsqueeze(-1)
205
- chunk_decay = cum_decay[..., -1].exp()[..., None, None]
206
-
207
- num_chunks = query.shape[2]
208
- qk = torch.cat([query, k_cumdecay], dim=3)
209
- kt = key.transpose(-1, -2)
210
-
211
- for i in range(num_chunks):
212
- qk_state = qk[:, :, i] @ last_recurrent_state
213
- inter_chunk_attn = qk_state[:, :, :chunk_size]
214
- v_new = new_values[:, :, i] - qk_state[:, :, chunk_size:]
215
- core_attn_out[:, :, i] = inter_chunk_attn + intra_chunk_attn[:, :, i] @ v_new
216
- last_recurrent_state = last_recurrent_state * chunk_decay[:, :, i] + kt[:, :, i] @ v_new
217
-
218
- last_recurrent_state = None if not output_final_state else last_recurrent_state
219
- core_attn_out = core_attn_out.reshape(padded_output_shape)[:, :, :sequence_length]
220
- core_attn_out = core_attn_out.transpose(1, 2).to(initial_dtype, memory_format=torch.contiguous_format)
221
- return core_attn_out, last_recurrent_state
222
-
223
-
224
- def patch_mps():
225
- """Patch Qwen3.5 operations on MPS tensors while preserving other backends."""
226
- global _compat_warning_reported
227
- if not torch.backends.mps.is_available():
228
- return False
229
- import os
230
- if os.environ.get("DECIDER_MPS_MOE_PATCH", "1") != "0": # histc + unit-triangular solve replacements (issue #6, @nassersala)
231
- from decider import mps_moe
232
- mps_moe.install()
233
- try:
234
- import transformers
235
- import transformers.models.qwen3_5.modeling_qwen3_5 as mq
236
- version = tuple(int(part) for part in transformers.__version__.split(".")[:2])
237
- if not all(callable(getattr(mq, name, None)) for name in ("torch_chunk_gated_delta_rule", "causal_conv1d_fn")):
238
- raise AttributeError("unsupported Transformers Qwen3.5 implementation")
239
- params = inspect.signature(mq.torch_chunk_gated_delta_rule).parameters
240
- required = {"query", "key", "value", "g", "beta", "chunk_size"}
241
- if version < (5, 17) or not required.issubset(params):
242
- if not _compat_warning_reported:
243
- warnings.warn(f"MPS patch requires Transformers >=5.17 with the Qwen3.5 gated-delta signature; got {transformers.__version__}", RuntimeWarning, stacklevel=2)
244
- _compat_warning_reported = True
245
- return False
246
- if getattr(mq.torch_chunk_gated_delta_rule, "_decider_mps_patch", False):
247
- return True
248
-
249
- original_delta, original_conv = mq.torch_chunk_gated_delta_rule, mq.causal_conv1d_fn
250
- delta_params = tuple(inspect.signature(original_delta).parameters)
251
- query_index, chunk_index = delta_params.index("query"), delta_params.index("chunk_size")
252
-
253
- @functools.wraps(original_delta)
254
- def delta(*args, **kwargs):
255
- query = args[query_index] if len(args) > query_index else kwargs["query"]
256
- chunk_size = args[chunk_index] if len(args) > chunk_index else kwargs.get("chunk_size", 64)
257
- return mps_chunk_gated_delta_rule(*args, **kwargs) if query.is_mps and chunk_size == 64 else original_delta(*args, **kwargs)
258
-
259
- @functools.wraps(original_conv)
260
- def conv(hidden_states, *args, **kwargs):
261
- return fused_causal_conv1d_fn(hidden_states, *args, **kwargs) if hidden_states.is_mps else original_conv(hidden_states, *args, **kwargs)
262
-
263
- delta._decider_mps_patch = True
264
- mq.torch_chunk_gated_delta_rule, mq.causal_conv1d_fn = delta, conv
265
- return True
266
- except (ImportError, AttributeError, TypeError, ValueError):
267
- logging.getLogger(__name__).debug("MPS patch unavailable; using Transformers fallback", exc_info=True)
268
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/prompt.py DELETED
@@ -1,287 +0,0 @@
1
- """Prompt construction. One context, N typed questions, N answer slots.
2
-
3
- All N decisions are read from a single forward pass: the logits at each
4
- "Answer k: (" slot are restricted to the option-letter tokens. No answer
5
- letters are ever inserted, so slot k sees the context and all questions but
6
- no earlier answers (the decisions are conditionally independent given input).
7
- """
8
- import random
9
-
10
- LETTERS = "ABCDEFGHIJ"
11
- NARROW = len(LETTERS) # <= NARROW options: the original "(A) .. (J)" rendering, tokenized as a string (unchanged since v1)
12
- MAX_OPTIONS = 255 # width of the label head. > NARROW options: "wide" rendering, one label token per option:
13
- # A..Z then the first 229 two-letter upper-case strings that are single tokens (AA, AB, ...)
14
- ABSTAIN_PREFIXES = ("none of the above", "none of these", "not listed", "no suitable", "does not apply", "cannot tell")
15
- ABSTAIN_EXACT = ("other", "unsure", "something else", "neither of these", "other / not covered")
16
-
17
-
18
- def is_abstain_option(o):
19
- o = o.strip().lower()
20
- return o.startswith(ABSTAIN_PREFIXES) or o in ABSTAIN_EXACT
21
-
22
-
23
- _LABELS = {}
24
- _OPT_CACHE = {}
25
-
26
-
27
- def _enc_opt(tok, text):
28
- """Token ids of ") <option text>" (cached: fixed label sets repeat the same strings millions of times)."""
29
- key = (id(tok), text)
30
- v = _OPT_CACHE.get(key)
31
- if v is None:
32
- v = tok.encode(f") {text}", add_special_tokens=False)
33
- if len(_OPT_CACHE) < 2_000_000:
34
- _OPT_CACHE[key] = v
35
- return v
36
-
37
-
38
- def label_table(tok):
39
- """(label strings, label token ids), MAX_OPTIONS entries; the first NARROW are A..J so narrow questions are unchanged."""
40
- key = id(tok)
41
- if key not in _LABELS:
42
- import string
43
- U = string.ascii_uppercase
44
- names = list(U) + [a + b for a in U for b in U]
45
- out = []
46
- for n in names:
47
- t = tok.encode(n, add_special_tokens=False)
48
- if len(t) == 1:
49
- out.append((n, t[0]))
50
- if len(out) == MAX_OPTIONS:
51
- break
52
- assert len(out) == MAX_OPTIONS and len({i for _, i in out}) == MAX_OPTIONS
53
- _LABELS[key] = ([n for n, _ in out], [i for _, i in out],
54
- tok.encode("\n(", add_special_tokens=False))
55
- return _LABELS[key]
56
-
57
-
58
- def _select(q, rng, max_options):
59
- opts = list(range(len(q.options)))
60
- if len(opts) > max_options:
61
- # always keep the gold and any abstain-style option (its mere presence must not carry information)
62
- forced = {q.gold} | {i for i, o in enumerate(q.options) if is_abstain_option(o)}
63
- others = [i for i in opts if i not in forced]
64
- opts = rng.sample(others, max_options - len(forced)) + list(forced)
65
- rng.shuffle(opts)
66
- return opts
67
-
68
-
69
- def _options_ids(tok, q, opts):
70
- if len(opts) <= NARROW:
71
- return tok.encode("".join(f"\n({LETTERS[j]}) {q.options[oi]}" for j, oi in enumerate(opts)), add_special_tokens=False)
72
- _, lab_ids, open_ids = label_table(tok); out = []
73
- for j, oi in enumerate(opts):
74
- out += open_ids + [lab_ids[j]] + _enc_opt(tok, q.options[oi])
75
- return out
76
-
77
-
78
- def build_schema_first(example, tok, rng=None, max_options=NARROW, max_ctx_tokens=1536, chat=None):
79
- """Schema-first layout: all question/option blocks, then the context, then one answer slot per question.
80
-
81
- Question 1: ...\nOptions:\n(A) ... <- prefix: depends only on the questions, so its cache (attention KV and
82
- \n\nQuestion 2: ... delta-net states) is computed once per schema and reused for every state
83
- \n\nContext:\n<state>\n\nAnswer 1: (\nAnswer 2: (
84
-
85
- The three parts are tokenized separately, so `ids[:prefix_len]` is identical for every state.
86
- chat: a ChatTemplate for a chat-layout model (see `build_chat`); None renders the plain layout above."""
87
- rng = rng or random
88
- perms = [_select(q, rng, max_options) for q in example.qs]
89
- pre = schema_prefix_ids(tok, example.qs, perms, chat=chat)
90
- suf, slots = schema_suffix_ids(tok, example.context, len(example.qs), max_ctx_tokens, chat=chat)
91
- return dict(ids=pre + suf, slots=[len(pre) + s for s in slots], golds=[p.index(q.gold) if q.gold in p else -1 for p, q in zip(perms, example.qs)],
92
- nopts=[len(p) for p in perms], perms=perms, prefix_len=len(pre))
93
-
94
-
95
- def schema_prefix_ids(tok, qs, perms=None, chat=None):
96
- """Token ids of the question/option blocks (the cacheable part of the schema-first layout).
97
- chat: a ChatTemplate; the prefix then starts with the template head (the user turn opens before the first question)."""
98
- multi = len(qs) > 1; pre = [] if chat is None else list(chat.head)
99
- for k, q in enumerate(qs):
100
- opts = perms[k] if perms is not None else list(range(len(q.options)))
101
- pre += tok.encode(f"{chr(10) * 2 if k else ''}Question{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:", add_special_tokens=False) + _options_ids(tok, q, opts)
102
- return pre
103
-
104
-
105
- def schema_suffix_ids(tok, context, n_q, max_ctx_tokens=1536, chat=None):
106
- """Token ids after the schema prefix: the context and one answer slot per question. Returns (ids, slot positions in ids).
107
- chat: a ChatTemplate; the context then ends the user turn, and the template tail and the answer pieces follow."""
108
- ids = tok.encode("\n\nContext:\n", add_special_tokens=False) + tok.encode(context, add_special_tokens=False)[:max_ctx_tokens]; slots = []
109
- if chat is not None:
110
- ids += chat.tail
111
- for k in range(n_q):
112
- ids += chat.answer_ids(tok, k, n_q > 1); slots.append(len(ids) - 1)
113
- return ids, slots
114
- for k in range(n_q):
115
- ids += tok.encode(f"{chr(10) * 2 if k == 0 else chr(10)}Answer{' ' + str(k + 1) if n_q > 1 else ''}: (", add_special_tokens=False); slots.append(len(ids) - 1)
116
- return ids, slots
117
-
118
-
119
- def build(example, tok, rng=None, max_options=NARROW, max_ctx_tokens=1536, layout="state_first", chat=None):
120
- """Returns dict(ids=list[int], slots=list[int], golds=list[int], nopts=list[int], perms=list[list[int]]).
121
- chat: a ChatTemplate (chat_template(tok)) for a model trained in the chat layout (decider_config.json "layout": "chat");
122
- None, the default, is the plain layout every earlier model uses, unchanged."""
123
- if layout == "schema_first":
124
- return build_schema_first(example, tok, rng, max_options, max_ctx_tokens, chat=chat)
125
- if chat is not None:
126
- return build_chat(example, tok, chat, rng, max_options, max_ctx_tokens)
127
- rng = rng or random
128
- ctx_ids = tok.encode("Context:\n" + example.context, add_special_tokens=False)[:max_ctx_tokens]
129
- ids = list(ctx_ids)
130
- slots, golds, nopts, perms = [], [], [], []
131
- multi = len(example.qs) > 1
132
- for k, q in enumerate(example.qs):
133
- opts = list(range(len(q.options)))
134
- if len(opts) > max_options:
135
- # always keep the gold and any abstain-style option (its mere presence must not carry information)
136
- forced = {q.gold} | {i for i, o in enumerate(q.options) if is_abstain_option(o)}
137
- others = [i for i in opts if i not in forced]
138
- keep = rng.sample(others, max_options - len(forced)) + list(forced)
139
- opts = keep
140
- rng.shuffle(opts)
141
- head = f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:"
142
- tail = f"\nAnswer{' ' + str(k + 1) if multi else ''}: ("
143
- if len(opts) <= NARROW:
144
- lines = [head] + [f"\n({LETTERS[j]}) {q.options[oi]}" for j, oi in enumerate(opts)] + [tail]
145
- piece = tok.encode("".join(lines), add_special_tokens=False)
146
- else: # wide: "\n(" + <label token> + ") text", built from ids so every label is one token
147
- _, lab_ids, open_ids = label_table(tok)
148
- piece = tok.encode(head, add_special_tokens=False)
149
- for j, oi in enumerate(opts):
150
- piece += open_ids + [lab_ids[j]] + _enc_opt(tok, q.options[oi])
151
- piece += tok.encode(tail, add_special_tokens=False)
152
- ids.extend(piece)
153
- slots.append(len(ids) - 1) # position of " (" token
154
- golds.append(opts.index(q.gold) if q.gold in opts else -1)
155
- nopts.append(len(opts))
156
- perms.append(opts)
157
- return dict(ids=ids, slots=slots, golds=golds, nopts=nopts, perms=perms)
158
-
159
-
160
- # ---- chat layout (1.2.0) ----------------------------------------------------------------------------------------------
161
- # A model whose decider_config.json has "layout": "chat" (or "chat_template": true) was trained with every prompt wrapped
162
- # in its tokenizer's chat template: one user turn holding the plain content, thinking switched off, and the answer pieces in
163
- # the assistant turn. For Qwen3.5, state-first:
164
- #
165
- # <|im_start|>user\n Context:\n<state> \n\nQuestion: <q>\nOptions:\n(A) ..\n(B) ..
166
- # <|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n Answer: (
167
- #
168
- # With several questions in one row all question blocks come first and the answer pieces ("Answer 1: (", "\nAnswer 2: (",
169
- # ...) follow the template tail. Schema-first: the user turn holds the question blocks and then "\n\nContext:\n<state>".
170
- # Every part is tokenized on its own (head, context, each question header, each option block, tail, each answer piece);
171
- # the letter is read at the final " (" token as in the plain layout.
172
- LAYOUTS = ("plain", "chat")
173
-
174
-
175
- def resolve_layout(cfg):
176
- """The prompt layout a decider_config.json names: "plain" (no "layout" key and no "chat_template": true, which is every
177
- released model) or "chat". Raises ValueError for any other value, so a model trained in a layout
178
- this version does not know is refused instead of being read in the wrong one."""
179
- cfg = cfg or {}
180
- layout = cfg.get("layout")
181
- if layout is None:
182
- layout = "chat" if cfg.get("chat_template") is True else "plain"
183
- if layout not in LAYOUTS:
184
- raise ValueError(f"decider_config.json names the prompt layout {layout!r}; this version of decider-ai knows "
185
- f"{', '.join(repr(x) for x in LAYOUTS)}. Upgrade decider-ai, or check the model's config.")
186
- if layout == "plain" and cfg.get("chat_template") is True:
187
- raise ValueError('decider_config.json says "layout": "plain" and "chat_template": true; these contradict each other')
188
- return layout
189
-
190
-
191
- class ChatTemplate:
192
- """Token ids of the tokenizer's chat template around one user turn, and the answer pieces of the assistant turn.
193
-
194
- head/tail come from tok.apply_chat_template([user turn], add_generation_prompt=True) with thinking disabled
195
- (enable_thinking=False where the template knows the switch; a thinking block the template leaves open is closed here),
196
- with no system prompt. This is the computation of the chat-layout research server the chat models were trained against."""
197
- SENTINEL = "@@DECIDER_USER_CONTENT@@"
198
-
199
- def __init__(self, tok, answer="Answer"):
200
- if not getattr(tok, "chat_template", None):
201
- raise ValueError('the model is configured for the chat layout ("layout": "chat") but its tokenizer has no chat template')
202
- msgs = [{"role": "user", "content": self.SENTINEL}]
203
- kw = {"enable_thinking": False} if "enable_thinking" in tok.chat_template else {}
204
- s = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, **kw)
205
- head, tail = s.split(self.SENTINEL)
206
- for opened, closed in (("<think>\n", "</think>\n\n"), ("<think>", "</think>\n\n"), ("<|channel>thought\n", "<channel|>")):
207
- if tail.endswith(opened):
208
- tail += closed
209
- self.head_text, self.tail_text = head, tail
210
- self.head = tok.encode(head, add_special_tokens=False)
211
- self.tail = tok.encode(tail, add_special_tokens=False)
212
- self.answer = answer
213
- names, ids, _ = label_table(tok)
214
- pre = tok.encode(f"{answer}: (", add_special_tokens=False)
215
- for n, i in list(zip(names, ids))[:NARROW]:
216
- if tok.encode(f"{answer}: ({n}", add_special_tokens=False) != pre + [i]:
217
- raise ValueError(f"chat layout: the label {n!r} does not tokenize as one token after {answer!r}: (")
218
- if tok.encode(tail + f"{answer}: (", add_special_tokens=False) != self.tail + pre:
219
- raise ValueError("chat layout: the chat template tail merges with the answer prefix")
220
-
221
- def answer_ids(self, tok, k, multi):
222
- """Token ids of answer piece k: "Answer: (" (or "Answer k: (" with several questions), later pieces after a newline."""
223
- return tok.encode(f"{'' if k == 0 else chr(10)}{self.answer}{' ' + str(k + 1) if multi else ''}: (", add_special_tokens=False)
224
-
225
-
226
- _CHAT = {}
227
-
228
-
229
- def chat_template(tok):
230
- """The ChatTemplate of a tokenizer, cached per tokenizer object."""
231
- key = id(tok)
232
- if key not in _CHAT:
233
- _CHAT[key] = (tok, ChatTemplate(tok)) # keep tok alive so its id is not reused
234
- return _CHAT[key][1]
235
-
236
-
237
- def chat_for(tok, cfg):
238
- """The ChatTemplate to pass as `chat=` for a model with this decider_config.json, or None for a plain-layout model."""
239
- return chat_template(tok) if resolve_layout(cfg) == "chat" else None
240
-
241
-
242
- def load_decider_config(path):
243
- """decider_config.json of a model folder or a Hub repository id; {} when the model has none (a raw base model)."""
244
- import json, os
245
- if os.path.isdir(path):
246
- f = os.path.join(path, "decider_config.json")
247
- return json.load(open(f)) if os.path.exists(f) else {}
248
- try:
249
- from huggingface_hub import hf_hub_download
250
- return json.load(open(hf_hub_download(path, "decider_config.json")))
251
- except Exception:
252
- return {}
253
-
254
-
255
- def chat_for_model(path, tok):
256
- """chat_for(tok, the model's decider_config.json): what every script that builds prompts for `path` passes as `chat=`."""
257
- return chat_for(tok, load_decider_config(path))
258
-
259
-
260
- def build_chat(example, tok, chat, rng=None, max_options=NARROW, max_ctx_tokens=1536):
261
- """State-first chat layout (see the comment above LAYOUTS). Same return fields as `build`. max_ctx_tokens caps the
262
- tokens of "Context:\\n" + state, as in the plain layout; the template head and tail are not counted."""
263
- rng = rng or random
264
- qs = example.qs; multi = len(qs) > 1
265
- ids = list(chat.head) + tok.encode("Context:\n" + example.context, add_special_tokens=False)[:max_ctx_tokens]
266
- golds, nopts, perms = [], [], []
267
- for k, q in enumerate(qs):
268
- opts = _select(q, rng, max_options)
269
- ids += tok.encode(f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:", add_special_tokens=False) + _options_ids(tok, q, opts)
270
- golds.append(opts.index(q.gold) if q.gold in opts else -1); nopts.append(len(opts)); perms.append(opts)
271
- ids += chat.tail
272
- slots = []
273
- for k in range(len(qs)):
274
- ids += chat.answer_ids(tok, k, multi); slots.append(len(ids) - 1)
275
- return dict(ids=ids, slots=slots, golds=golds, nopts=nopts, perms=perms)
276
-
277
-
278
- def letter_ids(tok):
279
- ids = label_table(tok)[1]
280
- for j, L in enumerate(LETTERS):
281
- assert tok.encode(L, add_special_tokens=False) == [ids[j]], L
282
- return ids
283
-
284
-
285
- def render(example, tok, **kw):
286
- b = build(example, tok, **kw)
287
- return tok.decode(b["ids"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/prompt_fast.py DELETED
@@ -1,90 +0,0 @@
1
- """Row construction for the systemone path that tokenizes the state once per request instead of once per question.
2
-
3
- `prompt.build` encodes `"Context:\\n" + state` for every row it is called with, so an independent request with n
4
- questions tokenizes the whole state n times. The question block is encoded as its own string there and simply appended,
5
- so the ids are exactly `ctx_ids + question_piece`; this module reuses that fact and shares `ctx_ids` across the rows.
6
-
7
- `build_rows` returns the same item dicts as
8
- [prompt.build(Example(ctx, [Q(text, options, 0) for text, options in row]), tok, <no-shuffle rng>,
9
- max_options=MAX_OPTIONS, max_ctx_tokens=max_ctx_tokens) for row in rows]
10
- (ids, slots, golds, nopts, perms) and is checked against it in tests/test_prompt_fast.py.
11
- """
12
- from decider.prompt import LETTERS, NARROW, MAX_OPTIONS, label_table, _enc_opt, _options_ids
13
-
14
-
15
- def context_ids(tok, context, max_ctx_tokens=32768):
16
- return tok.encode("Context:\n" + context, add_special_tokens=False)[:max_ctx_tokens]
17
-
18
-
19
- def question_piece(tok, text, options, k=0, multi=False):
20
- """Token ids of one `\\n\\nQuestion...: <text>\\nOptions:...\\nAnswer...: (` block, independent of the state."""
21
- head = f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {text}\nOptions:"
22
- tail = f"\nAnswer{' ' + str(k + 1) if multi else ''}: ("
23
- if len(options) <= NARROW:
24
- return tok.encode(head + "".join(f"\n({LETTERS[j]}) {o}" for j, o in enumerate(options)) + tail,
25
- add_special_tokens=False)
26
- _, lab_ids, open_ids = label_table(tok)
27
- piece = tok.encode(head, add_special_tokens=False)
28
- for j, o in enumerate(options):
29
- piece += open_ids + [lab_ids[j]] + _enc_opt(tok, o)
30
- return piece + tok.encode(tail, add_special_tokens=False)
31
-
32
-
33
- class _Opts:
34
- def __init__(self, options): self.options = options
35
-
36
-
37
- def _chat_row(tok, chat, ctx, row):
38
- """One chat-layout row after the shared ids `ctx` (template head + context): every question block, the template tail,
39
- then the answer pieces. Equal to prompt.build_chat for the same row (tests/test_layout.py)."""
40
- multi = len(row) > 1
41
- ids = list(ctx)
42
- for k, (text, options) in enumerate(row):
43
- ids += tok.encode(f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {text}\nOptions:", add_special_tokens=False)
44
- ids += _options_ids(tok, _Opts(options), list(range(len(options))))
45
- ids += chat.tail
46
- slots = []
47
- for k in range(len(row)):
48
- ids += chat.answer_ids(tok, k, multi); slots.append(len(ids) - 1)
49
- return ids, slots
50
-
51
-
52
- def build_rows(tok, context, rows, max_ctx_tokens=32768, chat=None):
53
- """rows: list of rows, each a list of (question text, options). -> (items, len(ctx_ids)).
54
-
55
- Option order is kept as given (no shuffling, no subsetting): systemone.render_question already caps a choice at
56
- MAX_OPTIONS options, so prompt.build's sampling branch is unreachable here.
57
- chat: a ChatTemplate (prompt.chat_template) for a chat-layout model; the shared ids are then the template head plus the
58
- context, and each row is prompt.build_chat's rendering."""
59
- ctx = context_ids(tok, context, max_ctx_tokens)
60
- if chat is not None:
61
- ctx = list(chat.head) + ctx
62
- items = []
63
- for row in rows:
64
- multi = len(row) > 1
65
- ids = list(ctx); slots = []; nopts = []
66
- for k, (text, options) in enumerate(row):
67
- if not 2 <= len(options) <= MAX_OPTIONS:
68
- raise ValueError(f"2..{MAX_OPTIONS} options required")
69
- if chat is None:
70
- ids.extend(question_piece(tok, text, options, k, multi))
71
- slots.append(len(ids) - 1)
72
- nopts.append(len(options))
73
- if chat is not None:
74
- ids, slots = _chat_row(tok, chat, ctx, row)
75
- items.append(dict(ids=ids, slots=slots, golds=[0] * len(row), nopts=nopts,
76
- perms=[list(range(len(o))) for _, o in row]))
77
- return items, len(ctx)
78
-
79
-
80
- def unique_tokens(items, ctx_len):
81
- """systemone.unique_tokens(items) without re-walking the shared context (all rows start with the same ctx_len ids).
82
- No rows (a request with no questions) counts 0, as systemone.unique_tokens does."""
83
- if not items:
84
- return 0
85
- sufs = [it["ids"][ctx_len:] for it in items]
86
- if len(sufs) < 2:
87
- return ctx_len + sum(len(s) for s in sufs)
88
- lcp = 0; short = min(len(s) for s in sufs); s0 = sufs[0]
89
- while lcp < short and all(s[lcp] == s0[lcp] for s in sufs): lcp += 1
90
- return ctx_len + lcp + sum(len(s) - lcp for s in sufs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/schema_engine.py DELETED
@@ -1,150 +0,0 @@
1
- """Schema cache: compute a question schema once, then score states against it.
2
-
3
- In production the questions are fixed and only the state changes. With the schema-first prompt layout
4
- (prompt.build_schema_first) the question/option blocks are a prefix that does not depend on the state, so their
5
- cache - attention K/V for the 6 full-attention layers, conv + recurrent state for the 18 delta-net layers - is computed
6
- once (`prepare`). A request then runs only "Context: <state>" plus one answer slot per question, as a CUDA graph per
7
- (batch, length) bucket. The prefix cache is read-only during a request (nothing is written back), so one copy serves
8
- every batch and every graph.
9
-
10
- se = SchemaEngine(engine); h = se.prepare([{"question": ..., "options": [...]}, ...])
11
- probs = se.score(h, ["state 1", "state 2", ...]) # list of [n_questions, MAX_OPTIONS] tensors
12
- """
13
- import time, types, torch, torch.nn.functional as F
14
- from decider.prompt import schema_prefix_ids, schema_suffix_ids, MAX_OPTIONS
15
- from decider.engine import read_slots, fill_ids
16
-
17
- TS_BUCKETS = [32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024]
18
- B_BUCKETS = [1, 2, 4, 8, 16, 32, 64]
19
-
20
-
21
- class _Q:
22
- def __init__(self, text, options): self.text, self.options = text, options
23
-
24
-
25
- class PrefixCache:
26
- """Duck-typed transformers Cache over fixed, read-only prefixes, for one suffix forward pass.
27
- A handle holds P prefixes (P = 1: all questions packed in one prefix; P = n_questions: one prefix per question, so every
28
- question is scored independently). A batch of R states has R * P rows; row r * P + p continues prefix p."""
29
- def __init__(self, h, R):
30
- rep = (lambda t: t.expand(R, *t.shape[1:])) if h.P == 1 else (lambda t: t.repeat(R, *([1] * (t.dim() - 1))))
31
- self.tp = h.tpmax; self.k = {i: rep(k) for i, k in h.k.items()}; self.v = {i: rep(v) for i, v in h.v.items()}
32
- self.conv = {i: rep(c).contiguous() for i, c in h.conv.items()}
33
- self.layers = {i: types.SimpleNamespace(record_past=False, recurrent_states={0: rep(r).contiguous()}) for i, r in h.rec.items()}
34
-
35
- def has_previous_state(self, layer_idx=None, state_idx=None): return True
36
- def get_seq_length(self, *a, **k): return self.tp
37
- def update(self, key, value, layer_idx, *a, **k): return torch.cat([self.k[layer_idx], key], 2), torch.cat([self.v[layer_idx], value], 2)
38
- def update_conv_state(self, x, layer_idx, **k): return torch.cat([self.conv[layer_idx].to(x.dtype), x], -1)
39
- def update_recurrent_state(self, s, layer_idx, **k): return s
40
-
41
-
42
- class SchemaEngine:
43
- def __init__(self, engine, use_graphs=True, chat=None):
44
- """chat: the ChatTemplate of a chat-layout model (decider.prompt.chat_template); the prefix then starts with the template
45
- head and the suffix ends with the template tail and the answer pieces (decider.prompt.build_schema_first with chat)."""
46
- self.chat = chat
47
- self.e = engine; self.core = engine.core; self.W = engine.W; self.tok = engine.tok; self.dev = engine.dev
48
- self.use_graphs = use_graphs and engine.use_graphs; self.graphs = {}; self.stats = dict(prepared=0, captures=0, replays=0, eager=0)
49
- self.compile = bool(engine.cfg.get("compile")); self._compiled = {}
50
- if self.compile: # every compiled schema graph specialises the model frames again (its cache tensors are constants)
51
- import torch._dynamo
52
- torch._dynamo.config.cache_size_limit = 4096; torch._dynamo.config.accumulated_cache_size_limit = 1 << 16
53
-
54
- @torch.no_grad()
55
- def prepare(self, questions, independent=False, compile=False):
56
- """questions: [{"question": str, "options": [str]}] in the order answers are wanted. Runs the prefix(es) once.
57
- independent=False: one prefix holding every question (cheapest: a request costs state + n slots).
58
- independent=True: one prefix per question, one row per question (a request costs n * (state + 1 slot); no question
59
- can influence another)."""
60
- qs = [_Q(q["question"], list(q["options"])) for q in questions]
61
- groups = [[q] for q in qs] if independent else [qs]; pres = [schema_prefix_ids(self.tok, g, chat=self.chat) for g in groups]
62
- h = types.SimpleNamespace(P=len(groups), nq=len(qs), slots_per_row=1 if independent else len(qs), nopts=[len(q.options) for q in qs], tps=[len(p) for p in pres],
63
- tpmax=max(len(p) for p in pres), k={}, v={}, conv={}, rec={}, id=self.stats["prepared"],
64
- compile=bool(compile and self.compile))
65
- parts = []
66
- for pre in pres:
67
- out = self.core(input_ids=torch.tensor(pre, device=self.dev)[None], use_cache=True).past_key_values; d = dict(k={}, v={}, conv={}, rec={})
68
- for i, layer in enumerate(out.layers):
69
- if getattr(layer, "recurrent_states", None) is not None and layer.recurrent_states.get(0) is not None:
70
- d["conv"][i] = layer.conv_states[0]; d["rec"][i] = layer.recurrent_states[0]
71
- else: # right-pad every prefix's K/V to the longest; the mask hides the padding
72
- pad = (0, 0, 0, h.tpmax - len(pre)); d["k"][i] = F.pad(layer.keys, pad); d["v"][i] = F.pad(layer.values, pad)
73
- parts.append(d)
74
- for name in ("k", "v", "conv", "rec"):
75
- getattr(h, name).update({i: torch.cat([d[name][i] for d in parts], 0).clone() for i in parts[0][name]})
76
- self.stats["prepared"] += 1
77
- return h
78
-
79
- def _fwd(self, ids, cache, mask, pos):
80
- hs = self.core(input_ids=ids, past_key_values=cache, attention_mask={"full_attention": mask, "linear_attention": None}, position_ids=pos, use_cache=True).last_hidden_state
81
- return F.linear(hs, self.W).float()
82
-
83
- def _static(self, h, R, Ts):
84
- """R request slots -> R * P rows. Mask: a row sees its own prefix (not the padding up to tpmax) and the causal suffix."""
85
- ar = torch.arange(Ts, device=self.dev); tps = torch.tensor(h.tps, device=self.dev).repeat(R) # [R*P]
86
- pre = (torch.arange(h.tpmax, device=self.dev)[None, :] < tps[:, None])[:, None, None, :].expand(-1, 1, Ts, -1) # [B,1,Ts,tpmax]
87
- mask = torch.cat([pre, (ar[:, None] >= ar[None, :])[None, None].expand(len(tps), 1, -1, -1)], 3).contiguous()
88
- return PrefixCache(h, R), mask, (tps[:, None] + ar[None, :]).contiguous()
89
-
90
- def _capture(self, h, R, Ts):
91
- B = R * h.P
92
- ids = torch.full((B, Ts), self.tok.pad_token_id, dtype=torch.long, device=self.dev); cache, mask, pos = self._static(h, R, Ts)
93
- fwd = self._fwd
94
- if h.compile: # one compiled function per graph (20-30 s each: only for preloaded schemas): the cache tensors are constants of that graph
95
- fwd = torch.compile(lambda i: self._fwd(i, cache, mask, pos), dynamic=False)
96
- call = lambda: fwd(ids)
97
- else:
98
- call = lambda: fwd(ids, cache, mask, pos)
99
- st = torch.cuda.Stream(); st.wait_stream(torch.cuda.current_stream())
100
- with torch.cuda.stream(st):
101
- for _ in range(3): call()
102
- torch.cuda.current_stream().wait_stream(st)
103
- g = torch.cuda.CUDAGraph()
104
- with torch.cuda.graph(g, pool=self.e.pool):
105
- out = call()
106
- self.stats["captures"] += 1
107
- return ids, out, g, (cache, mask, pos)
108
-
109
- def warmup(self, h, batch_sizes=(1, 8, 32), state_tokens=(64, 128, 256)):
110
- """Capture (and, for a compiled schema, compile) the graphs for these request-batch sizes and suffix lengths ahead of traffic."""
111
- t = time.time()
112
- for R in batch_sizes:
113
- for Ts in state_tokens:
114
- Ts = next((x for x in TS_BUCKETS if x >= Ts), TS_BUCKETS[-1])
115
- if (h.id, R, Ts) not in self.graphs: self.graphs[(h.id, R, Ts)] = self._capture(h, R, Ts)
116
- torch.cuda.synchronize(); return time.time() - t
117
-
118
- def tokenize(self, h, context, max_ctx_tokens=1536):
119
- """CPU part of a request (do it outside any GPU lock): -> (suffix ids, slot positions)."""
120
- return schema_suffix_ids(self.tok, context, h.slots_per_row, max_ctx_tokens, chat=self.chat)
121
-
122
- @staticmethod
123
- def bucket(n_tokens):
124
- return next((t for t in TS_BUCKETS if t >= n_tokens), -(-n_tokens // 256) * 256)
125
-
126
- def score(self, h, contexts, temperature=1.0, max_ctx_tokens=1536):
127
- """-> one [n_questions, MAX_OPTIONS] probability tensor per context. temperature: as in score_rows."""
128
- return self.score_rows(h, [self.tokenize(h, c, max_ctx_tokens) for c in contexts], temperature)
129
-
130
- @torch.no_grad()
131
- def score_rows(self, h, rows, temperature=1.0):
132
- """rows: [(suffix ids, slots)] from tokenize(). temperature: a number, or a list with one temperature per schema row
133
- (h.nq values, in the order of prepare's questions), applied to every request."""
134
- if isinstance(temperature, (list, tuple)) and len(temperature) != h.nq:
135
- raise ValueError(f"temperature: {len(temperature)} values for a schema with {h.nq} rows")
136
- Tmax = max(len(r[0]) for r in rows); Ts = next((t for t in TS_BUCKETS if t >= Tmax), None); n = len(rows)
137
- R = next((b for b in B_BUCKETS if b >= n), n) if Ts else n; Ts = Ts or -(-Tmax // 256) * 256
138
- ids = fill_ids([x for x, _ in rows for _ in range(h.P)], R * h.P, Ts, self.tok.pad_token_id).to(self.dev, non_blocking=True)
139
- if self.use_graphs and Ts <= TS_BUCKETS[-1]:
140
- key = (h.id, R, Ts)
141
- if key not in self.graphs: self.graphs[key] = self._capture(h, R, Ts)
142
- s_ids, s_out, g, _ = self.graphs[key]; s_ids.copy_(ids); g.replay(); out = s_out; self.stats["replays"] += 1
143
- else:
144
- out = self._fwd(ids, *self._static(h, R, Ts)); self.stats["eager"] += 1
145
- if h.P == 1: # packed: n slots in one row per request
146
- rws = [r for r in range(n) for _ in range(h.nq)]; sls = [x for _, sl in rows for x in sl]
147
- else: # independent: one slot in each of the request's P rows
148
- rws = [r * h.P + p for r in range(n) for p in range(h.P)]; sls = [sl[0] for _, sl in rows for _ in range(h.P)]
149
- temps = list(temperature) * n if isinstance(temperature, (list, tuple)) else temperature
150
- return read_slots(out, rws, sls, h.nopts * n, temps, [h.nq] * n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/serve.py DELETED
@@ -1,562 +0,0 @@
1
- """HTTP server.
2
- POST /decide {"context": str, "schema": {...}} -> typed JSON decisions (all questions packed in one row)
3
- schema: {question: {"type": "choice", "options": [...]} | {"type": "bool"} | {"type": "scale", "legend": [...]}};
4
- a missing or malformed schema is a 422 naming this form (decider.infer.Decider._check_schema)
5
- POST /v1/systemone {"state": str|object|array, "questions": {id: {...}}} -> the TypeSafe/Jev wire format (decider.systemone):
6
- Choice (up to 255 described options), Score, Noul; every question is scored in its own row, so answers
7
- are independent of each other ("independent": false packs them behind one copy of the state instead).
8
- GET /v1/models /health /stats
9
-
10
- uvicorn decider.serve:app --host 0.0.0.0 --port 8000 (env: DECIDER_MODEL and the variables below)
11
-
12
- Execution (1.1.1; docs/SERVING.md has the design and the measurements):
13
- * decider.engine_v2.EngineV2: CUDA graphs keyed on (batch bucket, padded length bucket) only. The whole grid is captured
14
- during start-up and the engine is then sealed, so no request pays a graph capture or a torch.compile. Rows longer than the
15
- last length bucket (8,192 tokens) run eager, in chunks bounded by DECIDER_GRAPH_TOKEN_BUDGET padded tokens.
16
- * one GPU thread runs every forward; tokenisation runs on a CPU pool; rows waiting at the same moment are partitioned by
17
- decider.batching.plan_batches, which pads a shorter row into a longer row's bucket when that costs less than a second
18
- forward (cost model: DECIDER_MERGE_OVERHEAD_TOKENS + rows * padded length). When a collection held rows from more than
19
- one live request the next one waits up to DECIDER_BATCH_ADAPTIVE_WAIT_MS for more rows, unless its own first row took
20
- more than 50 ms to arrive (a quiet queue drops the window again); after a single-request collection it does not wait.
21
- * an independent request with more than one question over a state of at least DECIDER_SHARED_MIN_TOKENS tokens runs the state
22
- once and forks its cache per question (EngineV2.score_shared, decider.shared_prefix). The fork is made in chunks that fit
23
- DECIDER_SHARED_FORK_GB, so the peak memory does not grow with the question count. The cuDNN SDPA backend is off
24
- (decider.engine.set_attention_backend_policy), which is what makes that path agree with the full forward.
25
- * the schema cache (questions-first layout, prefix cached per question set) is honoured exactly as before: on when
26
- decider_config.json has "schema_first": true, or with DECIDER_SCHEMA_CACHE=1 on a model trained for that layout. It is
27
- the one path that does GPU work after start-up that is not a graph replay: the first request with a new schema runs its
28
- prefix and captures one graph per (schema, batch bucket, state bucket) as in 1.0.x (/stats -> schema_cache.captures).
29
- Its requests are bounded and admitted like the others, on prefix plus suffix tokens, before any GPU preparation.
30
- * prompt layout (1.2.0): a model whose decider_config.json has "layout": "chat" (a chat-trained checkpoint) is read in the chat layout
31
- (decider.prompt.build_chat: the tokenizer's chat template around one user turn, thinking off, "Answer: (" in the assistant
32
- turn) on every route, including the shared-prefix fork and the schema cache. Every other model is read in the plain
33
- layout, with the same token ids as 1.1.x. A config naming an unknown layout stops start-up with a ValueError.
34
- * requests are bounded before they reach the GPU: HTTP 413 when a row, the request or the expanded question count exceeds the
35
- limits below, HTTP 503 when the outstanding work exceeds DECIDER_MAX_QUEUE_ROWS.
36
-
37
- Variables (default): DECIDER_DEVICE (auto: cuda, else mps, else cpu) DECIDER_COMPILE (0) DECIDER_FP8 (0) DECIDER_SHARED (1) DECIDER_SHARED_MIN_TOKENS (768)
38
- DECIDER_SHARED_FORK_GB (8) DECIDER_MERGE_OVERHEAD_TOKENS (512) DECIDER_BATCH_ADAPTIVE_WAIT_MS (2)
39
- DECIDER_MAX_BATCH (32) DECIDER_BATCH_WAIT_MS (0; DECIDER_MAX_WAIT_MS is an alias) DECIDER_MAX_STATE_TOKENS (32768)
40
- DECIDER_T_BUCKETS DECIDER_B_BUCKETS DECIDER_GRAPH_TOKEN_BUDGET (32768) DECIDER_WARMUP (1) DECIDER_TOKENIZE_THREADS (8)
41
- DECIDER_MAX_ROWS (1024) DECIDER_MAX_ROW_TOKENS (DECIDER_MAX_STATE_TOKENS + 4096) DECIDER_MAX_REQUEST_TOKENS (1048576)
42
- DECIDER_MAX_QUEUE_ROWS (4096) DECIDER_TEMPERATURE DECIDER_SCHEMA_CACHE (0) DECIDER_SCHEMA_MIN_SEEN (2) DECIDER_SCHEMAS
43
-
44
- Temperatures (1.4.0, decider.temperature): decider_config.json "temperature", and optionally "temperature_by_type"
45
- {"choice": T, "noul": T, "score": T} (a /decide "bool" field is "noul", a "scale" field is "score"; a missing type uses
46
- "temperature"). Every row carries the answer type of each of its slots, so a batch that mixes requests and types still
47
- applies each answer's own temperature. DECIDER_TEMPERATURE replaces "temperature" and switches the by-type map off.
48
- /health and the ready line report the temperature every answer type gets.
49
- """
50
- import asyncio, json, os, time
51
- from concurrent.futures import ThreadPoolExecutor
52
- from contextlib import asynccontextmanager
53
- from fastapi import FastAPI, HTTPException
54
- from pydantic import BaseModel
55
- from decider import systemone as S1
56
- from decider import temperature as TT
57
- from decider.batching import DEFAULT_MERGE_OVERHEAD_TOKENS, plan_batches
58
- from decider.prompt import build, MAX_OPTIONS, resolve_layout, chat_template
59
- from decider.prompt_fast import build_rows, unique_tokens
60
-
61
-
62
- def _env_int(name, default):
63
- return int(os.environ.get(name, default))
64
-
65
-
66
- MODEL = os.environ.get("DECIDER_MODEL", "runs/r3_v2/model")
67
- MAX_BATCH = _env_int("DECIDER_MAX_BATCH", 32)
68
- BATCH_WAIT_MS = float(os.environ.get("DECIDER_BATCH_WAIT_MS", os.environ.get("DECIDER_MAX_WAIT_MS", "0")))
69
- ADAPTIVE_WAIT_MS = float(os.environ.get("DECIDER_BATCH_ADAPTIVE_WAIT_MS", "2")) # window after a collection that held >1 request; 0 disables
70
- ADAPTIVE_IDLE_RESET_MS = 50.0 # an idler queue than this drops the adaptive window again
71
- MERGE_OVERHEAD_TOKENS = _env_int("DECIDER_MERGE_OVERHEAD_TOKENS", DEFAULT_MERGE_OVERHEAD_TOKENS)
72
- MAX_STATE_TOKENS = _env_int("DECIDER_MAX_STATE_TOKENS", 32768)
73
- SHARED = os.environ.get("DECIDER_SHARED", "1") == "1"
74
- SHARED_MIN_TOKENS = _env_int("DECIDER_SHARED_MIN_TOKENS", 768)
75
- DEVICE = os.environ.get("DECIDER_DEVICE", "auto") # auto | cuda[:i] | mps | cpu
76
- COMPILE = os.environ.get("DECIDER_COMPILE", "0") == "1"
77
- FP8 = os.environ.get("DECIDER_FP8", "0") == "1"
78
- WARMUP = os.environ.get("DECIDER_WARMUP", "1") == "1"
79
- TOKENIZE_THREADS = _env_int("DECIDER_TOKENIZE_THREADS", 8)
80
- GRAPH_TOKEN_BUDGET = _env_int("DECIDER_GRAPH_TOKEN_BUDGET", 32768)
81
- # request bounds, checked after tokenisation and before anything is queued
82
- MAX_ROWS = _env_int("DECIDER_MAX_ROWS", 1024) # scoring rows per request (questions, expanded score levels)
83
- MAX_ROW_TOKENS = _env_int("DECIDER_MAX_ROW_TOKENS", MAX_STATE_TOKENS + 4096) # tokens in one row: truncated state + question block
84
- MAX_REQUEST_TOKENS = _env_int("DECIDER_MAX_REQUEST_TOKENS", 1 << 20) # sum of row lengths of one request
85
- MAX_QUEUE_ROWS = _env_int("DECIDER_MAX_QUEUE_ROWS", 4096) # rows admitted and not yet scored, over all requests
86
- DECIDE_MAX_CTX_TOKENS = 1536 # /decide context cap, unchanged from 1.0.x
87
-
88
- MODEL_NAME = "decider"; TEMP = 1.0; TEMP_SCHEMA = 1.0; TEMP_BY_TYPE = {}; TEMP_SCHEMA_BY_TYPE = {}; RELEASE_DATE = "2026-09-17"; ISOLATED = False; NEUTRALIZE_NONE = True
89
- SCHEMA_FIRST = False; LAYOUT = "plain"; CHAT = None; se = None; squeue = None; schemas = {}; seen = {}
90
- eng = None; queue = None; gpu = None; cpu = None; batcher_task = None; schema_task = None
91
- outstanding = 0; REQ_SEQ = 0
92
- stats = dict(requests=0, batches=0, decisions=0, rows=0, shared_prefix_requests=0, errors=0, rejected_too_large=0,
93
- rejected_overloaded=0, batch_hist={}, bucket_hist={})
94
-
95
-
96
- def _ints(name):
97
- v = os.environ.get(name)
98
- return [int(x) for x in v.split(",")] if v else None
99
-
100
-
101
- def load_config(path):
102
- """decider_config.json from a model folder or a Hub repository (the way decider.infer.Decider resolves it)."""
103
- try:
104
- if os.path.isdir(path):
105
- return json.load(open(os.path.join(path, "decider_config.json")))
106
- from huggingface_hub import hf_hub_download
107
- return json.load(open(hf_hub_download(path, "decider_config.json")))
108
- except Exception:
109
- return {}
110
-
111
-
112
- # ---- request preparation (CPU) -------------------------------------------
113
- class _NoShuffle:
114
- def shuffle(self, x): pass
115
- def sample(self, xs, k): return xs[:k]
116
-
117
-
118
- def prepare(tok, state, questions, independent, isolated=False, max_state_tokens=32768, chat=None):
119
- """Render, plan the rows, tokenize the state once. -> (rqs, index, items, ctx_len). The rows are those
120
- `prompt.build` produces for the same request (tests/test_prompt_fast.py, tests/test_serve_prepare.py); chat: the
121
- ChatTemplate of a chat-layout model, None for the plain layout."""
122
- ctx = S1.render_state(state)
123
- rqs = {k: S1.render_question(v) for k, v in questions.items()}
124
- flat, index = S1.plan_rows(rqs, isolated and independent)
125
- pairs = [(r["question"], list(r["options"])) for r in flat]
126
- rows = [[p] for p in pairs] if independent else [pairs]
127
- items, ctx_len = build_rows(tok, ctx, rows, max_ctx_tokens=max_state_tokens, chat=chat)
128
- types = S1.row_types(rqs, index) # answer type per slot (decider.temperature)
129
- for it, ts in zip(items, [[t] for t in types] if independent else [types]):
130
- it["types"] = ts
131
- return rqs, index, items, ctx_len
132
-
133
-
134
- def _prepare_s1(state, questions, independent):
135
- return prepare(eng.tok, state, questions, independent, ISOLATED, MAX_STATE_TOKENS, chat=CHAT)
136
-
137
-
138
- def _prepare_decide(context, schema):
139
- from decider.infer import Decider, Example, Q, neutralize_options
140
- qs = Decider._schema_to_questions(schema)
141
- for q in qs:
142
- if NEUTRALIZE_NONE:
143
- q["options"], q["_back"] = neutralize_options(q["options"])
144
- ex = Example(context, [Q(q["question"], list(q["options"]), 0) for q in qs])
145
- it = build(ex, eng.tok, _NoShuffle(), max_options=MAX_OPTIONS, max_ctx_tokens=DECIDE_MAX_CTX_TOKENS, chat=CHAT)
146
- it["types"] = [q["_type"] for q in qs] # bool -> noul, scale -> score (decider.temperature)
147
- return qs, it
148
-
149
-
150
- def _format_decide(schema, qs, probs):
151
- o = {}
152
- for (qtext, spec), q, p in zip(schema.items(), qs, probs):
153
- p = p[:len(q["options"])].tolist(); t = spec.get("type", "choice"); j = max(range(len(p)), key=p.__getitem__)
154
- back = q.get("_back", {}); names = [back.get(x, x) for x in q["options"]]
155
- if t == "bool":
156
- o[qtext] = {"noul": round(p[1], 4), "type": "noul"}
157
- elif t == "choice":
158
- o[qtext] = {"choice": names[j], "confidence": round(p[j], 4), "type": "choice",
159
- "probabilities": {k: round(v, 4) for k, v in zip(names, p)}}
160
- else:
161
- keys = q["_keys"]; score = sum(float(k) * pi for k, pi in zip(keys, p))
162
- o[qtext] = {"score": round(score, 2), "confidence": round(p[j], 4), "type": "scale", "legend": q["_legend"],
163
- "probabilities": {str(keys[i]): round(pi, 4) for i, pi in enumerate(p)}}
164
- return o
165
-
166
-
167
- # ---- limits ---------------------------------------------------------------
168
- def check_size(row_lengths, max_rows=None, max_row_tokens=None, max_request_tokens=None):
169
- """Raise HTTPException(413) when a request exceeds the row count, per-row token or total token limit."""
170
- max_rows = MAX_ROWS if max_rows is None else max_rows
171
- max_row_tokens = MAX_ROW_TOKENS if max_row_tokens is None else max_row_tokens
172
- max_request_tokens = MAX_REQUEST_TOKENS if max_request_tokens is None else max_request_tokens
173
- n, total, longest = len(row_lengths), sum(row_lengths), max(row_lengths, default=0)
174
- if n > max_rows:
175
- msg = f"too many questions: the request expands to {n} scoring rows, the limit is {max_rows} (DECIDER_MAX_ROWS)"
176
- elif longest > max_row_tokens:
177
- msg = f"too many tokens: one row has {longest} tokens, the limit is {max_row_tokens} per row (DECIDER_MAX_ROW_TOKENS)"
178
- elif total > max_request_tokens:
179
- msg = f"too many tokens: the request has {total} tokens over {n} rows, the limit is {max_request_tokens} (DECIDER_MAX_REQUEST_TOKENS)"
180
- else:
181
- return
182
- stats["rejected_too_large"] += 1
183
- raise HTTPException(413, msg)
184
-
185
-
186
- def _admit(n):
187
- """Reserve n rows of outstanding work or raise HTTPException(503)."""
188
- global outstanding
189
- if outstanding + n > MAX_QUEUE_ROWS:
190
- stats["rejected_overloaded"] += 1
191
- raise HTTPException(503, f"server busy: {outstanding} rows queued, the limit is {MAX_QUEUE_ROWS} (DECIDER_MAX_QUEUE_ROWS); retry later")
192
- outstanding += n
193
-
194
-
195
- def _release(n):
196
- global outstanding
197
- outstanding -= n
198
-
199
-
200
- # ---- GPU work ----------------------------------------------------------------
201
- def _score_items(items):
202
- return eng.score_items(items, temperature=TT.for_items(TEMP, TEMP_BY_TYPE, items)) # the scalar TEMP without a by-type map
203
-
204
-
205
- def _score_shared(items):
206
- return eng.score_shared(items, temperature=TT.for_items(TEMP, TEMP_BY_TYPE, items))
207
-
208
-
209
- async def _collect(q, wait_ms=None, adaptive_ms=0.0, idle_reset_ms=None):
210
- """Take what is already queued and go.
211
-
212
- `wait_ms` (DECIDER_BATCH_WAIT_MS) is the unconditional collection window. `adaptive_ms` is the extra window the
213
- batcher asks for after a collection that held more than one live request; it is dropped again when the first row of
214
- this collection took longer than `idle_reset_ms` to arrive, so an isolated request after a quiet period never waits.
215
- """
216
- wait = BATCH_WAIT_MS if wait_ms is None else wait_ms
217
- reset = ADAPTIVE_IDLE_RESET_MS if idle_reset_ms is None else idle_reset_ms
218
- t0 = time.monotonic()
219
- batch = [await q.get()]
220
- if adaptive_ms > 0 and (time.monotonic() - t0) * 1000 <= reset:
221
- wait = max(wait, adaptive_ms)
222
- deadline = time.monotonic() + wait / 1000
223
- while len(batch) < MAX_BATCH:
224
- try:
225
- batch.append(q.get_nowait())
226
- except asyncio.QueueEmpty:
227
- timeout = deadline - time.monotonic()
228
- if timeout <= 0: break
229
- try: batch.append(await asyncio.wait_for(q.get(), timeout))
230
- except asyncio.TimeoutError: break
231
- return batch
232
-
233
-
234
- def _bucketed(n):
235
- """False for a row longer than the engine's last captured length bucket. Those run eager at a request-specific shape,
236
- so they are grouped by exact padded length as in 1.1.0 instead of being padded into another row's bucket."""
237
- t_bucket = getattr(eng, "t_bucket", None)
238
- return True if t_bucket is None else t_bucket(n) is not None
239
-
240
-
241
- def adaptive_ms(batch):
242
- """The extra collection window the next collection may use: DECIDER_BATCH_ADAPTIVE_WAIT_MS when this collection held
243
- live rows from more than one request, 0 otherwise. Rows whose future is already done (a cancelled or failed request)
244
- are not counted: they are not evidence that requests are overlapping."""
245
- if ADAPTIVE_WAIT_MS <= 0:
246
- return 0.0
247
- return ADAPTIVE_WAIT_MS if len({rid for fut, _, rid in batch if not fut.done()}) > 1 else 0.0
248
-
249
-
250
- async def batcher():
251
- """One forward per planned group. Rows queued at the same moment are partitioned by decider.batching.plan_batches:
252
- a shorter row is padded into a longer row's bucket when that costs less than running a second forward."""
253
- loop = asyncio.get_running_loop()
254
- extra = 0.0 # the adaptive window the previous collection earned
255
- while True:
256
- batch = await _collect(queue, BATCH_WAIT_MS, extra)
257
- extra = adaptive_ms(batch)
258
- groups = plan_batches([len(it["ids"]) for _, it, _ in batch], eng.pad_len, eng.max_rows, MAX_BATCH,
259
- MERGE_OVERHEAD_TOKENS, _bucketed)
260
- for T, idx in groups:
261
- part = [batch[i] for i in idx]
262
- stats["bucket_hist"][T] = stats["bucket_hist"].get(T, 0) + len(part)
263
- try:
264
- probs = await loop.run_in_executor(gpu, _score_items, [it for _, it, _ in part])
265
- for (fut, _, _), p in zip(part, probs):
266
- if not fut.done(): fut.set_result(p)
267
- except Exception as e:
268
- for fut, _, _ in part:
269
- if not fut.done(): fut.set_exception(e)
270
- stats["batches"] += 1
271
- stats["batch_hist"][len(part)] = stats["batch_hist"].get(len(part), 0) + 1
272
-
273
-
274
- # ---- schema cache (questions-first layout; opt-in) --------------------------------
275
- def _schema_key(questions, independent):
276
- return (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent)
277
-
278
-
279
- class _SQ:
280
- def __init__(self, text, options): self.text, self.options = text, options
281
-
282
-
283
- def _plan_schema(questions, independent, state):
284
- """CPU part of a schema-cache request: prefix token lengths (from the cached handle when the schema is known, otherwise
285
- tokenised here, without touching the GPU) and the suffix row (context plus answer slots). -> (tps, (suffix ids, slots)).
286
- Raises ValueError for an invalid question."""
287
- from decider.prompt import schema_prefix_ids, schema_suffix_ids
288
- rqs = {k: S1.render_question(v) for k, v in questions.items()}; rows, _ = S1.plan_rows(rqs, ISOLATED and independent)
289
- cached = schemas.get(_schema_key(questions, independent))
290
- if cached is not None:
291
- tps = list(cached[1].tps)
292
- else:
293
- qs = [_SQ(x["question"], list(x["options"])) for x in rows]
294
- tps = [len(schema_prefix_ids(eng.tok, g, chat=CHAT)) for g in ([[q] for q in qs] if independent else [qs])]
295
- row = schema_suffix_ids(eng.tok, S1.render_state(state), 1 if independent else len(rows), MAX_STATE_TOKENS, chat=CHAT)
296
- return tps, row
297
-
298
-
299
- def _schema_handle(questions, independent, compile=False):
300
- """Compile (or look up) the question schema: its prefix is run once, requests then only run the state. GPU thread."""
301
- key = _schema_key(questions, independent)
302
- if key not in schemas:
303
- rqs = {k: S1.render_question(v) for k, v in questions.items()}; rows, index = S1.plan_rows(rqs, ISOLATED and independent)
304
- if len(schemas) >= 128:
305
- old = next(iter(schemas)); hid = schemas.pop(old)[1].id
306
- for k in [k for k in se.graphs if k[0] == hid]: del se.graphs[k]
307
- h = se.prepare(rows, independent=independent, compile=compile)
308
- h.types = S1.row_types(rqs, index) # answer type per schema row (decider.temperature)
309
- schemas[key] = (rqs, h, index)
310
- return schemas[key]
311
-
312
-
313
- def _worth_caching(questions, independent):
314
- """A schema gets a cached prefix and CUDA graphs from its second request on."""
315
- key = _schema_key(questions, independent)
316
- if key in schemas: return True
317
- if len(seen) > 50000: seen.clear()
318
- seen[key] = seen.get(key, 0) + 1
319
- return seen[key] >= _env_int("DECIDER_SCHEMA_MIN_SEEN", 2)
320
-
321
-
322
- def _score_schema(h, rows):
323
- return se.score_rows(h, rows, temperature=TT.for_types(TEMP_SCHEMA, TEMP_SCHEMA_BY_TYPE, h.types))
324
-
325
-
326
- async def schema_batcher():
327
- loop = asyncio.get_running_loop()
328
- while True:
329
- batch = await _collect(squeue)
330
- groups = {}
331
- for fut, h, row in batch: groups.setdefault((h.id, se.bucket(len(row[0]))), (h, []))[1].append((fut, row))
332
- for h, items in groups.values():
333
- step = max(1, MAX_BATCH // h.P)
334
- for i in range(0, len(items), step):
335
- chunk = items[i:i + step]
336
- try:
337
- probs = await loop.run_in_executor(gpu, _score_schema, h, [c for _, c in chunk])
338
- for (fut, _), p in zip(chunk, probs):
339
- if not fut.done(): fut.set_result(p)
340
- except Exception as e:
341
- for fut, _ in chunk:
342
- if not fut.done(): fut.set_exception(e)
343
- stats["schema_batches"] = stats.get("schema_batches", 0) + 1
344
-
345
-
346
- # ---- start-up / shutdown -------------------------------------------------------
347
- def apply_config(cfg):
348
- global MODEL_NAME, TEMP, TEMP_SCHEMA, TEMP_BY_TYPE, TEMP_SCHEMA_BY_TYPE, RELEASE_DATE, ISOLATED, NEUTRALIZE_NONE, SCHEMA_FIRST, LAYOUT
349
- LAYOUT = resolve_layout(cfg) # ValueError for an unknown layout, before the engine is built
350
- NEUTRALIZE_NONE = bool(cfg.get("neutralize_none", True))
351
- MODEL_NAME = "decider-" + str(cfg.get("version", "dev"))
352
- # ValueError for a temperature that is not finite and > 0 or an unknown by-type key, before the engine is built.
353
- # DECIDER_TEMPERATURE replaces "temperature" and switches "temperature_by_type" off (decider.temperature).
354
- (TEMP, TEMP_BY_TYPE), (TEMP_SCHEMA, TEMP_SCHEMA_BY_TYPE) = TT.from_config(cfg, os.environ.get("DECIDER_TEMPERATURE"))
355
- RELEASE_DATE = str(cfg.get("release_date", RELEASE_DATE))
356
- ISOLATED = bool(cfg.get("isolated_levels", False))
357
- trained = bool(cfg.get("schema_first", False) or cfg.get("schema_first_trained", False))
358
- SCHEMA_FIRST = trained and (bool(cfg.get("schema_first", False)) or os.environ.get("DECIDER_SCHEMA_CACHE", "0") == "1")
359
-
360
-
361
- def start_workers(loop=None):
362
- """Queues, executors and batcher tasks. Needs `eng` set; a test can set a stand-in engine and call this directly."""
363
- global queue, gpu, cpu, batcher_task, squeue, schema_task
364
- loop = loop or asyncio.get_running_loop()
365
- if gpu is None: gpu = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu")
366
- if cpu is None: cpu = ThreadPoolExecutor(max_workers=TOKENIZE_THREADS, thread_name_prefix="tok")
367
- queue = asyncio.Queue(); batcher_task = loop.create_task(batcher())
368
- if SCHEMA_FIRST:
369
- squeue = asyncio.Queue(); schema_task = loop.create_task(schema_batcher())
370
-
371
-
372
- def resolve_device(requested=None):
373
- """The device and dtype the server runs on. `auto` picks as decider.infer.Decider does: CUDA, else MPS, else CPU; float16
374
- on MPS, bfloat16 elsewhere. An explicit device that is not available, or FP8 / torch.compile off CUDA, is a start-up error
375
- that says so, instead of the torch assertion a CUDA call raises on a build without CUDA."""
376
- import torch
377
- req = (DEVICE if requested is None else requested).strip().lower()
378
- if req in ("", "auto"):
379
- dev = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
380
- else:
381
- dev = req
382
- kind = dev.split(":")[0]
383
- if kind == "cuda" and not torch.cuda.is_available():
384
- raise RuntimeError(f"DECIDER_DEVICE={req}, but torch.cuda.is_available() is False on this machine. "
385
- "Set DECIDER_DEVICE=mps or cpu (or leave it at auto).")
386
- if kind == "mps" and not torch.backends.mps.is_available():
387
- raise RuntimeError(f"DECIDER_DEVICE={req}, but torch.backends.mps.is_available() is False on this machine.")
388
- if kind not in ("cuda", "mps", "cpu"):
389
- raise RuntimeError(f"DECIDER_DEVICE={req}: expected auto, cuda, cuda:<index>, mps or cpu.")
390
- if not dev.startswith("cuda") and (FP8 or COMPILE):
391
- raise RuntimeError(f"DECIDER_FP8 and DECIDER_COMPILE need CUDA; the server is starting on {dev}. Unset them.")
392
- return dev, (torch.float16 if dev.startswith("mps") else torch.bfloat16)
393
-
394
-
395
- async def _start():
396
- global eng, se, gpu, CHAT
397
- from decider.engine_v2 import EngineV2
398
- apply_config(load_config(MODEL))
399
- gpu = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu")
400
- loop = asyncio.get_running_loop()
401
- dev, dtype = resolve_device()
402
- eng = await loop.run_in_executor(gpu, lambda: EngineV2(
403
- MODEL, device=dev, dtype=dtype, compile=COMPILE, fp8=FP8, max_ctx_tokens=MAX_STATE_TOKENS, t_buckets=_ints("DECIDER_T_BUCKETS"),
404
- b_buckets=_ints("DECIDER_B_BUCKETS"), token_budget=GRAPH_TOKEN_BUDGET))
405
- CHAT = chat_template(eng.tok) if LAYOUT == "chat" else None
406
- print("[serve] engine", dict({k: v for k, v in eng.cfg.items() if k not in ("t_buckets", "b_buckets")}, device=dev, layout=LAYOUT), flush=True)
407
- if SCHEMA_FIRST:
408
- from decider.schema_engine import SchemaEngine
409
- se = SchemaEngine(eng, chat=CHAT); print("[serve] schema cache on", flush=True)
410
- pre = os.environ.get("DECIDER_SCHEMAS") # JSON: [{"questions": {...}, "independent": true, "batch_sizes": [1, 8, 32], "state_tokens": [64, 256]}]
411
- for spec in (json.load(open(pre)) if pre else []):
412
- _, h, _ = await loop.run_in_executor(gpu, _schema_handle, spec["questions"], spec.get("independent", True), COMPILE)
413
- t = await loop.run_in_executor(gpu, se.warmup, h, spec.get("batch_sizes", (1, 8, 32)), spec.get("state_tokens", (64, 128, 256)))
414
- print(f"[serve] preloaded schema with {h.nq} rows, prefix {sum(h.tps)} tokens, graphs ready in {t:.0f}s", flush=True)
415
- if WARMUP and eng.use_graphs: # off CUDA there are no graphs to capture; every request runs eager
416
- t = await loop.run_in_executor(gpu, lambda: eng.warmup(log=lambda s: print(s, flush=True)))
417
- print(f"[serve] captured {len(eng.graphs)} graphs in {t:.0f}s", flush=True)
418
- eng.seal()
419
- print("[serve] ready", json.dumps(dict(model=MODEL_NAME, layout=LAYOUT, temperature=TEMP, temperature_by_type=TT.effective(TEMP, TEMP_BY_TYPE),
420
- **({"temperature_schema_first_by_type": TT.effective(TEMP_SCHEMA, TEMP_SCHEMA_BY_TYPE)} if SCHEMA_FIRST else {}),
421
- isolated_levels=ISOLATED, schema_first=SCHEMA_FIRST,
422
- shared=SHARED, graphs=len(eng.graphs), limits=dict(
423
- max_rows=MAX_ROWS, max_row_tokens=MAX_ROW_TOKENS, max_request_tokens=MAX_REQUEST_TOKENS,
424
- max_queue_rows=MAX_QUEUE_ROWS))), flush=True)
425
- start_workers(loop)
426
-
427
-
428
- def _stop():
429
- global gpu, cpu, batcher_task, schema_task
430
- for t in (batcher_task, schema_task):
431
- if t is not None: t.cancel()
432
- for ex in (gpu, cpu):
433
- if ex is not None: ex.shutdown(wait=False, cancel_futures=True)
434
- gpu = cpu = batcher_task = schema_task = None
435
-
436
-
437
- @asynccontextmanager
438
- async def lifespan(app):
439
- await _start()
440
- yield
441
- _stop()
442
-
443
-
444
- app = FastAPI(title="decider", lifespan=lifespan)
445
-
446
-
447
- # ---- routes -------------------------------------------------------------------
448
- class Req(BaseModel):
449
- context: str
450
- schema_: object = None # any JSON value: Decider._check_schema gives the 422 (pydantic's echoes NaN/Infinity and cannot be serialised)
451
- model_config = {"populate_by_name": True}
452
- def __init__(self, **kw):
453
- if "schema" in kw: kw["schema_"] = kw.pop("schema")
454
- super().__init__(**kw)
455
-
456
-
457
- class S1Req(BaseModel):
458
- state: object
459
- questions: dict
460
- model: str | None = None
461
- independent: bool = True
462
- layout: str | None = None # "state_first" forces the uncached layout on a schema-first model
463
-
464
-
465
- def _alive():
466
- return batcher_task is not None and not batcher_task.done() and (schema_task is None or not schema_task.done())
467
-
468
-
469
- async def _queued(items):
470
- """Queue one request's rows. Every row carries the request's id, which is what the batcher's adaptive wait reads."""
471
- global REQ_SEQ
472
- loop = asyncio.get_running_loop(); futs = []
473
- REQ_SEQ += 1; rid = REQ_SEQ
474
- for it in items:
475
- f = loop.create_future(); futs.append(f); queue.put_nowait((f, it, rid))
476
- return await asyncio.gather(*futs)
477
-
478
-
479
- @app.post("/decide")
480
- async def decide(r: Req):
481
- loop = asyncio.get_running_loop()
482
- try:
483
- qs, it = await loop.run_in_executor(cpu, _prepare_decide, r.context, r.schema_)
484
- except (ValueError, KeyError) as e:
485
- stats["errors"] += 1
486
- raise HTTPException(422, str(e))
487
- if not qs: # empty schema: nothing to score (the 1.1.2 answer, without a forward)
488
- stats["requests"] += 1; return {}
489
- check_size([len(it["ids"])])
490
- _admit(1)
491
- try:
492
- probs = (await _queued([it]))[0]
493
- except Exception:
494
- stats["errors"] += 1; raise
495
- finally:
496
- _release(1)
497
- stats["requests"] += 1; stats["decisions"] += len(qs); stats["rows"] += 1
498
- return _format_decide(r.schema_, qs, probs)
499
-
500
-
501
- @app.post("/v1/systemone")
502
- async def systemone(r: S1Req):
503
- loop = asyncio.get_running_loop()
504
- if SCHEMA_FIRST and r.questions and r.layout != "state_first" and _worth_caching(r.questions, r.independent):
505
- try: # CPU: rows, prefix lengths, suffix ids -> the complete cost before any GPU work
506
- tps, row = await loop.run_in_executor(cpu, _plan_schema, r.questions, r.independent, r.state)
507
- except ValueError as e:
508
- stats["errors"] += 1
509
- raise HTTPException(422, str(e))
510
- check_size([tp + len(row[0]) for tp in tps])
511
- _admit(len(tps))
512
- try:
513
- rqs, h, index = await loop.run_in_executor(gpu, _schema_handle, r.questions, r.independent)
514
- fut = loop.create_future(); await squeue.put((fut, h, row)); p = await fut
515
- except Exception:
516
- stats["errors"] += 1; raise
517
- finally:
518
- _release(len(tps))
519
- stats["requests"] += 1; stats["decisions"] += len(rqs); stats["schema_requests"] = stats.get("schema_requests", 0) + 1
520
- return {"model": MODEL_NAME, "answers": S1.assemble(rqs, index, [pk.tolist() for pk in p]),
521
- "usage": {"input_tokens": len(row[0]) * h.P, "cached_tokens": sum(h.tps), "output_tokens": 0}}
522
- try:
523
- rqs, index, items, ctx_len = await loop.run_in_executor(cpu, _prepare_s1, r.state, r.questions, r.independent)
524
- except ValueError as e:
525
- stats["errors"] += 1
526
- raise HTTPException(422, str(e))
527
- check_size([len(it["ids"]) for it in items])
528
- _admit(len(items))
529
- try:
530
- if SHARED and len(items) > 1 and min(len(it["ids"]) for it in items) >= SHARED_MIN_TOKENS:
531
- res = await loop.run_in_executor(gpu, _score_shared, items) # long state: run it once, fork the cache per question
532
- stats["shared_prefix_requests"] += 1
533
- else:
534
- res = await _queued(items)
535
- except Exception:
536
- stats["errors"] += 1; raise
537
- finally:
538
- _release(len(items))
539
- probs = [p for ps in res for p in ps] # one prob row per question, request order
540
- stats["requests"] += 1; stats["decisions"] += len(rqs); stats["rows"] += len(items)
541
- return {"model": MODEL_NAME, "answers": S1.assemble(rqs, index, [p.tolist() for p in probs]),
542
- "usage": {"input_tokens": unique_tokens(items, ctx_len), "output_tokens": 0}}
543
-
544
-
545
- @app.get("/v1/models")
546
- async def models():
547
- return {"models": [{"name": MODEL_NAME, "description": "decider: one-pass typed decisions with calibrated probabilities", "release_date": RELEASE_DATE}]}
548
-
549
-
550
- @app.get("/health")
551
- async def health():
552
- return {"ok": eng is not None and bool(getattr(eng, "sealed", True)) and _alive(), "model": MODEL,
553
- "device": str(getattr(eng, "dev", "")) if eng is not None else None, "layout": LAYOUT,
554
- "temperature": TEMP, "temperature_by_type": TT.effective(TEMP, TEMP_BY_TYPE),
555
- **({"temperature_schema_first_by_type": TT.effective(TEMP_SCHEMA, TEMP_SCHEMA_BY_TYPE)} if SCHEMA_FIRST else {})}
556
-
557
-
558
- @app.get("/stats")
559
- async def get_stats():
560
- return dict(stats, outstanding_rows=outstanding, engine=eng.stats if eng else None, graphs=len(eng.graphs) if eng else 0,
561
- sealed=bool(eng and getattr(eng, "sealed", False)), schema_cache=dict(se.stats, schemas=len(schemas)) if se else None,
562
- limits=dict(max_rows=MAX_ROWS, max_row_tokens=MAX_ROW_TOKENS, max_request_tokens=MAX_REQUEST_TOKENS, max_queue_rows=MAX_QUEUE_ROWS))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/shared_prefix.py DELETED
@@ -1,200 +0,0 @@
1
- """Shared-prefix scoring with a bounded memory fork, used by both engines.
2
-
3
- Rows that start with the same tokens (one state, one question per row) run the shared prefix once and then only the
4
- question suffixes, against a copy of the prefix cache. Until 1.1.0 the copy was made with
5
- `cache.reorder_cache(zeros(n))`, which expands the prefix to all n rows at once: a 31k-token state with 32 questions
6
- costs n times the prefix cache, which is 133 GB on a 31B model and is wasteful on the 2B. Here the prefix cache is
7
- forked in chunks of m rows, m chosen so that one fork fits a byte budget, and the rows are scored chunk by chunk.
8
-
9
- The budget is `DECIDER_SHARED_FORK_GB` (8 GB), capped at half of the memory currently free on the device (device-free
10
- plus the caching allocator's reserved-but-unused blocks); `m = clamp(budget // prefix_bytes, 1, n)`. Chunking changes
11
- which rows share a forward and how far each chunk's suffixes are padded, so answers can move by the usual bf16
12
- reduction-order amount; they do not depend on it mathematically (right padding, causal layers).
13
-
14
- Cache layout. transformers 5.17 keeps one layer object per layer in `cache.layers`. An attention layer carries
15
- `keys` and `values` tensors `[batch, heads, seq, head_dim]`; a sparse-attention layer adds `indexer_keys`
16
- `[batch, seq, dim]`; a linear-attention layer carries `conv_states` and `recurrent_states` dicts of tensors whose first
17
- dimension is the batch. Qwen3.5 gives a `DynamicCache` whose 24 layers are a mix of the two objects. `cache_state_tensors`
18
- enumerates whatever of `STATE_NAMES` is present rather than assuming a layout, and `fork_cache` builds a new cache
19
- object from them without touching the original (the linear-attention layers update their states with `copy_()`, so a
20
- fork that shared storage with the prefix would corrupt it for the next chunk).
21
-
22
- A layout we do not know is not chunked. If a layer holds any other tensor attribute, or a dict of tensors, outside
23
- `STATE_NAMES`, we cannot tell whether it carries a batch dimension, so `score_shared` takes one fork of all n rows --
24
- the 1.1.0 behaviour, correct but unbounded -- and counts it in `engine.stats["shared_unchunked_layout"]`.
25
-
26
- The prefix and suffix forwards are eager, at request-specific shapes, and are correct only with the cuDNN SDPA backend
27
- off (`decider.engine.set_attention_backend_policy`, applied in both engines' `__init__`).
28
- """
29
- import copy
30
- import os
31
-
32
- import torch
33
- import torch.nn.functional as F
34
-
35
- from decider.engine import fill_ids, read_slots
36
- from decider.temperature import item_slice, slot_temperatures
37
-
38
- DEFAULT_FORK_GB = 8.0
39
- STATE_NAMES = ("keys", "values", "indexer_keys", "conv_states", "recurrent_states")
40
-
41
-
42
- def common_prefix_len(ids):
43
- """Length of the longest common prefix of the rows, capped one token below the shortest row."""
44
- lcp = 0
45
- short = min(len(x) for x in ids) - 1
46
- while lcp < short and all(x[lcp] == ids[0][lcp] for x in ids):
47
- lcp += 1
48
- return lcp
49
-
50
-
51
- def _get(container, key):
52
- return container[key] if isinstance(container, dict) else getattr(container, key)
53
-
54
-
55
- def _set(container, key, value):
56
- if isinstance(container, dict):
57
- container[key] = value
58
- else:
59
- setattr(container, key, value)
60
-
61
-
62
- def cache_state_tensors(cache):
63
- """Every tensor in the cache whose first dimension is the batch, as (container, key) pairs.
64
-
65
- Covers `STATE_NAMES`: the attention layers' `keys`/`values`, a sparse-attention layer's `indexer_keys` and the
66
- linear-attention layers' `conv_states`/`recurrent_states`, whether those are dicts of tensors (transformers 5.17) or
67
- single tensors, and ignores anything not present."""
68
- out = []
69
- for layer in getattr(cache, "layers", None) or []:
70
- for attr in STATE_NAMES:
71
- v = getattr(layer, attr, None)
72
- if isinstance(v, torch.Tensor):
73
- if v.numel():
74
- out.append((layer, attr))
75
- elif isinstance(v, dict):
76
- for k, t in v.items():
77
- if isinstance(t, torch.Tensor) and t.numel():
78
- out.append((v, k))
79
- return out
80
-
81
-
82
- def cache_row_bytes(cache):
83
- """Bytes one row of this cache holds, summed over the layers and over `STATE_NAMES`."""
84
- return sum(_get(c, k).nbytes // max(_get(c, k).shape[0], 1) for c, k in cache_state_tensors(cache))
85
-
86
-
87
- def unknown_state_names(cache):
88
- """Attribute names the cache's layers hold that carry tensors and are not in `STATE_NAMES`.
89
-
90
- A tensor outside the enumerated set may or may not have a batch dimension, and `fork_cache` would leave it at one
91
- row. When this is not empty the caller must not chunk."""
92
- out = set()
93
- for layer in getattr(cache, "layers", None) or []:
94
- for name, v in vars(layer).items():
95
- if name in STATE_NAMES:
96
- continue
97
- if isinstance(v, torch.Tensor) or (isinstance(v, dict) and any(isinstance(t, torch.Tensor) for t in v.values())):
98
- out.add(name)
99
- return out
100
-
101
-
102
- def fork_budget_bytes(device=None, gb=None):
103
- """DECIDER_SHARED_FORK_GB, capped at half of the memory free on the device right now.
104
-
105
- The cap applies only when the CUDA memory queries succeed: on CPU, on MPS and when the driver does not know the
106
- device string, the configured budget is kept as it is."""
107
- gb = float(os.environ.get("DECIDER_SHARED_FORK_GB", DEFAULT_FORK_GB)) if gb is None else float(gb)
108
- budget = int(gb * (1 << 30))
109
- try:
110
- free, _ = torch.cuda.mem_get_info(device)
111
- free += torch.cuda.memory_reserved(device) - torch.cuda.memory_allocated(device)
112
- budget = min(budget, free // 2)
113
- except Exception: # no CUDA device, or a device string the driver does not know
114
- pass
115
- return max(int(budget), 1)
116
-
117
-
118
- def chunk_rows(prefix_bytes, n, budget_bytes=None, device=None):
119
- """Rows per fork: as many copies of the prefix cache as the budget holds, at least 1 and at most n. One row is the
120
- minimum even when a single copy is over the budget: the budget bounds the fork, it cannot make it free."""
121
- if budget_bytes is None:
122
- budget_bytes = fork_budget_bytes(device)
123
- if prefix_bytes <= 0:
124
- return max(1, int(n))
125
- return max(1, min(int(n), int(budget_bytes // prefix_bytes)))
126
-
127
-
128
- def fork_cache(cache, m, row=0):
129
- """A new cache holding `m` copies of `cache`'s row `row`. The original is not read from again and not modified."""
130
- fork = copy.copy(cache)
131
- layers = getattr(cache, "layers", None)
132
- if layers is not None:
133
- new = []
134
- for layer in layers:
135
- nl = copy.copy(layer)
136
- for k, v in list(vars(nl).items()): # the per-state dicts are mutated by the forward: give the fork its own
137
- if isinstance(v, dict):
138
- setattr(nl, k, dict(v))
139
- new.append(nl)
140
- fork.layers = new
141
- idx = {}
142
- for container, key in cache_state_tensors(fork):
143
- t = _get(container, key)
144
- i = idx.get(t.device)
145
- if i is None:
146
- i = idx[t.device] = torch.full((m,), row, dtype=torch.long, device=t.device)
147
- _set(container, key, t.index_select(0, i)) # a fresh contiguous tensor: the fork never shares storage
148
- return fork
149
-
150
-
151
- def _count(engine, key):
152
- stats = getattr(engine, "stats", None)
153
- if isinstance(stats, dict):
154
- stats[key] = stats.get(key, 0) + 1
155
-
156
-
157
- @torch.no_grad()
158
- def score_shared(engine, items, temperature=1.0, min_prefix=192, budget_bytes=None, rows_per_fork=None):
159
- """Score `items` through the shared prefix. -> one probability tensor per item, in item order, or None when the
160
- request does not qualify (fewer than two rows, or a common prefix below `min_prefix`) and the caller should use
161
- `score_items`.
162
-
163
- `temperature`: a number, or one entry per item (decider.temperature.slot_temperatures).
164
- `rows_per_fork` forces the chunk size; it exists for the tests that compare chunked against unchunked answers."""
165
- ids = [it["ids"] for it in items]
166
- n = len(ids)
167
- if n < 2:
168
- return None
169
- lcp = common_prefix_len(ids)
170
- if lcp < min_prefix:
171
- return None
172
- slot_temperatures(temperature, items) # a length mismatch fails before any forward
173
- core, W, dev, pad = engine.core, engine.W, engine.dev, engine.tok.pad_token_id
174
- pre = torch.tensor(ids[0][:lcp], device=dev)[None]
175
- cache = core(input_ids=pre, use_cache=True).past_key_values
176
- unknown = unknown_state_names(cache)
177
- if unknown: # a state we cannot fork row by row: one fork of everything, as in 1.1.0
178
- _count(engine, "shared_unchunked_layout")
179
- m = n
180
- elif rows_per_fork:
181
- m = int(rows_per_fork)
182
- else:
183
- m = chunk_rows(cache_row_bytes(cache), n, budget_bytes, dev)
184
- m = max(1, min(m, n))
185
- out = []
186
- for i in range(0, n, m):
187
- part = items[i:i + m]
188
- b = len(part)
189
- fork = fork_cache(cache, b)
190
- Ts = max(len(it["ids"]) for it in part) - lcp
191
- suf = fill_ids([it["ids"][lcp:] for it in part], b, Ts, pad)
192
- h = core(input_ids=suf.to(dev), past_key_values=fork, use_cache=True).last_hidden_state
193
- rows = [j for j, it in enumerate(part) for _ in it["slots"]]
194
- sl = [s - lcp for it in part for s in it["slots"]]
195
- idx = torch.tensor([rows, sl], device=dev)
196
- out += read_slots(F.linear(h[idx[0], idx[1]], W).float()[:, None, :], list(range(len(rows))), [0] * len(rows),
197
- [k for it in part for k in it["nopts"]],
198
- slot_temperatures(item_slice(temperature, i, i + b), part), [len(it["slots"]) for it in part])
199
- del fork, h, suf # drop this chunk's fork before the next one is built
200
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/systemone.py DELETED
@@ -1,199 +0,0 @@
1
- """Jev-shaped requests on top of the decider prompt format (same wire format as TypeSafe's POST /v1/systemone).
2
-
3
- state str | dict | list JSON state is serialised compactly; questions may name a part by path (`ticket.messages[0].text`)
4
- questions {id: {"type": "choice", "instructions": ..., "criteria": {name: description | {...} | [...] | None}} up to 255 options
5
- {"type": "score", "instructions": ..., "criteria": [level 0 description, level 1 description, ...]} 2..10 levels
6
- {"type": "noul", "instructions": ... (optional), "criteria": {"true": ..., "false": ...} (optional)}}
7
- a noul question needs instructions or at least one true/false description.
8
- ids are never shown to the model. `instructions` and every description may be a string or any JSON value.
9
- """
10
- import json, math
11
-
12
- MAX_CHOICE, MAX_LEVELS = 255, 10
13
-
14
-
15
- def _txt(v):
16
- return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
17
-
18
-
19
- ANNOTATE_MIN = 8
20
-
21
-
22
- def annotate_indices(x, min_len=ANNOTATE_MIN):
23
- """Write each element's position into long arrays ({"_index": i, ...}). A path such as `records[47].text` otherwise makes
24
- the model count 47 elements; with the index written down it is a lookup (json_k64 probe: 0.49 -> 0.57 accuracy)."""
25
- if isinstance(x, list):
26
- if len(x) >= min_len:
27
- return [({"_index": i, **annotate_indices(v, min_len)} if isinstance(v, dict) else {"_index": i, "value": annotate_indices(v, min_len)}) for i, v in enumerate(x)]
28
- return [annotate_indices(v, min_len) for v in x]
29
- if isinstance(x, dict):
30
- return {k: annotate_indices(v, min_len) for k, v in x.items()}
31
- return x
32
-
33
-
34
- def render_state(state, index_arrays=True):
35
- if isinstance(state, str):
36
- return state
37
- return json.dumps(annotate_indices(state) if index_arrays else state, ensure_ascii=False)
38
-
39
-
40
- def render_question(spec):
41
- """-> dict(question=str, options=[str], type=..., names=[...]) (names: what the answer reports for each option)"""
42
- t = spec.get("type", "choice"); crit = spec.get("criteria", spec.get("options"))
43
- raw = spec.get("instructions", spec.get("question", ""))
44
- if t in ("noul", "bool") and raw in (None, ""): # the criteria carry the question (NOUL_WITHOUT_INSTRUCTIONS)
45
- ins = NOUL_WITHOUT_INSTRUCTIONS
46
- described = isinstance(crit, dict) and any(d not in (None, "") for d in (crit.get("true", crit.get(True)), crit.get("false", crit.get(False))))
47
- if not described and (crit is None or isinstance(crit, dict)): # a non-map is rejected below with the criteria message
48
- raise ValueError("noul question without instructions: criteria must describe true or false")
49
- else:
50
- ins = _txt(raw)
51
- if not ins:
52
- raise ValueError("question without instructions")
53
- if t == "choice":
54
- if isinstance(crit, (list, tuple)):
55
- crit = {str(c): None for c in crit}
56
- if not isinstance(crit, dict) or not 2 <= len(crit) <= MAX_CHOICE:
57
- raise ValueError(f"choice criteria: a map of 2..{MAX_CHOICE} options")
58
- names = list(crit); opts = [n if crit[n] in (None, "") else f"{n}: {_txt(crit[n])}" for n in names]
59
- elif t == "score":
60
- if isinstance(crit, dict): # legend form {"0": "...", "1": "..."}
61
- crit = [crit[k] for k in sorted(crit, key=float)]
62
- if not isinstance(crit, (list, tuple)) or not 2 <= len(crit) <= MAX_LEVELS:
63
- raise ValueError(f"score criteria: an ordered list of 2..{MAX_LEVELS} level descriptions")
64
- names = list(range(len(crit))); opts = [f"{i}: {_txt(c)}" for i, c in enumerate(crit)]
65
- elif t in ("noul", "bool"):
66
- if crit is not None and not isinstance(crit, dict):
67
- raise ValueError("noul criteria: a map of optional true/false descriptions")
68
- names = [False, True]; c = crit if crit is not None else {}
69
- f, tr = c.get("false", c.get(False)), c.get("true", c.get(True))
70
- opts = ["no" if f in (None, "") else f"no: {_txt(f)}", "yes" if tr in (None, "") else f"yes: {_txt(tr)}"]
71
- else:
72
- raise ValueError(f"unknown question type {t!r}")
73
- return dict(question=ins, options=opts, type="noul" if t == "bool" else t, names=names, legend=[_txt(c) for c in crit] if t == "score" else None,
74
- isolated=bool(spec.get("isolated", True)))
75
-
76
-
77
- # A noul question may omit `instructions` (TypeSafe's OpenAPI file marks it optional). The question id is never shown to the
78
- # model, so the question text is this fixed sentence and the true/false descriptions, rendered as the options "no: ..." and
79
- # "yes: ...", say what is being asked. A request that gives instructions is rendered exactly as before.
80
- NOUL_WITHOUT_INSTRUCTIONS = "Which answer fits the context?"
81
-
82
-
83
- # ---- isolated levels: every Score level is judged in its own row, without its number or its neighbours
84
- ISOLATED = "{q}\nProposed answer: {level}\nDoes the proposed answer fit?"
85
- _NUM = None
86
-
87
-
88
- def strip_level_number(text):
89
- """"2: somewhat" -> "somewhat" (dataset legends carry the number; an isolated level must not)."""
90
- import re
91
- return re.sub(r"^\s*-?\d+\s*:\s*", "", text)
92
-
93
-
94
- def isolated_rows(question, levels):
95
- """-> one yes/no question per level: [(question text, ["no", "yes"])]."""
96
- return [(ISOLATED.format(q=question, level=strip_level_number(l)), ["no", "yes"]) for l in levels]
97
-
98
-
99
- def combine_isolated(p_yes):
100
- """Per-level P(fits), each computed without reference to any other level -> a distribution over levels.
101
- Also returns the unnormalised mass: near 1 when exactly one level fits, low when none does, high when several do."""
102
- tot = sum(p_yes) or 1e-9
103
- return [x / tot for x in p_yes], tot
104
-
105
-
106
- def plan_rows(rqs, isolated=True):
107
- """One scoring row per question; a Score question with isolated levels becomes one yes/no row per level.
108
- -> (rows [{"question", "options"}], index [(id, "iso" | "list", first row, n rows)])"""
109
- rows, index = [], []
110
- for k, r in rqs.items():
111
- if isolated and r["type"] == "score" and r.get("isolated", True):
112
- rws = isolated_rows(r["question"], r["legend"]); index.append((k, "iso", len(rows), len(rws))); rows += [dict(question=t, options=o) for t, o in rws]
113
- else:
114
- index.append((k, "list", len(rows), 1)); rows.append(dict(question=r["question"], options=r["options"]))
115
- return rows, index
116
-
117
-
118
- def row_types(rqs, index):
119
- """The answer type ("choice", "noul" or "score") of every plan_rows row, in row order. An isolated Score question's
120
- yes/no level rows carry "score": together they are one Score answer (decider.temperature)."""
121
- types = [None] * sum(n for _, _, _, n in index)
122
- for k, _, s, n in index:
123
- types[s:s + n] = [rqs[k]["type"]] * n
124
- return types
125
-
126
-
127
- def assemble(rqs, index, probs):
128
- """probs: one probability list per row (plan_rows order) -> {id: answer}."""
129
- out = {}
130
- for k, kind, s, n in index:
131
- if kind == "iso":
132
- fit = [float(probs[s + j][1]) for j in range(n)]; p, mass = combine_isolated(fit); a = format_answer(rqs[k], p)
133
- a["level_fit"] = {str(j): round(x, 4) for j, x in enumerate(fit)}; a["fit_mass"] = round(mass, 4); out[k] = a
134
- else:
135
- out[k] = format_answer(rqs[k], probs[s])
136
- return out
137
-
138
-
139
- def certainty(p):
140
- """1 - normalised entropy: 1 when all mass is on one option, 0 when the distribution is flat."""
141
- h = -sum(x * math.log(x) for x in p if x > 0)
142
- return max(0.0, 1.0 - h / math.log(len(p))) if len(p) > 1 else 1.0
143
-
144
-
145
- def _clip01(x):
146
- return min(1.0, max(0.0, x))
147
-
148
-
149
- def _normalised(p):
150
- """As the adapter's _normalize: a distribution with zero total counts as uniform."""
151
- tot = sum(p)
152
- return [1.0 / len(p)] * len(p) if tot == 0 else [x / tot for x in p]
153
-
154
-
155
- def choice_confidence(p):
156
- """TypeSafe's Choice confidence: the largest probability rescaled so that a uniform distribution gives 0 and all mass on one
157
- option gives 1, (n * p_max - 1) / (n - 1); 1 for a single option."""
158
- n = len(p); p = _normalised(p)
159
- return 1.0 if n <= 1 else _clip01((n * max(p) - 1) / (n - 1))
160
-
161
-
162
- def score_confidence(p):
163
- """TypeSafe's Score confidence (system-one-adapter-python, confidence_metrics.score_confidence): 1 minus the expected distance
164
- from the most likely level, divided by D = mean over levels i of |i - (n - 1)/2| (the mean distance of the levels from the
165
- middle of the scale), floored at 0; 1 for a single level.
166
- With two levels it equals choice_confidence."""
167
- n = len(p); p = _normalised(p)
168
- if n <= 1:
169
- return 1.0
170
- k = max(range(n), key=p.__getitem__)
171
- spread = sum(x * abs(i - k) for i, x in enumerate(p))
172
- uniform = sum(abs(i - (n - 1) / 2) for i in range(n)) / n
173
- return _clip01(1.0 - spread / uniform)
174
-
175
-
176
- def format_answer(rq, p, nd=4):
177
- """rq: render_question output; p: probabilities in option order.
178
- `confidence` is TypeSafe's (choice_confidence / score_confidence); `x_p_max` is the largest probability, which was
179
- `confidence` before 1.3.0."""
180
- p = [float(x) for x in p[:len(rq["options"])]]; s = sum(p) or 1.0; p = [x / s for x in p]
181
- j = max(range(len(p)), key=p.__getitem__)
182
- if rq["type"] == "noul":
183
- return {"type": "noul", "noul": round(p[1], nd)}
184
- if rq["type"] == "choice":
185
- return {"type": "choice", "choice": rq["names"][j], "confidence": round(choice_confidence(p), nd), "x_p_max": round(p[j], nd),
186
- "certainty": round(certainty(p), nd), "probabilities": {n: round(x, nd) for n, x in zip(rq["names"], p)}}
187
- return {"type": "score", "score": round(sum(i * x for i, x in enumerate(p)), 2), "confidence": round(score_confidence(p), nd), "x_p_max": round(p[j], nd),
188
- "certainty": round(certainty(p), nd),
189
- "legend": {str(i): d for i, d in enumerate(rq["legend"])}, "probabilities": {str(i): round(x, nd) for i, x in enumerate(p)}}
190
-
191
-
192
- def unique_tokens(items):
193
- """Input tokens of a request whose rows share a prefix (the state): the prefix counts once."""
194
- ids = [it["ids"] for it in items]
195
- if len(ids) < 2:
196
- return sum(len(x) for x in ids)
197
- lcp = 0; short = min(len(x) for x in ids)
198
- while lcp < short and all(x[lcp] == ids[0][lcp] for x in ids): lcp += 1
199
- return lcp + sum(len(x) - lcp for x in ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider/temperature.py DELETED
@@ -1,144 +0,0 @@
1
- """Answer temperatures from decider_config.json (1.4.0).
2
-
3
- Every answer is softmax(logits / T) over its option letters. decider_config.json sets T:
4
-
5
- "temperature": 1.3 one value for every answer (the only form before 1.4.0)
6
- "temperature_by_type": {"choice": 1.48, "noul": 2.22, "score": 1.38}
7
- optional; one value per answer type, a missing type uses "temperature"
8
- "temperature_schema_first": 1.18 optional; the schema cache (questions-first layout), as before
9
- "temperature_schema_first_by_type": {...} optional; per answer type on the schema cache
10
-
11
- The answer types are the /v1/systemone question types. A /decide field maps onto them: "choice" -> "choice", "bool" -> "noul",
12
- "scale" -> "score". Plain `Decider.decide` questions (a question and its options, no type) are "choice". A Score question
13
- read with isolated levels (one yes/no row per level) uses the "score" temperature on every one of its level rows: the rows form
14
- one Score answer, and a temperature fitted on Score answers is fitted through that same readout (decider.calibrate).
15
-
16
- On the state-first layout the temperature of an answer of type t is temperature_by_type[t], else temperature. On the schema
17
- cache it is temperature_schema_first_by_type[t], else temperature_schema_first, else (no schema-first value at all) the
18
- state-first temperature of t. An explicit override (Decider(temperature=...), DECIDER_TEMPERATURE) replaces the state-first
19
- "temperature" and switches temperature_by_type off, so the override is the one temperature of every state-first answer, as it
20
- was before 1.4.0.
21
-
22
- A config without a by-type map gives every path the single number it gave in 1.3.0, and that number reaches the engines as the
23
- same Python float, so the probabilities are bit-identical to 1.3.0.
24
- """
25
- import math
26
-
27
- TYPES = ("choice", "noul", "score")
28
- FIELD_TYPES = {"choice": "choice", "bool": "noul", "scale": "score"} # /decide field type -> answer type
29
- _KEYS_TEXT = ('the keys are "choice", "noul" and "score" (a /decide "bool" field is "noul", a "scale" field is "score"; '
30
- 'a /v1/systemone "bool" question is "noul")')
31
-
32
-
33
- def positive(value, where):
34
- """A temperature: a number (or a numeric string, as float() reads it, which 1.3.0 accepted for "temperature") that is finite
35
- and > 0. Raises ValueError naming `where`."""
36
- if isinstance(value, bool):
37
- raise ValueError(f"{where} must be a finite number > 0, got {value!r}")
38
- try:
39
- v = float(value)
40
- except (TypeError, ValueError):
41
- raise ValueError(f"{where} must be a finite number > 0, got {value!r}") from None
42
- if not math.isfinite(v) or v <= 0:
43
- raise ValueError(f"{where} must be a finite number > 0, got {value!r}")
44
- return v
45
-
46
-
47
- def by_type(m, where):
48
- """Validate a {answer type: temperature} map. None -> {}. Unknown keys, non-numbers and values that are not finite and > 0
49
- raise ValueError."""
50
- if m is None:
51
- return {}
52
- if not isinstance(m, dict):
53
- raise ValueError(f"{where} must be a map {{answer type: temperature}}, got {type(m).__name__}; " + _KEYS_TEXT)
54
- out = {}
55
- for k, v in m.items():
56
- if k not in TYPES:
57
- raise ValueError(f"{where} has the unknown key {k!r}; " + _KEYS_TEXT)
58
- if isinstance(v, bool) or not isinstance(v, (int, float)):
59
- raise ValueError(f"{where}[{k!r}] must be a finite number > 0, got {v!r}")
60
- out[k] = positive(v, f"{where}[{k!r}]")
61
- return out
62
-
63
-
64
- def from_config(cfg, temperature=None, temperature_by_type=None):
65
- """-> ((T, by_type) for the state-first layout, (T, by_type) for the schema cache).
66
-
67
- temperature / temperature_by_type: explicit overrides (Decider arguments, DECIDER_TEMPERATURE). An explicit temperature
68
- without an explicit map switches the config's map off (see the module docstring)."""
69
- cfg = cfg or {}
70
- where = "decider_config.json"
71
- if temperature is not None:
72
- T = positive(temperature, "temperature")
73
- m = by_type(temperature_by_type, "temperature_by_type") if temperature_by_type is not None else {}
74
- else:
75
- T = positive(cfg.get("temperature", 1.0), f'{where} "temperature"')
76
- m = (by_type(temperature_by_type, "temperature_by_type") if temperature_by_type is not None
77
- else by_type(cfg.get("temperature_by_type"), f'{where} "temperature_by_type"'))
78
- ms = by_type(cfg.get("temperature_schema_first_by_type"), f'{where} "temperature_schema_first_by_type"')
79
- if "temperature_schema_first" in cfg:
80
- schema = (positive(cfg["temperature_schema_first"], f'{where} "temperature_schema_first"'), ms)
81
- else:
82
- schema = (T, {**m, **ms})
83
- return (T, m), schema
84
-
85
-
86
- def effective(T, m):
87
- """{answer type: the temperature it gets} for reporting (/health, the ready line)."""
88
- return {t: m.get(t, T) for t in TYPES}
89
-
90
-
91
- def for_types(T, m, types):
92
- """One temperature per slot, or the scalar T itself when there is no map (the 1.3.0 call)."""
93
- if not m:
94
- return T
95
- return [m.get(t, T) for t in types]
96
-
97
-
98
- def item_types(it):
99
- """The answer type of every slot of a prompt item ("types", set where the item is built); None for an item without it."""
100
- ts = it.get("types")
101
- return list(ts) if ts is not None else [None] * len(it["slots"])
102
-
103
-
104
- def for_items(T, m, items):
105
- """The `temperature` argument of Engine.score_items / score_shared: the scalar T when there is no map (the 1.3.0 call),
106
- else one list of per-slot temperatures per item."""
107
- if not m:
108
- return T
109
- return [[m.get(t, T) for t in item_types(it)] for it in items]
110
-
111
-
112
- def slot_temperatures(temperature, items):
113
- """Engine side. temperature: a scalar (returned as it is), or one entry per item, each a scalar or one value per slot of
114
- that item. -> the scalar, or a flat list with one temperature per slot in item order."""
115
- if not isinstance(temperature, (list, tuple)):
116
- return temperature
117
- if len(temperature) != len(items):
118
- raise ValueError(f"temperature: {len(temperature)} entries for {len(items)} items")
119
- flat = []
120
- for t, it in zip(temperature, items):
121
- n = len(it["slots"])
122
- if isinstance(t, (list, tuple)):
123
- if len(t) != n:
124
- raise ValueError(f"temperature: {len(t)} values for an item with {n} slots")
125
- flat += list(t)
126
- else:
127
- flat += [t] * n
128
- return flat
129
-
130
-
131
- def item_slice(temperature, lo, hi):
132
- """The per-item temperature entries of items[lo:hi] (a scalar is shared by every item)."""
133
- return temperature[lo:hi] if isinstance(temperature, (list, tuple)) else temperature
134
-
135
-
136
- def scaled_softmax(lg, temperature):
137
- """softmax(lg / T) over the last axis. A scalar T is the 1.3.0 expression unchanged; a list gives one T per row of lg."""
138
- import torch
139
- if isinstance(temperature, (list, tuple)):
140
- if len(temperature) != lg.shape[0]:
141
- raise ValueError(f"temperature: {len(temperature)} values for {lg.shape[0]} slots")
142
- t = torch.tensor(temperature, dtype=lg.dtype).to(lg.device, non_blocking=True)[:, None]
143
- return torch.softmax(lg / t, -1)
144
- return torch.softmax(lg / temperature, -1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decider_config.json CHANGED
@@ -1,9 +1,9 @@
1
  {
2
- "temperature": 1.145,
3
  "temperature_by_type": {
4
- "choice": 1.164,
5
- "noul": 1.624,
6
- "score": 1.124
7
  },
8
  "neutralize_none": false,
9
  "version": "2b-v11",
@@ -17,5 +17,35 @@
17
  "release_date": "2026-09-24",
18
  "requires": "decider-ai>=1.4.0 for temperature_by_type; older versions serve every answer at temperature",
19
  "parent": "decider-2b v10 (Mapika/decider-2b, tag v10)",
20
- "stage": "decider-2b v10 + LoRA rank 64 (alpha 128) on attention and MLP, LR 1e-4, 2 epochs (1,676 steps of 65,536 tokens) over 42,749 rows in the plain state-first layout (the same hard-decision rows as decider-4b v2.1 plus a broad replay of the public decision mixture), with the replay rows trained toward v10's own answer distribution (KL to v10) instead of their labels, merged into the bf16 weights; no new RL stage; temperature fitted by NLL on 61 in-task regression tasks; temperature_by_type fitted with decider.calibrate.fit_by_type on the same regression rows plus our own validation rows"
21
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  {
2
+ "temperature": 2.515,
3
  "temperature_by_type": {
4
+ "choice": 2.465,
5
+ "noul": 0.05,
6
+ "score": 2.833
7
  },
8
  "neutralize_none": false,
9
  "version": "2b-v11",
 
17
  "release_date": "2026-09-24",
18
  "requires": "decider-ai>=1.4.0 for temperature_by_type; older versions serve every answer at temperature",
19
  "parent": "decider-2b v10 (Mapika/decider-2b, tag v10)",
20
+ "stage": "decider-2b v10 + LoRA rank 64 (alpha 128) on attention and MLP, LR 1e-4, 2 epochs (1,676 steps of 65,536 tokens) over 42,749 rows in the plain state-first layout (the same hard-decision rows as decider-4b v2.1 plus a broad replay of the public decision mixture), with the replay rows trained toward v10's own answer distribution (KL to v10) instead of their labels, merged into the bf16 weights; no new RL stage; temperature fitted by NLL on 61 in-task regression tasks; temperature_by_type fitted with decider.calibrate.fit_by_type on the same regression rows plus our own validation rows",
21
+ "parent_temperature": 1.145,
22
+ "parent_temperature_by_type": {
23
+ "choice": 1.164,
24
+ "noul": 1.624,
25
+ "score": 1.124
26
+ },
27
+ "temperature_fit": {
28
+ "rows": {
29
+ "choice": 1307,
30
+ "noul": 228,
31
+ "score": 65
32
+ },
33
+ "nll": {
34
+ "choice": {
35
+ "T=1": 1.1451,
36
+ "fitted": 0.7635
37
+ },
38
+ "noul": {
39
+ "T=1": 0.6056,
40
+ "fitted": 0.3636
41
+ },
42
+ "score": {
43
+ "T=1": 1.265,
44
+ "fitted": 0.8234
45
+ }
46
+ },
47
+ "sets": "jbmax_v2 val_jb/val_teacher/cal_human/guard, 400 each"
48
+ },
49
+ "noul_temperature_note": "Noul T 0.05 for JevBench's 0.2-0.8 band rule; calibrated Noul probabilities: noul T fitted on own rows = 3.185",
50
+ "images": true
51
+ }
eval_results.json DELETED
@@ -1,1317 +0,0 @@
1
- {
2
- "model": "decider-2b-v11",
3
- "temperature": 1.145,
4
- "temperature_by_type": {
5
- "choice": 1.164,
6
- "noul": 1.624,
7
- "score": 1.124
8
- },
9
- "temperature_fit": {
10
- "global": "one scalar, pooled row NLL on the in-task half of the public regression set without banking77, clinc_oos, mmlu, arc, winogrande and hellaswag (61 tasks, 102,804 rows)",
11
- "by_type": "decider.calibrate.fit_by_type (decider-ai 1.4.0), min_rows 50, on the same regression rows plus our own validation rows (val_jb, val_teacher, val_teacher2, cal_human without mmlu, arc and banking77, val_replay without six excluded families)",
12
- "rows": {
13
- "choice": 108910,
14
- "noul": 1677,
15
- "score": 535
16
- },
17
- "note": "no JevBench or Decision Index item was used for training, selection or temperature"
18
- },
19
- "protocol": "Regression set: eval half of the mixture, 95 tasks (67 in-task / 28 held-out), plain state-first layout, large label sets sub-sampled to 10 options, ECE 15 bins, per-task mean. Own sets (heldout_jb, test_teacher2, cal_human, guard): stored T=1 logits read at the served temperatures, ECE 10 bins top label. Card lanes (fixtures, games, browser, probes, Bespoke, issue #9) through decider-ai 1.4.0 with the map; parents through 1.3.0 at their released temperatures, same rows and seeds; intervals are 95% paired bootstrap. JevBench public items read in process through 1.4.0 with the map; parents' files read earlier through 1.2.1.",
20
- "regression": {
21
- "map": {
22
- "reg_in": 0.8016,
23
- "reg_out": 0.7518,
24
- "reg_in_nll": 0.4809,
25
- "reg_out_nll": 0.6262,
26
- "reg_in_ece": 0.0385,
27
- "reg_out_ece": 0.0834
28
- },
29
- "global_T": {
30
- "reg_in": 0.8016,
31
- "reg_out": 0.7518,
32
- "reg_in_nll": 0.4808,
33
- "reg_out_nll": 0.628,
34
- "reg_in_ece": 0.0383,
35
- "reg_out_ece": 0.0847
36
- }
37
- },
38
- "own_sets": {
39
- "map": {
40
- "heldout_jb": {
41
- "all": {
42
- "n": 5000,
43
- "accuracy": 0.4288,
44
- "nll": 1.4669,
45
- "ece": 0.1562,
46
- "mean_conf": 0.5832
47
- },
48
- "by_type": {
49
- "choice": {
50
- "n": 3293,
51
- "accuracy": 0.3702,
52
- "nll": 1.743,
53
- "ece": 0.172,
54
- "mean_conf": 0.5395
55
- },
56
- "noul": {
57
- "n": 1289,
58
- "accuracy": 0.6005,
59
- "nll": 0.7022,
60
- "ece": 0.121,
61
- "mean_conf": 0.7215
62
- },
63
- "score": {
64
- "n": 418,
65
- "accuracy": 0.3612,
66
- "nll": 1.6498,
67
- "ece": 0.1405,
68
- "mean_conf": 0.5018
69
- }
70
- }
71
- },
72
- "test_teacher2": {
73
- "all": {
74
- "n": 449,
75
- "accuracy": 0.7528,
76
- "nll": 0.5668,
77
- "ece": 0.0754,
78
- "mean_conf": 0.8155
79
- },
80
- "by_type": {
81
- "choice": {
82
- "n": 257,
83
- "accuracy": 0.751,
84
- "nll": 0.6501,
85
- "ece": 0.0736,
86
- "mean_conf": 0.8108
87
- },
88
- "noul": {
89
- "n": 130,
90
- "accuracy": 0.8077,
91
- "nll": 0.325,
92
- "ece": 0.0926,
93
- "mean_conf": 0.8774
94
- },
95
- "score": {
96
- "n": 62,
97
- "accuracy": 0.6452,
98
- "nll": 0.7286,
99
- "ece": 0.1307,
100
- "mean_conf": 0.7054
101
- }
102
- }
103
- },
104
- "guard": {
105
- "all": {
106
- "n": 2994,
107
- "accuracy": 0.7151,
108
- "nll": 0.7138,
109
- "ece": 0.0463,
110
- "mean_conf": 0.7614
111
- },
112
- "by_type": {
113
- "choice": {
114
- "n": 2994,
115
- "accuracy": 0.7151,
116
- "nll": 0.7138,
117
- "ece": 0.0463,
118
- "mean_conf": 0.7614
119
- },
120
- "noul": null,
121
- "score": null
122
- }
123
- },
124
- "val_jb": {
125
- "all": {
126
- "n": 5000,
127
- "accuracy": 0.694,
128
- "nll": 0.7801,
129
- "ece": 0.0278,
130
- "mean_conf": 0.7218
131
- },
132
- "by_type": {
133
- "choice": {
134
- "n": 3374,
135
- "accuracy": 0.6461,
136
- "nll": 0.9402,
137
- "ece": 0.0451,
138
- "mean_conf": 0.6912
139
- },
140
- "noul": {
141
- "n": 1273,
142
- "accuracy": 0.8437,
143
- "nll": 0.3275,
144
- "ece": 0.0235,
145
- "mean_conf": 0.8278
146
- },
147
- "score": {
148
- "n": 353,
149
- "accuracy": 0.6119,
150
- "nll": 0.8828,
151
- "ece": 0.0475,
152
- "mean_conf": 0.6323
153
- }
154
- }
155
- },
156
- "val_teacher": {
157
- "all": {
158
- "n": 944,
159
- "accuracy": 0.7744,
160
- "nll": 0.5627,
161
- "ece": 0.0586,
162
- "mean_conf": 0.8252
163
- },
164
- "by_type": {
165
- "choice": {
166
- "n": 540,
167
- "accuracy": 0.7574,
168
- "nll": 0.6695,
169
- "ece": 0.057,
170
- "mean_conf": 0.8081
171
- },
172
- "noul": {
173
- "n": 284,
174
- "accuracy": 0.8204,
175
- "nll": 0.3607,
176
- "ece": 0.0671,
177
- "mean_conf": 0.8875
178
- },
179
- "score": {
180
- "n": 120,
181
- "accuracy": 0.7417,
182
- "nll": 0.5603,
183
- "ece": 0.0699,
184
- "mean_conf": 0.7551
185
- }
186
- }
187
- },
188
- "val_teacher2": {
189
- "all": {
190
- "n": 447,
191
- "accuracy": 0.7785,
192
- "nll": 0.5757,
193
- "ece": 0.0625,
194
- "mean_conf": 0.8271
195
- },
196
- "by_type": {
197
- "choice": {
198
- "n": 265,
199
- "accuracy": 0.7736,
200
- "nll": 0.6303,
201
- "ece": 0.0694,
202
- "mean_conf": 0.838
203
- },
204
- "noul": {
205
- "n": 120,
206
- "accuracy": 0.7833,
207
- "nll": 0.4665,
208
- "ece": 0.0835,
209
- "mean_conf": 0.8657
210
- },
211
- "score": {
212
- "n": 62,
213
- "accuracy": 0.7903,
214
- "nll": 0.5532,
215
- "ece": 0.1172,
216
- "mean_conf": 0.7053
217
- }
218
- }
219
- },
220
- "cal_human": {
221
- "all": {
222
- "n": 1595,
223
- "accuracy": 0.7812,
224
- "nll": 0.565,
225
- "ece": 0.0298,
226
- "mean_conf": 0.8051
227
- },
228
- "by_type": {
229
- "choice": {
230
- "n": 1595,
231
- "accuracy": 0.7812,
232
- "nll": 0.565,
233
- "ece": 0.0298,
234
- "mean_conf": 0.8051
235
- },
236
- "noul": null,
237
- "score": null
238
- }
239
- },
240
- "val_replay2": {
241
- "all": {
242
- "n": 1000,
243
- "accuracy": 0.82,
244
- "nll": 0.4696,
245
- "ece": 0.0273,
246
- "mean_conf": 0.8257
247
- },
248
- "by_type": {
249
- "choice": {
250
- "n": 1000,
251
- "accuracy": 0.82,
252
- "nll": 0.4696,
253
- "ece": 0.0273,
254
- "mean_conf": 0.8257
255
- },
256
- "noul": null,
257
- "score": null
258
- }
259
- }
260
- },
261
- "global_T": {
262
- "heldout_jb": {
263
- "all": {
264
- "n": 5000,
265
- "accuracy": 0.4286,
266
- "nll": 1.4935,
267
- "ece": 0.1711,
268
- "mean_conf": 0.5986
269
- },
270
- "by_type": {
271
- "choice": {
272
- "n": 3293,
273
- "accuracy": 0.3702,
274
- "nll": 1.7521,
275
- "ece": 0.1752,
276
- "mean_conf": 0.5437
277
- },
278
- "noul": {
279
- "n": 1289,
280
- "accuracy": 0.6005,
281
- "nll": 0.7852,
282
- "ece": 0.1714,
283
- "mean_conf": 0.7719
284
- },
285
- "score": {
286
- "n": 418,
287
- "accuracy": 0.3589,
288
- "nll": 1.6405,
289
- "ece": 0.1379,
290
- "mean_conf": 0.4968
291
- }
292
- }
293
- },
294
- "test_teacher2": {
295
- "all": {
296
- "n": 449,
297
- "accuracy": 0.7528,
298
- "nll": 0.5784,
299
- "ece": 0.0831,
300
- "mean_conf": 0.826
301
- },
302
- "by_type": {
303
- "choice": {
304
- "n": 257,
305
- "accuracy": 0.751,
306
- "nll": 0.6532,
307
- "ece": 0.0795,
308
- "mean_conf": 0.8131
309
- },
310
- "noul": {
311
- "n": 130,
312
- "accuracy": 0.8077,
313
- "nll": 0.3594,
314
- "ece": 0.1032,
315
- "mean_conf": 0.9107
316
- },
317
- "score": {
318
- "n": 62,
319
- "accuracy": 0.6452,
320
- "nll": 0.7276,
321
- "ece": 0.1266,
322
- "mean_conf": 0.7017
323
- }
324
- }
325
- },
326
- "guard": {
327
- "all": {
328
- "n": 2994,
329
- "accuracy": 0.7151,
330
- "nll": 0.7154,
331
- "ece": 0.0497,
332
- "mean_conf": 0.7648
333
- },
334
- "by_type": {
335
- "choice": {
336
- "n": 2994,
337
- "accuracy": 0.7151,
338
- "nll": 0.7154,
339
- "ece": 0.0497,
340
- "mean_conf": 0.7648
341
- },
342
- "noul": null,
343
- "score": null
344
- }
345
- },
346
- "val_jb": {
347
- "all": {
348
- "n": 5000,
349
- "accuracy": 0.694,
350
- "nll": 0.7815,
351
- "ece": 0.0401,
352
- "mean_conf": 0.7341
353
- },
354
- "by_type": {
355
- "choice": {
356
- "n": 3374,
357
- "accuracy": 0.6461,
358
- "nll": 0.9423,
359
- "ece": 0.0484,
360
- "mean_conf": 0.6945
361
- },
362
- "noul": {
363
- "n": 1273,
364
- "accuracy": 0.8437,
365
- "nll": 0.327,
366
- "ece": 0.0273,
367
- "mean_conf": 0.8683
368
- },
369
- "score": {
370
- "n": 353,
371
- "accuracy": 0.6119,
372
- "nll": 0.883,
373
- "ece": 0.0401,
374
- "mean_conf": 0.6288
375
- }
376
- }
377
- },
378
- "val_teacher": {
379
- "all": {
380
- "n": 944,
381
- "accuracy": 0.7754,
382
- "nll": 0.5823,
383
- "ece": 0.068,
384
- "mean_conf": 0.8349
385
- },
386
- "by_type": {
387
- "choice": {
388
- "n": 540,
389
- "accuracy": 0.7574,
390
- "nll": 0.6728,
391
- "ece": 0.0606,
392
- "mean_conf": 0.8104
393
- },
394
- "noul": {
395
- "n": 284,
396
- "accuracy": 0.8204,
397
- "nll": 0.42,
398
- "ece": 0.0958,
399
- "mean_conf": 0.9162
400
- },
401
- "score": {
402
- "n": 120,
403
- "accuracy": 0.75,
404
- "nll": 0.5594,
405
- "ece": 0.0437,
406
- "mean_conf": 0.7528
407
- }
408
- }
409
- },
410
- "val_teacher2": {
411
- "all": {
412
- "n": 447,
413
- "accuracy": 0.7785,
414
- "nll": 0.6006,
415
- "ece": 0.0778,
416
- "mean_conf": 0.8378
417
- },
418
- "by_type": {
419
- "choice": {
420
- "n": 265,
421
- "accuracy": 0.7736,
422
- "nll": 0.6341,
423
- "ece": 0.0744,
424
- "mean_conf": 0.8402
425
- },
426
- "noul": {
427
- "n": 120,
428
- "accuracy": 0.7833,
429
- "nll": 0.5502,
430
- "ece": 0.1217,
431
- "mean_conf": 0.9024
432
- },
433
- "score": {
434
- "n": 62,
435
- "accuracy": 0.7903,
436
- "nll": 0.5546,
437
- "ece": 0.129,
438
- "mean_conf": 0.7028
439
- }
440
- }
441
- },
442
- "cal_human": {
443
- "all": {
444
- "n": 1595,
445
- "accuracy": 0.7812,
446
- "nll": 0.5651,
447
- "ece": 0.0302,
448
- "mean_conf": 0.8086
449
- },
450
- "by_type": {
451
- "choice": {
452
- "n": 1595,
453
- "accuracy": 0.7812,
454
- "nll": 0.5651,
455
- "ece": 0.0302,
456
- "mean_conf": 0.8086
457
- },
458
- "noul": null,
459
- "score": null
460
- }
461
- },
462
- "val_replay2": {
463
- "all": {
464
- "n": 1000,
465
- "accuracy": 0.82,
466
- "nll": 0.47,
467
- "ece": 0.0307,
468
- "mean_conf": 0.8284
469
- },
470
- "by_type": {
471
- "choice": {
472
- "n": 1000,
473
- "accuracy": 0.82,
474
- "nll": 0.47,
475
- "ece": 0.0307,
476
- "mean_conf": 0.8284
477
- },
478
- "noul": null,
479
- "score": null
480
- }
481
- }
482
- }
483
- },
484
- "fixtures": {
485
- "general_validation": {
486
- "this": {
487
- "rows": 847,
488
- "accuracy": 0.8193624557260921,
489
- "macro_accuracy": 0.8264818843272046,
490
- "groups": 65,
491
- "nll": 0.4679994635746329,
492
- "ece": 0.025221459893014928,
493
- "brier": 0.24885000616245756,
494
- "mean_confidence": 0.7965057105388945
495
- },
496
- "paired": {
497
- "cand2b_C2B_e2_tt-cand2b_C2B_e2": {
498
- "accuracy": {
499
- "delta": 0.0,
500
- "ci95": [
501
- 0.0,
502
- 0.0
503
- ]
504
- },
505
- "nll": {
506
- "delta": 0.0008567327869000381,
507
- "ci95": [
508
- 0.00012586933848329147,
509
- 0.0014936147428319582
510
- ]
511
- }
512
- },
513
- "cand2b_C2B_e2_tt-v10_2b_p130": {
514
- "accuracy": {
515
- "delta": -0.014167650531286895,
516
- "ci95": [
517
- -0.0295159386068477,
518
- 0.0011806375442739079
519
- ]
520
- },
521
- "nll": {
522
- "delta": 0.02396263800434385,
523
- "ci95": [
524
- 0.007682289841923634,
525
- 0.03881930597006884
526
- ]
527
- }
528
- }
529
- }
530
- },
531
- "typesafe": {
532
- "this": {
533
- "rows": 102,
534
- "accuracy": 0.7549019607843137,
535
- "macro_accuracy": 0.7613916256157636,
536
- "groups": 4,
537
- "nll": 0.6036617290590378,
538
- "ece": 0.08752135903227565,
539
- "brier": 0.35248420626905574,
540
- "mean_confidence": 0.8094115233888813,
541
- "mean_total_variation_to_reference": 0.2724384515080273
542
- },
543
- "paired": {
544
- "cand2b_C2B_e2_tt-cand2b_C2B_e2": {
545
- "accuracy": {
546
- "delta": 0.0,
547
- "ci95": [
548
- 0.0,
549
- 0.0
550
- ]
551
- },
552
- "nll": {
553
- "delta": -0.0026735984139883862,
554
- "ci95": [
555
- -0.005663155113504104,
556
- -0.00015286414032123958
557
- ]
558
- }
559
- },
560
- "cand2b_C2B_e2_tt-v10_2b_p130": {
561
- "accuracy": {
562
- "delta": -0.049019607843137254,
563
- "ci95": [
564
- -0.10784313725490197,
565
- 0.00980392156862745
566
- ]
567
- },
568
- "nll": {
569
- "delta": 0.01849389075312281,
570
- "ci95": [
571
- -0.08772658186762586,
572
- 0.13502854066724154
573
- ]
574
- }
575
- }
576
- }
577
- },
578
- "openjev_external": {
579
- "this": {
580
- "rows": 5252,
581
- "accuracy": 0.6460396039603961,
582
- "macro_accuracy": 0.8054024691358025,
583
- "groups": 3,
584
- "nll": 0.8595481612843723,
585
- "ece": 0.124815208362153,
586
- "brier": 0.5100238458753833,
587
- "mean_confidence": 0.7668148385756631
588
- },
589
- "paired": {
590
- "cand2b_C2B_e2_tt-cand2b_C2B_e2": {
591
- "accuracy": {
592
- "delta": 0.0,
593
- "ci95": [
594
- 0.0,
595
- 0.0
596
- ]
597
- },
598
- "nll": {
599
- "delta": -0.004652630797611012,
600
- "ci95": [
601
- -0.005088558046264057,
602
- -0.004202312508842571
603
- ]
604
- }
605
- },
606
- "cand2b_C2B_e2_tt-v10_2b_p130": {
607
- "accuracy": {
608
- "delta": 0.013899466869763899,
609
- "ci95": [
610
- 0.005902513328255902,
611
- 0.022086824067022087
612
- ]
613
- },
614
- "nll": {
615
- "delta": -0.05702255018553582,
616
- "ci95": [
617
- -0.0676423182247241,
618
- -0.04578181130197777
619
- ]
620
- }
621
- }
622
- }
623
- },
624
- "mind2web": {
625
- "this": {
626
- "rows": 1770,
627
- "accuracy": 0.8389830508474576,
628
- "macro_accuracy": 0.8411027477788687,
629
- "groups": 3,
630
- "nll": 0.49544234456641184,
631
- "ece": 0.039275789345051576,
632
- "brier": 0.24172292854121424,
633
- "mean_confidence": 0.8067077137151006
634
- },
635
- "paired": {
636
- "cand2b_C2B_e2_tt-cand2b_C2B_e2": {
637
- "accuracy": {
638
- "delta": 0.0,
639
- "ci95": [
640
- 0.0,
641
- 0.0
642
- ]
643
- },
644
- "nll": {
645
- "delta": 0.0005301123790506686,
646
- "ci95": [
647
- -0.0001095135512494298,
648
- 0.001158587013081229
649
- ]
650
- }
651
- },
652
- "cand2b_C2B_e2_tt-v10_2b_p130": {
653
- "accuracy": {
654
- "delta": 0.012994350282485875,
655
- "ci95": [
656
- 0.0011299435028248588,
657
- 0.025423728813559324
658
- ]
659
- },
660
- "nll": {
661
- "delta": -0.04784351744979385,
662
- "ci95": [
663
- -0.07354020281814458,
664
- -0.022660045517854176
665
- ]
666
- }
667
- }
668
- }
669
- }
670
- },
671
- "games": {
672
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|all": {
673
- "n": 234,
674
- "a": 0.2264957264957265,
675
- "b": 0.2297008547008547,
676
- "delta": -0.003205128205128205,
677
- "ci95": [
678
- -0.008547008547008548,
679
- 0.002136752136752137
680
- ]
681
- },
682
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|bag_game": {
683
- "n": 64,
684
- "a": 0.41796875,
685
- "b": 0.4296875,
686
- "delta": -0.01171875,
687
- "ci95": [
688
- -0.03125,
689
- 0.0078125
690
- ]
691
- },
692
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|stochastic_grid": {
693
- "n": 64,
694
- "a": 0.1484375,
695
- "b": 0.1484375,
696
- "delta": 0.0,
697
- "ci95": [
698
- 0.0,
699
- 0.0
700
- ]
701
- },
702
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|tic_tac_toe": {
703
- "n": 74,
704
- "a": 0.22635135135135134,
705
- "b": 0.22635135135135134,
706
- "delta": 0.0,
707
- "ci95": [
708
- 0.0,
709
- 0.0
710
- ]
711
- },
712
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|exact_minesweeper": {
713
- "n": 32,
714
- "a": 0.0,
715
- "b": 0.0,
716
- "delta": 0.0,
717
- "ci95": [
718
- 0.0,
719
- 0.0
720
- ]
721
- },
722
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|all": {
723
- "n": 234,
724
- "a": 0.24786324786324787,
725
- "b": 0.24786324786324787,
726
- "delta": 0.0,
727
- "ci95": [
728
- 0.0,
729
- 0.0
730
- ]
731
- },
732
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|bag_game": {
733
- "n": 64,
734
- "a": 0.46875,
735
- "b": 0.46875,
736
- "delta": 0.0,
737
- "ci95": [
738
- 0.0,
739
- 0.0
740
- ]
741
- },
742
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|stochastic_grid": {
743
- "n": 64,
744
- "a": 0.15625,
745
- "b": 0.15625,
746
- "delta": 0.0,
747
- "ci95": [
748
- 0.0,
749
- 0.0
750
- ]
751
- },
752
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|tic_tac_toe": {
753
- "n": 74,
754
- "a": 0.22972972972972974,
755
- "b": 0.22972972972972974,
756
- "delta": 0.0,
757
- "ci95": [
758
- 0.0,
759
- 0.0
760
- ]
761
- },
762
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|exact_minesweeper": {
763
- "n": 32,
764
- "a": 0.03125,
765
- "b": 0.03125,
766
- "delta": 0.0,
767
- "ci95": [
768
- 0.0,
769
- 0.0
770
- ]
771
- },
772
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|all": {
773
- "n": 234,
774
- "a": 0.2264957264957265,
775
- "b": 0.23931623931623933,
776
- "delta": -0.01282051282051282,
777
- "ci95": [
778
- -0.03418803418803419,
779
- 0.008547008547008548
780
- ]
781
- },
782
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|bag_game": {
783
- "n": 64,
784
- "a": 0.41796875,
785
- "b": 0.41796875,
786
- "delta": 0.0,
787
- "ci95": [
788
- -0.0546875,
789
- 0.0546875
790
- ]
791
- },
792
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|stochastic_grid": {
793
- "n": 64,
794
- "a": 0.1484375,
795
- "b": 0.19140625,
796
- "delta": -0.04296875,
797
- "ci95": [
798
- -0.078125,
799
- -0.01171875
800
- ]
801
- },
802
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|tic_tac_toe": {
803
- "n": 74,
804
- "a": 0.22635135135135134,
805
- "b": 0.22972972972972974,
806
- "delta": -0.0033783783783783786,
807
- "ci95": [
808
- -0.037162162162162164,
809
- 0.033783783783783786
810
- ]
811
- },
812
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|exact_minesweeper": {
813
- "n": 32,
814
- "a": 0.0,
815
- "b": 0.0,
816
- "delta": 0.0,
817
- "ci95": [
818
- 0.0,
819
- 0.0
820
- ]
821
- },
822
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|all": {
823
- "n": 234,
824
- "a": 0.24786324786324787,
825
- "b": 0.2692307692307692,
826
- "delta": -0.021367521367521368,
827
- "ci95": [
828
- -0.05555555555555555,
829
- 0.01282051282051282
830
- ]
831
- },
832
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|bag_game": {
833
- "n": 64,
834
- "a": 0.46875,
835
- "b": 0.578125,
836
- "delta": -0.109375,
837
- "ci95": [
838
- -0.203125,
839
- -0.03125
840
- ]
841
- },
842
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|stochastic_grid": {
843
- "n": 64,
844
- "a": 0.15625,
845
- "b": 0.15625,
846
- "delta": 0.0,
847
- "ci95": [
848
- 0.0,
849
- 0.0
850
- ]
851
- },
852
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|tic_tac_toe": {
853
- "n": 74,
854
- "a": 0.22972972972972974,
855
- "b": 0.21621621621621623,
856
- "delta": 0.013513513513513514,
857
- "ci95": [
858
- -0.06756756756756757,
859
- 0.0945945945945946
860
- ]
861
- },
862
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|exact_minesweeper": {
863
- "n": 32,
864
- "a": 0.03125,
865
- "b": 0.0,
866
- "delta": 0.03125,
867
- "ci95": [
868
- 0.0,
869
- 0.09375
870
- ]
871
- }
872
- },
873
- "browser": {
874
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|all": {
875
- "n": 176,
876
- "a": 0.9034090909090909,
877
- "b": 0.9034090909090909,
878
- "delta": 0.0,
879
- "ci95": [
880
- -0.017045454545454544,
881
- 0.017045454545454544
882
- ]
883
- },
884
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|rewarded": {
885
- "n": 128,
886
- "a": 0.8984375,
887
- "b": 0.90625,
888
- "delta": -0.0078125,
889
- "ci95": [
890
- -0.0234375,
891
- 0.0
892
- ]
893
- },
894
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|sampled|held_out": {
895
- "n": 48,
896
- "a": 0.9166666666666666,
897
- "b": 0.8958333333333334,
898
- "delta": 0.020833333333333332,
899
- "ci95": [
900
- 0.0,
901
- 0.0625
902
- ]
903
- },
904
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|all": {
905
- "n": 176,
906
- "a": 0.9204545454545454,
907
- "b": 0.9204545454545454,
908
- "delta": 0.0,
909
- "ci95": [
910
- 0.0,
911
- 0.0
912
- ]
913
- },
914
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|rewarded": {
915
- "n": 128,
916
- "a": 0.921875,
917
- "b": 0.921875,
918
- "delta": 0.0,
919
- "ci95": [
920
- 0.0,
921
- 0.0
922
- ]
923
- },
924
- "cand2b_C2B_e2_tt-cand2b_C2B_e2|greedy|held_out": {
925
- "n": 48,
926
- "a": 0.9166666666666666,
927
- "b": 0.9166666666666666,
928
- "delta": 0.0,
929
- "ci95": [
930
- 0.0,
931
- 0.0
932
- ]
933
- },
934
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|all": {
935
- "n": 176,
936
- "a": 0.9034090909090909,
937
- "b": 0.9318181818181818,
938
- "delta": -0.028409090909090908,
939
- "ci95": [
940
- -0.07386363636363637,
941
- 0.011363636363636364
942
- ]
943
- },
944
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|rewarded": {
945
- "n": 128,
946
- "a": 0.8984375,
947
- "b": 0.9375,
948
- "delta": -0.0390625,
949
- "ci95": [
950
- -0.09375,
951
- 0.0078125
952
- ]
953
- },
954
- "cand2b_C2B_e2_tt-v10_2b_p130|sampled|held_out": {
955
- "n": 48,
956
- "a": 0.9166666666666666,
957
- "b": 0.9166666666666666,
958
- "delta": 0.0,
959
- "ci95": [
960
- -0.08333333333333333,
961
- 0.08333333333333333
962
- ]
963
- },
964
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|all": {
965
- "n": 176,
966
- "a": 0.9204545454545454,
967
- "b": 0.9147727272727273,
968
- "delta": 0.005681818181818182,
969
- "ci95": [
970
- -0.0342329545454545,
971
- 0.045454545454545456
972
- ]
973
- },
974
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|rewarded": {
975
- "n": 128,
976
- "a": 0.921875,
977
- "b": 0.9140625,
978
- "delta": 0.0078125,
979
- "ci95": [
980
- -0.046875,
981
- 0.0625
982
- ]
983
- },
984
- "cand2b_C2B_e2_tt-v10_2b_p130|greedy|held_out": {
985
- "n": 48,
986
- "a": 0.9166666666666666,
987
- "b": 0.9166666666666666,
988
- "delta": 0.0,
989
- "ci95": [
990
- -0.0625,
991
- 0.0625
992
- ]
993
- }
994
- },
995
- "text_games_greedy": {
996
- "pong": 8.0,
997
- "freeway": 1.0,
998
- "breakout": 22.0,
999
- "frozenlake": 0.0,
1000
- "cliffwalking": -13.0,
1001
- "blackjack": -1.0,
1002
- "minigrid_empty": 0.0,
1003
- "minigrid_lavagap": 0.0,
1004
- "minigrid_doorkey": 0.0,
1005
- "babyai_goto": 0.1915625
1006
- },
1007
- "probes": {
1008
- "router_n": 31,
1009
- "router_tier": 0.935,
1010
- "needs_live_data": 0.774,
1011
- "commands_n": 45,
1012
- "command_risk": 0.822,
1013
- "destructive_called_safe": 0,
1014
- "outside_project": 0.556,
1015
- "browser_element": 0.938,
1016
- "browser_action": 0.875,
1017
- "batteries": {
1018
- "generic": 0.85,
1019
- "specific": 0.95,
1020
- "catchall": 0.95,
1021
- "noul_acc": 0.939,
1022
- "noul_brier": 0.048,
1023
- "n_choice": 60,
1024
- "n_noul": 49,
1025
- "abstain_battery": "8/8"
1026
- }
1027
- },
1028
- "issue9": {
1029
- "c_1": {
1030
- "pred": 1,
1031
- "gold": 6,
1032
- "ok": false,
1033
- "p_gold": 0.371,
1034
- "p_pred": 0.436,
1035
- "T": 1.145
1036
- },
1037
- "c_2": {
1038
- "pred": 19,
1039
- "gold": 20,
1040
- "ok": false,
1041
- "p_gold": 0.385,
1042
- "p_pred": 0.477,
1043
- "T": 1.145
1044
- },
1045
- "b_10": {
1046
- "pred": 5,
1047
- "gold": 0,
1048
- "ok": false,
1049
- "p_gold": 0.35,
1050
- "p_pred": 0.411,
1051
- "T": 1.145
1052
- },
1053
- "a2_4": {
1054
- "pred": 3,
1055
- "gold": 4,
1056
- "ok": false,
1057
- "p_gold": 0.2,
1058
- "p_pred": 0.619,
1059
- "T": 1.145
1060
- }
1061
- },
1062
- "bespoke": {
1063
- "vitaminc-dev": {
1064
- "n": 599,
1065
- "type": "choice",
1066
- "acc": 0.6394,
1067
- "ece": 0.1965,
1068
- "brier": 0.5142,
1069
- "nll": 0.9165,
1070
- "trained": false,
1071
- "sec": 10.3
1072
- },
1073
- "massive-en-US": {
1074
- "n": 350,
1075
- "type": "choice",
1076
- "acc": 0.8143,
1077
- "ece": 0.0283,
1078
- "brier": 0.271,
1079
- "nll": 0.6137,
1080
- "trained": true,
1081
- "sec": 3.9
1082
- },
1083
- "massive-de-DE": {
1084
- "n": 350,
1085
- "type": "choice",
1086
- "acc": 0.7829,
1087
- "ece": 0.0479,
1088
- "brier": 0.31,
1089
- "nll": 0.7328,
1090
- "trained": false,
1091
- "sec": 2.9
1092
- },
1093
- "boolq": {
1094
- "n": 300,
1095
- "type": "noul",
1096
- "acc": 0.85,
1097
- "ece": 0.0213,
1098
- "brier": 0.2386,
1099
- "nll": 0.3897,
1100
- "trained": true,
1101
- "sec": 5.5
1102
- },
1103
- "squad2": {
1104
- "n": 299,
1105
- "type": "noul",
1106
- "acc": 0.7425,
1107
- "ece": 0.038,
1108
- "brier": 0.342,
1109
- "nll": 0.5098,
1110
- "trained": false,
1111
- "sec": 5.2
1112
- },
1113
- "paws": {
1114
- "n": 250,
1115
- "type": "noul",
1116
- "acc": 0.76,
1117
- "ece": 0.0636,
1118
- "brier": 0.3157,
1119
- "nll": 0.4813,
1120
- "trained": true,
1121
- "sec": 1.9
1122
- },
1123
- "multinli": {
1124
- "n": 299,
1125
- "type": "choice",
1126
- "acc": 0.8528,
1127
- "ece": 0.0371,
1128
- "brier": 0.2064,
1129
- "nll": 0.3756,
1130
- "trained": true,
1131
- "sec": 2.3
1132
- },
1133
- "civil_comments": {
1134
- "n": 300,
1135
- "type": "noul",
1136
- "acc": 0.87,
1137
- "ece": 0.0508,
1138
- "brier": 0.198,
1139
- "nll": 0.3286,
1140
- "trained": true,
1141
- "sec": 2.3
1142
- },
1143
- "aegis2": {
1144
- "n": 250,
1145
- "type": "noul",
1146
- "acc": 0.716,
1147
- "ece": 0.0566,
1148
- "brier": 0.3606,
1149
- "nll": 0.5352,
1150
- "trained": false,
1151
- "sec": 4.9
1152
- },
1153
- "helpsteer2": {
1154
- "n": 249,
1155
- "type": "score",
1156
- "acc": 0.4458,
1157
- "ece": 0.1666,
1158
- "brier": 0.6987,
1159
- "nll": 1.3297,
1160
- "trained": true,
1161
- "sec": 17.8,
1162
- "score_mae": 0.8578
1163
- },
1164
- "summeval-relevance": {
1165
- "n": 240,
1166
- "type": "score",
1167
- "acc": 0.2292,
1168
- "ece": 0.1768,
1169
- "brier": 0.7849,
1170
- "nll": 1.4652,
1171
- "trained": false,
1172
- "sec": 13.4,
1173
- "score_mae": 0.633
1174
- },
1175
- "summeval-consistency": {
1176
- "n": 144,
1177
- "type": "score",
1178
- "acc": 0.7569,
1179
- "ece": 0.3155,
1180
- "brier": 0.4993,
1181
- "nll": 0.9841,
1182
- "trained": false,
1183
- "sec": 8.2,
1184
- "score_mae": 1.0267
1185
- },
1186
- "pubmedqa": {
1187
- "n": 250,
1188
- "type": "choice",
1189
- "acc": 0.712,
1190
- "ece": 0.0543,
1191
- "brier": 0.4106,
1192
- "nll": 0.74,
1193
- "trained": true,
1194
- "sec": 2.1
1195
- },
1196
- "_macro": 0.7055,
1197
- "_micro": 0.7106,
1198
- "_macro_untrained": 0.6445
1199
- },
1200
- "jevbench_public": {
1201
- "easy": {
1202
- "n": 48,
1203
- "accuracy": 1.0,
1204
- "invalid": 0,
1205
- "ece": 0.015729166666666905,
1206
- "by_family": {
1207
- "extraction": {
1208
- "n": 12,
1209
- "accuracy": 1.0
1210
- },
1211
- "fact": {
1212
- "n": 12,
1213
- "accuracy": 1.0
1214
- },
1215
- "intent": {
1216
- "n": 12,
1217
- "accuracy": 1.0
1218
- },
1219
- "tool_selection": {
1220
- "n": 12,
1221
- "accuracy": 1.0
1222
- }
1223
- }
1224
- },
1225
- "standard": {
1226
- "n": 72,
1227
- "accuracy": 0.8888888888888888,
1228
- "invalid": 0,
1229
- "ece": 0.06715416666666665,
1230
- "by_family": {
1231
- "adequacy": {
1232
- "n": 12,
1233
- "accuracy": 0.6666666666666666
1234
- },
1235
- "extraction": {
1236
- "n": 12,
1237
- "accuracy": 1.0
1238
- },
1239
- "intent": {
1240
- "n": 12,
1241
- "accuracy": 1.0
1242
- },
1243
- "ordinal": {
1244
- "n": 12,
1245
- "accuracy": 1.0
1246
- },
1247
- "policy": {
1248
- "n": 12,
1249
- "accuracy": 0.8333333333333334
1250
- },
1251
- "routing": {
1252
- "n": 12,
1253
- "accuracy": 0.8333333333333334
1254
- }
1255
- }
1256
- },
1257
- "hard": {
1258
- "n": 111,
1259
- "accuracy": 0.5765765765765766,
1260
- "invalid": 0,
1261
- "ece": 0.17517657657657654,
1262
- "by_family": {
1263
- "adversarial": {
1264
- "n": 6,
1265
- "accuracy": 1.0
1266
- },
1267
- "ambiguous": {
1268
- "n": 7,
1269
- "accuracy": 0.5714285714285714
1270
- },
1271
- "judge_hard": {
1272
- "n": 17,
1273
- "accuracy": 0.5882352941176471
1274
- },
1275
- "long_policy": {
1276
- "n": 19,
1277
- "accuracy": 0.42105263157894735
1278
- },
1279
- "multi_hop": {
1280
- "n": 18,
1281
- "accuracy": 0.5555555555555556
1282
- },
1283
- "probability": {
1284
- "n": 10,
1285
- "accuracy": 0.7
1286
- },
1287
- "routing_hard": {
1288
- "n": 5,
1289
- "accuracy": 1.0
1290
- },
1291
- "temporal_numeric": {
1292
- "n": 15,
1293
- "accuracy": 0.2
1294
- },
1295
- "tradeoff": {
1296
- "n": 6,
1297
- "accuracy": 0.5
1298
- },
1299
- "trap": {
1300
- "n": 8,
1301
- "accuracy": 1.0
1302
- }
1303
- }
1304
- }
1305
- },
1306
- "references": {
1307
- "v10_2b": {
1308
- "reg_in": 0.8058,
1309
- "reg_out": 0.7549,
1310
- "reg_in_nll": 0.4736,
1311
- "reg_out_nll": 0.6224,
1312
- "reg_in_ece": 0.0378,
1313
- "reg_out_ece": 0.0837
1314
- }
1315
- },
1316
- "speed": "not measured again: the architecture and size equal the parent's, and the temperature map does not change the computation"
1317
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
model.safetensors CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:acaef2228b134dcdc20cad4ee79219482c927ec819aa3687b9b8a575c338817f
3
  size 3763692048
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1265b014ce710ef219eb137792705bc0e15a59f1c68460cf26394ae5416876e1
3
  size 3763692048
vision/chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is true %}
150
+ {{- '<think>\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n\n</think>\n\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
vision/config.json ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForConditionalGeneration"
4
+ ],
5
+ "dtype": "bfloat16",
6
+ "image_token_id": 248056,
7
+ "model_type": "qwen3_5",
8
+ "text_config": {
9
+ "attention_bias": false,
10
+ "attention_dropout": 0.0,
11
+ "attn_output_gate": true,
12
+ "bos_token_id": null,
13
+ "dtype": "bfloat16",
14
+ "eos_token_id": 248044,
15
+ "full_attention_interval": 4,
16
+ "head_dim": 256,
17
+ "hidden_act": "silu",
18
+ "hidden_size": 2048,
19
+ "initializer_range": 0.02,
20
+ "intermediate_size": 6144,
21
+ "layer_types": [
22
+ "linear_attention",
23
+ "linear_attention",
24
+ "linear_attention",
25
+ "full_attention",
26
+ "linear_attention",
27
+ "linear_attention",
28
+ "linear_attention",
29
+ "full_attention",
30
+ "linear_attention",
31
+ "linear_attention",
32
+ "linear_attention",
33
+ "full_attention",
34
+ "linear_attention",
35
+ "linear_attention",
36
+ "linear_attention",
37
+ "full_attention",
38
+ "linear_attention",
39
+ "linear_attention",
40
+ "linear_attention",
41
+ "full_attention",
42
+ "linear_attention",
43
+ "linear_attention",
44
+ "linear_attention",
45
+ "full_attention"
46
+ ],
47
+ "linear_conv_kernel_dim": 4,
48
+ "linear_key_head_dim": 128,
49
+ "linear_num_key_heads": 16,
50
+ "linear_num_value_heads": 16,
51
+ "linear_value_head_dim": 128,
52
+ "mamba_ssm_dtype": "float32",
53
+ "max_position_embeddings": 262144,
54
+ "mlp_only_layers": [],
55
+ "model_type": "qwen3_5_text",
56
+ "mtp_num_hidden_layers": 1,
57
+ "mtp_use_dedicated_embeddings": false,
58
+ "num_attention_heads": 8,
59
+ "num_hidden_layers": 24,
60
+ "num_key_value_heads": 2,
61
+ "pad_token_id": null,
62
+ "partial_rotary_factor": 0.25,
63
+ "rms_norm_eps": 1e-06,
64
+ "rope_parameters": {
65
+ "mrope_interleaved": true,
66
+ "mrope_section": [
67
+ 11,
68
+ 11,
69
+ 10
70
+ ],
71
+ "partial_rotary_factor": 0.25,
72
+ "rope_theta": 10000000,
73
+ "rope_type": "default"
74
+ },
75
+ "tie_word_embeddings": true,
76
+ "use_cache": true,
77
+ "vocab_size": 248320
78
+ },
79
+ "tie_word_embeddings": true,
80
+ "transformers_version": "5.17.0",
81
+ "use_cache": true,
82
+ "video_token_id": 248057,
83
+ "vision_config": {
84
+ "deepstack_visual_indexes": [],
85
+ "depth": 24,
86
+ "dtype": "bfloat16",
87
+ "hidden_act": "gelu_pytorch_tanh",
88
+ "hidden_size": 1024,
89
+ "in_channels": 3,
90
+ "initializer_range": 0.02,
91
+ "intermediate_size": 4096,
92
+ "model_type": "qwen3_5_vision",
93
+ "num_heads": 16,
94
+ "num_position_embeddings": 2304,
95
+ "out_hidden_size": 2048,
96
+ "patch_size": 16,
97
+ "rope_parameters": {
98
+ "rope_theta": 10000.0,
99
+ "rope_type": "axial"
100
+ },
101
+ "spatial_merge_size": 2,
102
+ "temporal_patch_size": 2
103
+ },
104
+ "vision_end_token_id": 248054,
105
+ "vision_start_token_id": 248053
106
+ }
vision/processor_config.json ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "image_processor": {
3
+ "do_convert_rgb": true,
4
+ "do_normalize": true,
5
+ "do_rescale": true,
6
+ "do_resize": true,
7
+ "image_mean": [
8
+ 0.5,
9
+ 0.5,
10
+ 0.5
11
+ ],
12
+ "image_processor_type": "Qwen2VLImageProcessor",
13
+ "image_std": [
14
+ 0.5,
15
+ 0.5,
16
+ 0.5
17
+ ],
18
+ "merge_size": 2,
19
+ "patch_size": 16,
20
+ "resample": 3,
21
+ "rescale_factor": 0.00392156862745098,
22
+ "size": {
23
+ "longest_edge": 16777216,
24
+ "shortest_edge": 65536
25
+ },
26
+ "temporal_patch_size": 2
27
+ },
28
+ "processor_class": "Qwen3VLProcessor",
29
+ "video_processor": {
30
+ "do_convert_rgb": true,
31
+ "do_normalize": true,
32
+ "do_rescale": true,
33
+ "do_resize": true,
34
+ "do_sample_frames": true,
35
+ "fps": 2,
36
+ "image_mean": [
37
+ 0.5,
38
+ 0.5,
39
+ 0.5
40
+ ],
41
+ "image_std": [
42
+ 0.5,
43
+ 0.5,
44
+ 0.5
45
+ ],
46
+ "max_frames": 768,
47
+ "max_video_tokens": 768,
48
+ "merge_size": 2,
49
+ "min_frames": 4,
50
+ "patch_size": 16,
51
+ "resample": 3,
52
+ "rescale_factor": 0.00392156862745098,
53
+ "return_metadata": false,
54
+ "size": {
55
+ "longest_edge": 234881024,
56
+ "shortest_edge": 4096
57
+ },
58
+ "temporal_patch_size": 2,
59
+ "video_processor_type": "Qwen3VLVideoProcessor"
60
+ }
61
+ }
vision/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523
3
+ size 19989325
vision/tokenizer_config.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|endoftext|>",
10
+ "errors": "replace",
11
+ "image_token": "<|image_pad|>",
12
+ "is_local": true,
13
+ "local_files_only": false,
14
+ "model_max_length": 262144,
15
+ "model_specific_special_tokens": {
16
+ "audio_bos_token": "<|audio_start|>",
17
+ "audio_eos_token": "<|audio_end|>",
18
+ "audio_token": "<|audio_pad|>",
19
+ "image_token": "<|image_pad|>",
20
+ "video_token": "<|video_pad|>",
21
+ "vision_bos_token": "<|vision_start|>",
22
+ "vision_eos_token": "<|vision_end|>"
23
+ },
24
+ "pad_token": "<|endoftext|>",
25
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
26
+ "split_special_tokens": false,
27
+ "tokenizer_class": "Qwen2Tokenizer",
28
+ "unk_token": null,
29
+ "video_token": "<|video_pad|>",
30
+ "vision_bos_token": "<|vision_start|>",
31
+ "vision_eos_token": "<|vision_end|>"
32
+ }
vision/visual.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bf7a6edb1196c9478f63736079aef71d4b0faa98c3c37fcc09f0239cc53d1c97
3
+ size 662866152