The fix
tier up is the only win condition — reach tier 4 and the match ends. The experimental dataset
contained 11 examples of it. A model trained on that corpus matched the teacher on rush,
reload, attack and cards, then declined tier-up 137 times out of 145 offered: a recognisable
game, played by something that would not climb the ladder that ends it.
v2 contains 3,069 tier-up examples, and every one of its 280 matches contains its decisive
tier-up.
Table with columns: action, n, v1 model, v2 model| action | n | v1 model | v2 model |
|---|
| tier up | 57 | declined 137/145 | 100% |
| end turn | 333 | 99.5% | 100% |
| tier choice | 54 | 100% | 100% |
| rush | 194 | 45.5% | 96.4% |
| attack | 132 | 98.9% | 96.2% |
| reload | 41 | 92.3% | 92.7% |
| card | 122 | 93.6% | 89.3% |
| move | 559 | 77.1% | 80.9% |
select tile (pick) | 8 | 40% | 25.0% |
| overall agreement | 1500 | 85.1% | 90.6% |
| legal action emitted | 1500 | 100% | 100% |
1,500 held-out decisions, greedy decoding, from matches the model never saw (the split is by match,
not by row). Against the dataset's published prompting-only baseline of 36.3%.
select tile is the one regression and the one weak spot: 2 of 8. It is 0.3% of the training data
(262 rows in the full corpus, ~79 in the sample used here), and eight decisions cannot separate a
real weakness from noise. Treat it as unmeasured rather than as 25%.
Read tier up and rush as the result, not the overall number. Move only improved 77 → 82%;
it was never broken. The gain is concentrated in exactly the two actions v1 refused to take, which
is the signature of a data fix rather than a training one.
A note on how v1 was mis-measured
The v1 evaluation bucketed actions by name prefix, so tier (tier-up, the win condition) was
averaged together with tier_stars and tier_kills (tier choice, which track to climb). It
reported 32/32 = 100% while the model was declining the winning move 137 times out of 145.
The metric was not wrong arithmetic — it was a bucketing that could not see the failure. v2's data
separates the three action ids, and eval_actions.py in this repo reports tier-up on its own line,
labelled as the only win condition. If you build your own eval, do the same.
Honest limits
- Agreement is not win rate. This is imitation of the built-in "classic" AI, which beats a
random-legal control 83–17 but is a hand-written heuristic, not a solved policy. 90.6% means
"plays like it 90.6% of the time". The dataset card notes a prompting baseline that reached 40.1%
agreement and won zero of 40 matches — which is exactly why agreement should not be trusted
as a proxy. Run it in a real match.
- Per-action samples vary. tier-up is n=57 and reload n=41, which are meaningful;
pick is
n=8, which is not.
last_action is absent. At inference the sidecar appends a line describing how the previous
action resolved mid-turn; traces do not record it, so the model never saw it in training. A known
train/serve mismatch, inherited from the data.
Use
llama.cpp, adapter GGUF included:
llama-server -m Qwen3-VL-4B-abliterated-f16.gguf \
--lora-scaled raifu-v2-lora-f16.gguf:1.0 \
-ngl 99 -c 32768 --jinja -fa on --port 8090 --host 0.0.0.0
A pre-merged, quantised model is at
RaifuWars-Warrior-Qwen3VL-4B-v2
if you would rather not carry the adapter.
Post the Warrior payload unchanged — system prompt, board, and the per-turn tools schema whose
action_id enum is that turn's legal set:
requests.post(".../v1/chat/completions", json={
"messages": msgs, "tools": row["tools"], "temperature": 0, "max_tokens": 128})
With peft, load through AutoModelForImageTextToText — this is a
Qwen3VLForConditionalGeneration, not a plain causal LM.
Training
Table | |
|---|
| base | huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated (4.44B) |
| method | SFT, LoRA r=32 α=64 dropout 0.05 |
| data | 30,000 rows sampled from 69,502 train (87M tokens) |
| schedule | 1 epoch, 3,750 steps, lr 1e-4 cosine, warmup 0.05 |
| batch | 1 × grad-accum 8, max_length 8192 |
| hardware | one RTX A6000, 15h53m |
| trainer | grimoire |
Train loss 0.96 → 0.025; held-out eval 0.974 → 0.0095, below train loss at every checkpoint —
no memorisation despite 7 maps.
The 30,000 rows are a uniform random sample of the full train split, preserving the action mix
(37.4% move, 3.9% tier-up) and all seven maps proportionally. A full epoch is 238M tokens ≈ 38h;
this was the overnight version.
Two build details that matter:
The target text is derived, not written. The conversation is rendered twice through the chat
template — once without the assistant turn, once with — and the difference is the training target.
Whatever the template emits for a tool call is what the model learns to emit. (content: null on
the assistant turn must become "" first, or the template raises TypeError.)
Over-length rows are dropped, never truncated. The trainer truncates long prompts from the
right, which would remove the legal-action list the answer must be copied from — training the model
to name an action_id it was never shown. 176 of 69,678 rows exceed 8192 tokens and are dropped; a
4096 cap would have dropped 18,285.
Vision is untouched. The seven targeted projections match 252 language modules and zero vision
modules, so image understanding is bit-identical to the base.