File size: 13,385 Bytes
a1ede48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb50150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cd1d1
 
 
 
 
 
 
 
 
 
 
 
 
 
a1ede48
 
 
 
eb50150
01cd1d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb50150
 
 
 
 
 
 
 
 
 
 
a1ede48
 
 
eb50150
 
 
 
 
 
01cd1d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb50150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1ede48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb50150
 
 
 
 
 
 
 
 
01cd1d1
 
 
eb50150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cd1d1
 
eb50150
 
 
 
 
 
01cd1d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb50150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
---
license: apache-2.0
base_model: Qwen/Qwen3-0.6B-Base
language:
- tr
- en
tags:
- text-classification
- decision-making
- triage
- moderation
- turkish
pipeline_tag: text-classification
---

![ARBITER banner](./arbiter-banner.svg)

[![held-out](https://img.shields.io/badge/held--out_macro-0.844-blue)](./README.md)
[![params](https://img.shields.io/badge/params-0.6B-violet)](./README.md)
[![langs](https://img.shields.io/badge/langs-TR_%2B_EN-yellow)](./README.md)
[![license](https://img.shields.io/badge/license-Apache--2.0-green)](./README.md)

# ARBITER-general — 0.6B single-pass decision layer

**State in, typed decisions out. One forward pass, zero generated tokens.**

ARBITER is a fine-tune of `Qwen/Qwen3-0.6B-Base` that answers structured questions —
`choice` (up to 52 options), `score` (ordered levels), `noul` (yes/no) — in a single
forward pass, in Turkish or English. It never generates text: option-letter logits
(`A`–`Z`, `a`–`z`) are read at a fixed `Answer: (` slot and soft-maxed into calibrated
probabilities with a confidence score.

```python
from arbiter import Arbiter

arb = Arbiter("Utiric/arbiter-general")  # a local directory path also works
out = arb.decide(
    state="My package never arrived, I want a refund.",
    questions=[
        {"id": "intent", "type": "choice",
         "instructions": "What does the customer want?",
         "options": {"Refund": "wants money back",
                     "Whereabouts": "asks where the package is",
                     "Cancel": "wants to cancel the subscription",
                     "Greeting": "just saying hello"}},
        {"id": "urgent", "type": "noul",
         "instructions": "Does this need immediate action?",
         "criteria": {"true": "angry, threatening, or time-critical",
                      "false": "routine request"}},
        {"id": "tone", "type": "score",
         "instructions": "Customer anger?",
         "levels": ["calm", "annoyed", "angry", "furious"]},
    ],
)
# {"intent": {"choice": "Refund", "probabilities": {...}, "confidence": 0.61},
#  "urgent": {"noul": 0.12, "confidence": 0.76, ...},
#  "tone": {"level": "2", "score": 2.1, ...}}
```

Turkish works the same way:

```python
out = arb.decide(
    state="Kargo iki günde geldi, ürün fotoğraftaki gibi. Teşekkürler!",
    questions=[{"id": "sentiment", "type": "choice",
                "instructions": "What is the overall sentiment polarity expressed in this text?",
                "options": {"Positive": "satisfaction, praise, thanks",
                            "Neutral": "factual objective information",
                            "Negative": "complaint, anger, disappointment"}}],
)
# {"sentiment": {"choice": "Positive", "probabilities": {...}}}
```

> **No pip package (yet).** Copy `arbiter.py` from this repo (~200 lines, only
> `torch` + `transformers`) next to your code. No training stack, no server, no API
> key needed. The file mirrors the exact training prompt format — do not rephrase
> the prompt template inside it.

## Requirements

- Python 3.10+, `torch`, `transformers`. That is the whole dependency list.
- Weights are 1.2 GB (bf16). Comfortable in ~2 GB of VRAM; CPU works too
  (float32, slower).
- No server, no API key, no Inference Providers deployment: you run `arbiter.py`
  wherever your code already runs.

Measured latency (T4 GPU, September 2026, single questions, temperature 1.6):

| Situation | Latency |
|---|---|
| first call (warmup) | ~1 s |
| steady state, per question | 60–90 ms |

Latency grows with prompt length (prefill cost) and is flat in the number of
questions per pass: asking 6 questions at once costs barely more than asking 1.

---

## How it works

![How it works](./arbiter-how.svg)

1. State and question are rendered into one prompt wrapped in `<state>` tags with
   `Question (choice|yes/no|score):` labels and `(A) option — description` lines,
   ending in `Answer: (`.
2. The model runs **once**. The hidden state at the slot position is projected
   through the LM-head rows of the 52 letter tokens only.
3. Softmax over the valid options gives the distribution. Temperature is a **single
   global value (1.6)**, fit post-hoc on held-out data and shipped in `s1_config.json`
   — not per-task, not per-slice. `confidence` = top-1 minus top-2 probability gap.
4. `noul` returns P(yes); `score` returns the argmax level plus the expectation
   over levels.

Latency scales with prompt length only (prefill cost) — there is no per-token
generation loop.

## Getting good answers (read this before prompting)

ARBITER is prompt-format sensitive: it answers from the exact prompt shape it
trained on. Three habits decide whether you get 0.88 or 0.37:

1. **Frame the state like training data.** A short domain prefix plus the raw
   text works far better than a bare sentence, e.g.
   `[SUPPORT_TIER: return_desk] | Ticket #43998 Customer purchased a wireless
   headphones 15 days ago. Reason: 'item defective on arrival'...`
   We measured this directly on these weights: 8 hand-written free-form probes
   scored 3/8, while 96 verbatim held-out rows in training format scored 85/96.
2. **Describe every option.** `{"Refund": "wants money back"}` beats
   `["Refund", "Whereabouts", ...]`. The description is the signal; the name is
   just a key.
3. **Gate on confidence.** `confidence` is the top-1 minus top-2 probability gap:

```python
a = out["intent"]
if a["confidence"] < 0.2:
    escalate(state)      # human review or a stronger judge
else:
    route(a["choice"])
```

## When to use it (and when not)

Good fit: support-ticket triage and routing, Discord/forum moderation
pre-filtering, dialogue turn-taking, code-review triage, sentiment buckets —
any pipeline step shaped "text in, one of N labels out" in Turkish or English.

Not a fit: arithmetic or reasoning (`2 + 2` with options `[3, 4, 5, 22]`
picks `22` — it matches strings, it does not compute), free-text generation,
standalone medical or legal decisions, more than 52 options in one question,
languages beyond TR/EN.

## API reference

`Arbiter(path, device=None, temperature=None, max_state_tokens=2048)`

| Argument | Meaning |
|---|---|
| `path` | HF repo id or local directory with weights |
| `device` | `"cuda"` / `"cpu"` (auto-detected by default) |
| `temperature` | softmax temperature; default read from `s1_config.json` (1.6) |
| `max_state_tokens` | state truncation budget (70% head / 30% tail, marked) |

`decide(state, questions)` — `state` is a string (or JSON-serializable object).
Each question needs `id`, `type` (`choice` \| `score` \| `noul`), and:

| Type | Fields | Returns |
|---|---|---|
| `choice` | `instructions`, `options` (list or `{name: description}`) | `choice`, `probabilities`, `confidence` |
| `score` | `instructions`, `levels` (ordered list) | `level`, `score` (expectation), `confidence`, `probabilities` |
| `noul` | `instructions`, optional `criteria: {true, false}` | `noul` = P(yes), `confidence`, `probabilities` |

Budgets: **2048** state tokens · **52** options per pass · Turkish + English tested.

---

## Evaluation — held-out (8 tasks × 500, full eval, temperature 1.6)

Held-out rows never appeared in training (exact-dedup isolated). Macro **0.8438** vs chance 0.2958.

| Slice | acc | ECE | Note |
|---|---|---|---|
| code_pr_review | 1.000 | 0.053 | |
| customer_action | 0.796 | 0.073 | weakest link, under active work |
| dialogue_turn_taking | 0.992 | 0.014 | |
| irony_sarcasm | 0.620 | 0.117 | noisy slice — do not use standalone |
| legal_contract_risk | 0.914 | 0.025 | routing signal, not legal advice |
| medical_triage | 0.778 | 0.112 | routing signal, never autonomous diagnosis |
| moderation_discord | 0.704 | 0.084 | tier-1 pre-filter; escalate on low confidence |
| multilingual_sentiment | 0.946 | 0.028 | |
| **macro** | **0.8438** | 0.063 | Brier 0.220 |

## Evaluation — public zero-shot (0-shot, slot-logit scoring, no generation)

| Benchmark (split, n) | ARBITER | Random / Majority |
|---|---|---|
| Belebele tur_Latn (900) / eng_Latn (900) | 0.284 / 0.340 | 0.25 / 0.28 |
| MMLU 6-subset avg (1,425) | 0.373 | 0.25 / 0.32 |
| Banking77 sample-500 | 0.035 | 0.013 |
| OffensEval-TR (3,528) | 0.797 | 0.50 / 0.80 |
| TweetEval offensive+hate (3,830) | 0.632 | 0.50 / 0.61 |
| XCOPA-TR (500) | 0.486 | 0.50 |
| XNLI-TR (1k sample) | 0.407 | 0.33 / 0.35 |

Read honestly: strong on trained domains, modest zero-shot — reported with random and
majority baselines everywhere, as it should be. XCOPA is chance-level; Banking77's
77-way zero-shot is 3× random but low in absolute terms. Treat these as a floor for
fine-tuning, not a ceiling.

## Evaluation — permutation consistency (position-bias check)

Same question, three different option orders (50 held-out rows × 8 slices, seeded
shuffles); a content-driven model must pick the same option every time.

| Slice | consistent |
|---|---|
| code_pr_review | 1.000 |
| customer_action | 0.980 |
| dialogue_turn_taking | 0.940 |
| irony_sarcasm | 0.880 |
| legal_contract_risk | 0.980 |
| medical_triage | 0.960 |
| moderation_discord | 0.900 |
| multilingual_sentiment | 0.960 |
| **overall (400 rows)** | **0.950** |

Position bias is dead: option-shuffle training (uniform Check 4 + per-epoch shuffle)
holds, weakest on the already-noisy irony slice. Nothing above is "not yet measured"
anymore.

## Training journey (held-out macro)

| Stage | macro | What changed |
|---|---|---|
| v1 SFT | 0.497 | baseline; overfit on 1.5 epochs |
| v2 + teacher relabel | 0.768 | 26k teacher-written descriptions |
| v3 + moderation data | 0.787 | moderation slice relabeled |
| v4 cherry-pick | 0.811 | kept gains, restored casualties |
| DPO-1 | 0.822 | 16k preference pairs |
| v5–v7 + new domains | 0.818 | legal/medical/multilingual siblings, irony-train contradiction found and dropped |
| v8 SFT | 0.840 | customer siblings (+19 pts on customer); moderation dipped 0.750 → 0.626 |
| **DPO-4 (this release)** | **0.844** | preference pairs recovered moderation to 0.704, kept the rest |

Recipe: full fine-tune of the 0.6B backbone, CE + 0.3·Brier loss, 1 epoch,
held-out-Brier early stopping, then Decision-DPO (β=1.0 + 0.1 CE anchor, LR 1e-5).

## Data card (summary)

- ~130k training rows across 26 tasks; ~26k teacher-(re)labeled rows.
- The teacher model (Ministral-14B) writes **descriptions only**; gold labels are
  deterministic (rules, dataset labels, star ratings). Teacher-written golds proved
  unreliable on irony and were reverted to dataset labels there.
- Gates on every iteration: exact-dedup train↔held-out, test-set contamination scan
  (**0 overlaps** across 11.4k public-benchmark rows), option-name leakage scan on
  teacher-written descriptions.

## Limitations (please read before deploying)

- A classifier head — not a reasoner, generator, or judge. No free text, ever.
- No arithmetic or multi-step reasoning. `2 + 2` with options `[3, 4, 5, 22]`
  answers `22`: it matches surface strings, it does not compute.
- Medical and legal outputs are triage signals for pipelines **with humans in the loop**.
- Moderation output is a tier-1 signal; escalate to a stronger judge and a human
  on low confidence.
- Turkish + English only. Customer-action and irony are the weakest slices.
- Not hardened against adversarial prompt injection.

## FAQ

**Why is temperature 1.6, above 1.0?**
Decision-DPO sharpens the distributions (expected side effect). 1.6 is a single
global value fit post-hoc on held-out data to re-calibrate them — not per-task,
not per-slice. If you fine-tune or DPO further, refit it.

**What does `confidence` mean?**
Top-1 probability minus top-2. A 0.90 prediction with a 0.08 runner-up
(gap 0.82) is a sure thing; 0.45 vs 0.44 (gap 0.01) is a coin flip. Threshold it.

**More than 52 options?**
Chunk them: score 40-option windows with a `none of the above` catcher, then
re-score the finalists in one pass. `arbiter.py` covers the single-pass case
(≤52); port the chunking from `s1/engine.py` for the rest.

**Several questions at once?**
Yes — pass a list. Every question gets its own answer slot in one forward pass
(the quickstart answers intent + urgency + tone together).

**Can I fine-tune it?**
It is a stock Qwen3-0.6B-Base fine-tune (full, not LoRA), so any HF trainer
works. Keep option shuffling on and early-stop on held-out Brier, not train
loss — 1.5 epochs already overfit this recipe once
(v1: 0.94 in-task, 0.50 held-out).

**Why no Inference Providers / widget?**
The hosted inference API generates text; this model never generates — it is read
at a logit slot. A widget would return nonsense, so the model is deliberately
left undeployed there. Run `arbiter.py` locally instead.

## Files

| File | What |
|---|---|
| `model.safetensors` (+ `config.json`, `tokenizer.*`, `s1_config.json`) | weights, Qwen3-0.6B-Base format |
| `arbiter.py` | standalone inference client — copy it, nothing to install |
| `arbiter-banner.svg`, `arbiter-how.svg` | release artwork |
| `README.md` | this card |

## License & citation

Apache-2.0 (base-model license carried over).

```bibtex
@misc{arbiter2026,
  title  = {ARBITER-general: a 0.6B single-pass decision layer for TR+EN},
  author = {Utiric},
  year   = {2026},
  note   = {Held-out macro 0.844 across 8 tasks; Qwen3-0.6B-Base fine-tune + Decision-DPO}
}
```