Measured
200 held-out decisions, greedy decoding. The validation split is divided by match, so these
are boards from games the model never saw.
Table with columns: base, epoch 1, epoch 2 (this) | base | epoch 1 | epoch 2 (this) |
|---|
| emitted a parseable call | 100% | 100% | 100% |
| action was legal | 100% | 100% | 100% |
| agreed with the teacher | 23.5% | 75.0% | 84.0% |
Against the dataset card's published prompting-only baseline of 36.3% (40.1% with hints).
Per action type, with counts, because the training mix is 36% move and 23% end turn and a
model that collapsed onto those two would still score respectably overall:
Table with columns: action, n, base, epoch 1, epoch 2| action | n | base | epoch 1 | epoch 2 |
|---|
| move | 71 | 15.5% | 60.6% | 74.6% |
| end turn | 44 | 20.5% | 100% | 100% |
| card | 26 | 11.5% | 92.3% | 92.3% |
|
The rush result is the point. The dataset card reports a prompted model taking rush 0 of
1,258 times offered; the untuned base here reproduces that exactly at 0/21. Both tuned
checkpoints break it.
Epoch 1 is included in epoch1-alternate/ because the two checkpoints trade differently.
Epoch 2 wins overall and fixes an attack regression that epoch 1 introduced (36.4% → 95.5%), but
epoch 1 is markedly better at rush (81.0% vs 52.4%). If rush is the behaviour you care about,
use epoch 1.
Sample sizes are small per category. rush is 21 decisions, reload 7, tier 8. The
epoch-1-vs-2 rush gap is 17/21 against 11/21 — suggestive, not settled. A full 841-row run was
attempted and is not reflected here; treat the per-action numbers as directional.
Legality was never the problem. The base model already emitted a legal action_id on 200/200
decisions. This adapter does not teach valid syntax — it changes which legal action gets chosen.
Usage
Serve with llama.cpp, adapter GGUF included:
llama-server -m Qwen3VL-4B-abliterated-f16.gguf \
--lora-scaled raifu-warrior-lora-f16.gguf:1.0 \
-ngl 99 -c 32768 --jinja -fa on --port 8081 --host 0.0.0.0
Then POST the Warrior payload unchanged — system prompt, board, and the per-row tools schema
whose action_id enum is that turn's legal set:
requests.post("http://localhost:8081/v1/chat/completions", json={
"messages": msgs, "tools": row["tools"], "temperature": 0, "max_tokens": 128,
})
Or with peft:
model = AutoModelForImageTextToText.from_pretrained(
"huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated", dtype=torch.bfloat16, device_map="auto")
model = PeftModel.from_pretrained(model, "yotisstudios/RaifuWars-Warrior-Qwen3VL-4B-LoRA")
Note AutoModelForImageTextToText, not AutoModelForCausalLM — this is a
Qwen3VLForConditionalGeneration.
Vision is untouched
The base is a vision-language model and the training data is text-only. The seven targeted
projections match 252 language modules and 0 vision modules — the ViT uses qkv,
linear_fc1, linear_fc2. The resulting adapter has 504 tensors, none of them vision, all
under model.language_model.layers.*. Image understanding is bit-identical to the base, by
construction rather than by care.
Training
Table | |
|---|
| base | huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated (4.44B) |
| method | SFT, LoRA r=32 α=64 dropout 0.05 |
| data | 9,030 train / 841 val, 31.3M tokens |
| schedule | 2 epochs, 2,258 steps, lr 1e-4 cosine, warmup 0.05 |
| batch | 1 × grad-accum 8, max_length 8192 |
| hardware | one RTX A6000, 16.4 GB peak, 9h51m |
| trainer | grimoire |
Loss 0.742 → 0.017; held-out eval loss 0.021, below train throughout. Low absolute loss is
expected and not very informative here — the target is one short action_id from a constrained
enum, so most tokens are trivially predictable. Agreement is the metric that distinguishes
learning from format-fitting.
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. Hand-writing
<tool_call> blocks would risk a train/serve mismatch that nothing reports. (The dataset's
content: null on tool-calling assistant turns must be replaced with "" first, or Qwen3-VL's
jinja raises TypeError: 'NoneType' object is not iterable.)
Over-length rows are dropped, never truncated. The trainer truncates long prompts from the
right, which here would cut off the legal-action list and the closing instruction — training the
model to name an action_id it was never shown. 20 of 9,050 rows exceeded 8192 tokens and were
dropped. A 4096 cap would have dropped 2,607; a 2048 cap would have dropped every row.
build_dataset.py, train.py and eval_actions.py are included.
Limitations
Inherited from the data, and they are real:
- One map. Every training row is Arboretum. Tile preferences learned here may not transfer,
and validation cannot detect that because it shares the map.
- 40 seeds is the true sample size, not 9,030. Decisions within a match are strongly
correlated.
- The teacher is a heuristic, not optimal. This is imitation of a decent player. 84% agreement
is 84% agreement with that, and the ceiling is its skill.
- Agreement is not win rate. Nothing here measures whether the model wins games. The
prompting baseline reached 40.1% agreement and won zero of 40 matches, which is exactly why
agreement alone should not be trusted as a proxy. Run it in a real match.
last_action is absent. At inference the sidecar appends a line describing how the previous
action resolved mid-turn; the traces do not contain it, so the model never saw it in training.
A known train/serve mismatch, inherited and left explicit.
Licence
Apache-2.0, matching the base model. Note the training data is GPL-3.0 — whether a dataset
licence reaches model weights is unsettled, and this repo takes the position that it does not.
Users with a different view should treat the weights as GPL-3.0 derived.