Text Generation
Transformers
Safetensors
Arabic
llama
arabic
reasoning
chain-of-thought
math
gsm8k
small-language-model
slm
sft
conversational
text-generation-inference
File size: 10,328 Bytes
867d0f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Nawah-Math-Reasoning β€” training code

Everything that produced the model in this repo: two dataset pipelines, the SFT trainer, the eval
harness, a `pass@k` diagnostic, and a GRPO implementation. Run top to bottom and you get the
release; run any single stage and it resumes from what is already on disk.

Paths below assume this directory is the working directory and `$P` is a Python with
`torch`, `transformers>=5.15`, `pyarrow` and `huggingface_hub`.

## 0. Two environments, and why

| env | for | pins |
|---|---|---|
| training / eval / Hub | `train_reasoning.py`, `eval_reasoning.py`, everything else | torch 2.9 + cu126, **transformers 5.15** |
| generation | `translate_gsm.py`, `synth_generate.py` | **vLLM 0.8.5.post1**, torch 2.6 + cu124, **transformers 4.51.3** |

They are not interchangeable. The model configs are transformers-v5 format (`rope_parameters`,
`dtype`, `tokenizer_class: TokenizersBackend`) and transformers 4.x cannot read them β€” but vLLM
0.8.5 breaks on transformers 5.x (`TokenizersBackend has no attribute
all_special_tokens_extended`), so the generation env must stay at 4.51.3. Do not `pip install -U`
in it.

vLLM 0.8.5 is itself a pin: every release from 0.20.2 up requires torch 2.11, which is a CUDA 13
build needing driver β‰₯ 580. On a CUDA 12.4 host that is a hard stop, and cu12 wheels do not rescue
it (`ImportError: libcudart.so.13`). 0.8.5 is the last cu124 release.

In transformers v5 `TrainingArguments` it is `eval_strategy` (not `evaluation_strategy`) and
`warmup_steps` (there is no `warmup_ratio`).

## 1. `gsm8k-reasoning-ar` β€” 142,969 machine-translated rows

```bash
$V translate_gsm.py 150000     # vLLM env; ~95 min on 1x A6000; resumable
$P build_dataset.py            # -> out_gsm/*.parquet + rejects.jsonl
$P push_dataset.py --repo <user>/gsm8k-reasoning-ar
```

`Ajhesh7/gsm8k-reasoning-SFT-datas` translated with `ByteDance-Seed/Seed-X-PPO-7B`. The trailing
language tag in the prompt is **mandatory**: `Translate the following English sentence into
Arabic:\n{text} <ar>`.

Only 150k of the source's 600,000 rows are translated, and the sample is stratified by question
pattern with a floor of 13 rows/pattern: the corpus expands from just **2,814** question patterns,
so translating all 600k is ~99% redundant.

**The reason `build_dataset.py` has a validator at all:** Seed-X silently corrupts arithmetic in
~0.6% of segments β€” `$13751` β†’ `13571`, `4 Γ— 44 = 176` β†’ `4 Γ— 46 = 176`. These read as fluent
Arabic and pass any fluency check. So every row is numeral-audited, asymmetrically: **reasoning
chains strictly** (every numeral must survive exactly), **questions leniently** (a value ≀ 12 may
be verbalised β€” `6 friends` β†’ `Ψ£Ψ΅Ψ―Ω‚Ψ§Ψ¦Ω‡Ψ§ Ψ§Ω„Ψ³ΨͺΨ©` β€” but any number the English never contained is
rejected). Naive exact-multiset matching rejects ~6% of *correct* translations; do not "simplify"
it back to that.

`translate_gsm.py` appends every chunk to `out_gsm/translations.jsonl` and skips cached segments on
restart, so an interruption costs at most one 20k chunk. Do not pass `enable_prefix_caching=True`
β€” it hangs at startup on this vLLM/V1 engine.

## 2. `arabic-math-reasoning-synth` β€” 120,462 verified rows

```bash
# general pool: 100,323 rows, ~22 h
GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth TARGET=100000 \
BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 \
nohup $V -u synth_generate.py > synth_run.log 2>&1 &

# relational pool: 20,139 rows, ~3.5 h. Disjoint task-id range, on purpose.
GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth_rel POOL=relational \
START_TASK=1000000 TARGET=20000 BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 SEED=1234 \
nohup $V -u synth_generate.py > synth_rel.log 2>&1 &

./finish_merge_push.sh          # merge primary cache + any node shards -> out_merged_v6/
$P split_synth_v6.py            # -> data_synth_v6_sft/{train,eval,eval_rel}.jsonl
$P push_synth_dataset.py --repo <user>/arabic-math-reasoning-synth
```

**Resumability is the design, not a feature.** A task is one generation call asking for 4 problems,
and task *N*'s prompt is a pure function of *N* (`synth_common.build_task`) β€” nothing about the
plan is persisted, so a restart redraws identical prompts. Finished tasks are appended to
`generations.jsonl` and fsynced; a torn final line is dropped with a warning. The stop condition is
**accepted rows, not tasks**.

**Raw completions are stored, never just the parsed rows.** Every validator change can be re-scored
over the whole cache with no GPU β€” which is how the accept rate went 48.4% β†’ 64.9% without
regenerating anything.

**The arithmetic audit** (`synth_common.validate`) re-evaluates every `a op b = c` in the reasoning
and one wrong equation rejects the row. Two things it must keep doing, both of which were bugs that
rejected *correct* rows:

1. **Equation chains.** Models write `75 + 15 Γ— 5 = 75 + 75 = 150`. Reading only to the first `=`
   compares 150 against 75 and rejects a correct chain. Split the whole chain, require every
   segment to agree.
2. **Rounding.** `3200 / 60 = 53.33` is arithmetic as people write it. `_close()` forgives rounding
   *at the precision the model displayed* (and floor/ceil for integers), so a genuinely wrong
   number still fails.

Also rejected: `noop_step` (`63 + 0 = 63`, padding to hit a step count), `meta_commentary`, Latin
residue, missing conclusion marker, and answers that disagree with the last computed value.

**The relational pool is a separate pool** (`RELATIONAL_OPS`), deliberately not appended to
`OPERATIONS`. Appending would change `rng.choice()` for every task id and silently break
reproducibility of the first 100,323 rows. `build_task(task_id, seed, pool)` takes
`pool="default"` or `"relational"`; keep it that way.

Check `unique_templates` in `build_stats.json` rather than assuming the variation grid worked.

## 3. SFT

```bash
$P prepare_data.py              # Arabic_Reasoning_Dataset -> data/{train,eval}.jsonl
$P prepare_gsm_sft.py           # -> data_gsm_sft/{train,eval}.jsonl
$P prepare_v6_sft.py            # three-way mix -> data_v6_sft/{train,eval}.jsonl

BASE_MODEL=<base> OUTPUT_DIR=./Nawah-Math-Reasoning \
TRAIN_FILE=data_v6_sft/train.jsonl EVAL_FILE=data_v6_sft/eval.jsonl \
MAX_LENGTH=768 EPOCHS=5 BATCH_SIZE=64 GRAD_ACCUM=1 WARMUP_STEPS=200 EVAL_STEPS=1000 LOAD_BEST=0 \
$P -u train_reasoning.py 2>&1 | tee train.log      # 21,535 steps, ~85 min on 1x A6000
```

**`LOAD_BEST=0` is load-bearing.** Eval loss selects the *worse* checkpoint on this ladder, and
that was measured rather than assumed: on a corpus with no repeated rows, the minimum-loss
checkpoint scored 30.9% where the final scored 35.6%. It held for four consecutive runs. Ship the
final checkpoint.

**Answer styles are not normalised.** GSM8K rows end in a bare numeral, the other two corpora in an
`Ψ₯Ψ°Ω†ΨŒ …` sentence. Rewriting them into one style deletes what the mix adds, so a `source` tag rides
on every row and eval scores each half on its own terms.

The eval splits are **pinned across versions** β€” the same 400 `Arabic_Reasoning` rows and the same
600 GSM8K rows since the first model, and `split_synth_v6.py` copies the previous synth eval rows
through verbatim rather than re-shuffling. Re-drawing would have moved 1,955 of 2,000 held-out
items into train.

## 4. Eval

```bash
EVAL_FILE=data_v6_sft/eval.jsonl        $P eval_reasoning.py ./Nawah-Math-Reasoning 1800
EVAL_FILE=data_synth_sft/eval.jsonl     $P eval_reasoning.py ./Nawah-Math-Reasoning 1000
```

Greedy. Reports well-formedness, **number agreement**, exact match and reasoning length, broken
down per `source` when the rows carry the tag. `EVAL_FILE` comes from the env β€” pointing it at the
wrong split silently scores against the wrong data.

Score on **number agreement**, not exact match: with two answer styles in the mix, exact match
measures style compliance, not arithmetic.

## 5. RL β€” run the diagnostic first

```bash
$P -u passk_diag.py ./Nawah-Math-Reasoning     # 240 problems x k=8 @ T=1.0, ~25 min
```

RLVR reweights samples the model already produces. A group where every sample is wrong scores
all-zero, the advantage is zero, and there is no gradient β€” so exploitable headroom is bounded by
`pass@k βˆ’ pass@1`. On the previous version that was **+27.1 points**, with 40.4% of problems never
solved in 8 tries. Run this before spending a GPU-hour.

That diagnostic is also what produced this release: the dead 40% turned out to be a *data* gap
(relational comparisons were 1.34% of the corpus), so the fix was Β§2's relational pool through
SFT, not RL. **Data first, then RL** β€” running RL first leaves the dead tail untouched and caps
the gain.

```bash
MODEL=./Nawah-Math-Reasoning OUTPUT_DIR=./Nawah-Math-Reasoning-grpo \
GROUP=8 PROMPTS_PER=8 STEPS=500 LR=1e-6 BETA=0.02 \
$P -u grpo_train.py 2>&1 | tee grpo.log
```

`grpo_train.py` is hand-rolled β€” TRL is not installable against these pins. Group-normalised
advantage with no value network, binary final-answer reward (not partial credit β€” the failure being
fixed is fluent reasoning landing on a wrong number), KL to a frozen reference via Schulman's k3
estimator, and no importance ratio or PPO clipping because sampling is on-policy with one step per
batch. Zero-spread groups are skipped **and counted**; the dead-group percentage in the log is the
number to watch.

## 6. Release

```bash
$P build_release_code.py                    # stage this directory
$P push_release.py --dry-run                # render the card only
$P push_release.py
```

Every number in the model card is read from an eval JSON on disk. Nothing is typed by hand, so the
card cannot drift from the measurements.

## Demo

`space/` is the Gradio demo, deployable as-is to a Space. Two things that break it if "cleaned up":
the streamer must use `skip_special_tokens=False` (`<think>`/`</think>` are real special tokens and
stripping them destroys the reasoning/answer split), and `render_prompt()` must stay byte-identical
to the trainer's rendering β€” a 52M model is very sensitive to format drift.

On ZeroGPU, `import spaces` must come **before** torch, and `model.to("cuda")` at startup must stay
unguarded: no GPU is attached at startup and the call is replayed inside the forked GPU process.