Text Generation
Transformers
Safetensors
Arabic
llama
arabic
reasoning
chain-of-thought
math
gsm8k
small-language-model
slm
sft
conversational
text-generation-inference
oddadmix commited on
Commit
867d0f3
·
verified ·
1 Parent(s): 7b44664

training code: data generation, SFT, eval, GRPO

Browse files
code/README.md ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nawah-Math-Reasoning — training code
2
+
3
+ Everything that produced the model in this repo: two dataset pipelines, the SFT trainer, the eval
4
+ harness, a `pass@k` diagnostic, and a GRPO implementation. Run top to bottom and you get the
5
+ release; run any single stage and it resumes from what is already on disk.
6
+
7
+ Paths below assume this directory is the working directory and `$P` is a Python with
8
+ `torch`, `transformers>=5.15`, `pyarrow` and `huggingface_hub`.
9
+
10
+ ## 0. Two environments, and why
11
+
12
+ | env | for | pins |
13
+ |---|---|---|
14
+ | training / eval / Hub | `train_reasoning.py`, `eval_reasoning.py`, everything else | torch 2.9 + cu126, **transformers 5.15** |
15
+ | generation | `translate_gsm.py`, `synth_generate.py` | **vLLM 0.8.5.post1**, torch 2.6 + cu124, **transformers 4.51.3** |
16
+
17
+ They are not interchangeable. The model configs are transformers-v5 format (`rope_parameters`,
18
+ `dtype`, `tokenizer_class: TokenizersBackend`) and transformers 4.x cannot read them — but vLLM
19
+ 0.8.5 breaks on transformers 5.x (`TokenizersBackend has no attribute
20
+ all_special_tokens_extended`), so the generation env must stay at 4.51.3. Do not `pip install -U`
21
+ in it.
22
+
23
+ vLLM 0.8.5 is itself a pin: every release from 0.20.2 up requires torch 2.11, which is a CUDA 13
24
+ build needing driver ≥ 580. On a CUDA 12.4 host that is a hard stop, and cu12 wheels do not rescue
25
+ it (`ImportError: libcudart.so.13`). 0.8.5 is the last cu124 release.
26
+
27
+ In transformers v5 `TrainingArguments` it is `eval_strategy` (not `evaluation_strategy`) and
28
+ `warmup_steps` (there is no `warmup_ratio`).
29
+
30
+ ## 1. `gsm8k-reasoning-ar` — 142,969 machine-translated rows
31
+
32
+ ```bash
33
+ $V translate_gsm.py 150000 # vLLM env; ~95 min on 1x A6000; resumable
34
+ $P build_dataset.py # -> out_gsm/*.parquet + rejects.jsonl
35
+ $P push_dataset.py --repo <user>/gsm8k-reasoning-ar
36
+ ```
37
+
38
+ `Ajhesh7/gsm8k-reasoning-SFT-datas` translated with `ByteDance-Seed/Seed-X-PPO-7B`. The trailing
39
+ language tag in the prompt is **mandatory**: `Translate the following English sentence into
40
+ Arabic:\n{text} <ar>`.
41
+
42
+ Only 150k of the source's 600,000 rows are translated, and the sample is stratified by question
43
+ pattern with a floor of 13 rows/pattern: the corpus expands from just **2,814** question patterns,
44
+ so translating all 600k is ~99% redundant.
45
+
46
+ **The reason `build_dataset.py` has a validator at all:** Seed-X silently corrupts arithmetic in
47
+ ~0.6% of segments — `$13751` → `13571`, `4 × 44 = 176` → `4 × 46 = 176`. These read as fluent
48
+ Arabic and pass any fluency check. So every row is numeral-audited, asymmetrically: **reasoning
49
+ chains strictly** (every numeral must survive exactly), **questions leniently** (a value ≤ 12 may
50
+ be verbalised — `6 friends` → `أصدقائها الستة` — but any number the English never contained is
51
+ rejected). Naive exact-multiset matching rejects ~6% of *correct* translations; do not "simplify"
52
+ it back to that.
53
+
54
+ `translate_gsm.py` appends every chunk to `out_gsm/translations.jsonl` and skips cached segments on
55
+ restart, so an interruption costs at most one 20k chunk. Do not pass `enable_prefix_caching=True`
56
+ — it hangs at startup on this vLLM/V1 engine.
57
+
58
+ ## 2. `arabic-math-reasoning-synth` — 120,462 verified rows
59
+
60
+ ```bash
61
+ # general pool: 100,323 rows, ~22 h
62
+ GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth TARGET=100000 \
63
+ BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 \
64
+ nohup $V -u synth_generate.py > synth_run.log 2>&1 &
65
+
66
+ # relational pool: 20,139 rows, ~3.5 h. Disjoint task-id range, on purpose.
67
+ GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth_rel POOL=relational \
68
+ START_TASK=1000000 TARGET=20000 BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 SEED=1234 \
69
+ nohup $V -u synth_generate.py > synth_rel.log 2>&1 &
70
+
71
+ ./finish_merge_push.sh # merge primary cache + any node shards -> out_merged_v6/
72
+ $P split_synth_v6.py # -> data_synth_v6_sft/{train,eval,eval_rel}.jsonl
73
+ $P push_synth_dataset.py --repo <user>/arabic-math-reasoning-synth
74
+ ```
75
+
76
+ **Resumability is the design, not a feature.** A task is one generation call asking for 4 problems,
77
+ and task *N*'s prompt is a pure function of *N* (`synth_common.build_task`) — nothing about the
78
+ plan is persisted, so a restart redraws identical prompts. Finished tasks are appended to
79
+ `generations.jsonl` and fsynced; a torn final line is dropped with a warning. The stop condition is
80
+ **accepted rows, not tasks**.
81
+
82
+ **Raw completions are stored, never just the parsed rows.** Every validator change can be re-scored
83
+ over the whole cache with no GPU — which is how the accept rate went 48.4% → 64.9% without
84
+ regenerating anything.
85
+
86
+ **The arithmetic audit** (`synth_common.validate`) re-evaluates every `a op b = c` in the reasoning
87
+ and one wrong equation rejects the row. Two things it must keep doing, both of which were bugs that
88
+ rejected *correct* rows:
89
+
90
+ 1. **Equation chains.** Models write `75 + 15 × 5 = 75 + 75 = 150`. Reading only to the first `=`
91
+ compares 150 against 75 and rejects a correct chain. Split the whole chain, require every
92
+ segment to agree.
93
+ 2. **Rounding.** `3200 / 60 = 53.33` is arithmetic as people write it. `_close()` forgives rounding
94
+ *at the precision the model displayed* (and floor/ceil for integers), so a genuinely wrong
95
+ number still fails.
96
+
97
+ Also rejected: `noop_step` (`63 + 0 = 63`, padding to hit a step count), `meta_commentary`, Latin
98
+ residue, missing conclusion marker, and answers that disagree with the last computed value.
99
+
100
+ **The relational pool is a separate pool** (`RELATIONAL_OPS`), deliberately not appended to
101
+ `OPERATIONS`. Appending would change `rng.choice()` for every task id and silently break
102
+ reproducibility of the first 100,323 rows. `build_task(task_id, seed, pool)` takes
103
+ `pool="default"` or `"relational"`; keep it that way.
104
+
105
+ Check `unique_templates` in `build_stats.json` rather than assuming the variation grid worked.
106
+
107
+ ## 3. SFT
108
+
109
+ ```bash
110
+ $P prepare_data.py # Arabic_Reasoning_Dataset -> data/{train,eval}.jsonl
111
+ $P prepare_gsm_sft.py # -> data_gsm_sft/{train,eval}.jsonl
112
+ $P prepare_v6_sft.py # three-way mix -> data_v6_sft/{train,eval}.jsonl
113
+
114
+ BASE_MODEL=<base> OUTPUT_DIR=./Nawah-Math-Reasoning \
115
+ TRAIN_FILE=data_v6_sft/train.jsonl EVAL_FILE=data_v6_sft/eval.jsonl \
116
+ MAX_LENGTH=768 EPOCHS=5 BATCH_SIZE=64 GRAD_ACCUM=1 WARMUP_STEPS=200 EVAL_STEPS=1000 LOAD_BEST=0 \
117
+ $P -u train_reasoning.py 2>&1 | tee train.log # 21,535 steps, ~85 min on 1x A6000
118
+ ```
119
+
120
+ **`LOAD_BEST=0` is load-bearing.** Eval loss selects the *worse* checkpoint on this ladder, and
121
+ that was measured rather than assumed: on a corpus with no repeated rows, the minimum-loss
122
+ checkpoint scored 30.9% where the final scored 35.6%. It held for four consecutive runs. Ship the
123
+ final checkpoint.
124
+
125
+ **Answer styles are not normalised.** GSM8K rows end in a bare numeral, the other two corpora in an
126
+ `إذن، …` sentence. Rewriting them into one style deletes what the mix adds, so a `source` tag rides
127
+ on every row and eval scores each half on its own terms.
128
+
129
+ The eval splits are **pinned across versions** — the same 400 `Arabic_Reasoning` rows and the same
130
+ 600 GSM8K rows since the first model, and `split_synth_v6.py` copies the previous synth eval rows
131
+ through verbatim rather than re-shuffling. Re-drawing would have moved 1,955 of 2,000 held-out
132
+ items into train.
133
+
134
+ ## 4. Eval
135
+
136
+ ```bash
137
+ EVAL_FILE=data_v6_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1800
138
+ EVAL_FILE=data_synth_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1000
139
+ ```
140
+
141
+ Greedy. Reports well-formedness, **number agreement**, exact match and reasoning length, broken
142
+ down per `source` when the rows carry the tag. `EVAL_FILE` comes from the env — pointing it at the
143
+ wrong split silently scores against the wrong data.
144
+
145
+ Score on **number agreement**, not exact match: with two answer styles in the mix, exact match
146
+ measures style compliance, not arithmetic.
147
+
148
+ ## 5. RL — run the diagnostic first
149
+
150
+ ```bash
151
+ $P -u passk_diag.py ./Nawah-Math-Reasoning # 240 problems x k=8 @ T=1.0, ~25 min
152
+ ```
153
+
154
+ RLVR reweights samples the model already produces. A group where every sample is wrong scores
155
+ all-zero, the advantage is zero, and there is no gradient — so exploitable headroom is bounded by
156
+ `pass@k − pass@1`. On the previous version that was **+27.1 points**, with 40.4% of problems never
157
+ solved in 8 tries. Run this before spending a GPU-hour.
158
+
159
+ That diagnostic is also what produced this release: the dead 40% turned out to be a *data* gap
160
+ (relational comparisons were 1.34% of the corpus), so the fix was §2's relational pool through
161
+ SFT, not RL. **Data first, then RL** — running RL first leaves the dead tail untouched and caps
162
+ the gain.
163
+
164
+ ```bash
165
+ MODEL=./Nawah-Math-Reasoning OUTPUT_DIR=./Nawah-Math-Reasoning-grpo \
166
+ GROUP=8 PROMPTS_PER=8 STEPS=500 LR=1e-6 BETA=0.02 \
167
+ $P -u grpo_train.py 2>&1 | tee grpo.log
168
+ ```
169
+
170
+ `grpo_train.py` is hand-rolled — TRL is not installable against these pins. Group-normalised
171
+ advantage with no value network, binary final-answer reward (not partial credit — the failure being
172
+ fixed is fluent reasoning landing on a wrong number), KL to a frozen reference via Schulman's k3
173
+ estimator, and no importance ratio or PPO clipping because sampling is on-policy with one step per
174
+ batch. Zero-spread groups are skipped **and counted**; the dead-group percentage in the log is the
175
+ number to watch.
176
+
177
+ ## 6. Release
178
+
179
+ ```bash
180
+ $P build_release_code.py # stage this directory
181
+ $P push_release.py --dry-run # render the card only
182
+ $P push_release.py
183
+ ```
184
+
185
+ Every number in the model card is read from an eval JSON on disk. Nothing is typed by hand, so the
186
+ card cannot drift from the measurements.
187
+
188
+ ## Demo
189
+
190
+ `space/` is the Gradio demo, deployable as-is to a Space. Two things that break it if "cleaned up":
191
+ the streamer must use `skip_special_tokens=False` (`<think>`/`</think>` are real special tokens and
192
+ stripping them destroys the reasoning/answer split), and `render_prompt()` must stay byte-identical
193
+ to the trainer's rendering — a 52M model is very sensitive to format drift.
194
+
195
+ On ZeroGPU, `import spaces` must come **before** torch, and `model.to("cuda")` at startup must stay
196
+ unguarded: no GPU is attached at startup and the call is replayed inside the forked GPU process.
code/build_dataset.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Assemble the Arabic dataset from the translation cache and validate it.
3
+
4
+ Validation per row (a translation is only kept if it passes):
5
+ * both segments translated and non-empty
6
+ * the numbers in the Arabic text match the English exactly (order-insensitive multiset)
7
+ * no degenerate repetition loop from the translator
8
+ * not left largely untranslated (Latin-script residue)
9
+
10
+ Writes out_gsm/gsm8k_reasoning_ar.parquet plus a rejects file for inspection/retry.
11
+ """
12
+ import collections
13
+ import json
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import pyarrow as pa
19
+ import pyarrow.parquet as pq
20
+
21
+ sys.path.insert(0, ".")
22
+ from gsm_common import build_record
23
+
24
+ OUT = Path("out_gsm")
25
+ AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩", "0123456789")
26
+ NUM_RE = re.compile(r"\d+(?:\.\d+)?")
27
+ LATIN_RE = re.compile(r"[A-Za-z]")
28
+ ARABIC_RE = re.compile(r"[؀-ۿ]")
29
+
30
+
31
+ def numbers(text):
32
+ return NUM_RE.findall(text.translate(AR_DIGITS).replace(",", ""))
33
+
34
+
35
+ def has_repetition_loop(text, n=6, times=3):
36
+ """Detect the translator getting stuck repeating an n-word window."""
37
+ words = text.split()
38
+ if len(words) < n * times:
39
+ return False
40
+ counts = collections.Counter(
41
+ " ".join(words[i : i + n]) for i in range(len(words) - n + 1)
42
+ )
43
+ return counts.most_common(1)[0][1] >= times
44
+
45
+
46
+ VERBALISABLE_MAX = 12 # Arabic writes small quantities as words ("ضعف" for 2, "الستة" for 6)
47
+
48
+
49
+ def check(en, ar, strict=True):
50
+ """
51
+ strict=True (reasoning chains): every numeral must survive exactly — the arithmetic depends
52
+ on it. strict=False (questions): a small number may be verbalised, but a number the source
53
+ never contained is a translation error (observed: '$13751' -> '13571', '4 × 44' -> '4 × 46'),
54
+ which silently corrupts the math and is always rejected.
55
+ """
56
+ if not ar or not ar.strip():
57
+ return "empty"
58
+ en_n, ar_n = collections.Counter(numbers(en)), collections.Counter(numbers(ar))
59
+ if ar_n - en_n:
60
+ return "invented_number"
61
+ missing = en_n - ar_n
62
+ if missing:
63
+ if strict:
64
+ return "number_dropped"
65
+ if any(float(v) > VERBALISABLE_MAX for v in missing):
66
+ return "number_dropped"
67
+ if has_repetition_loop(ar):
68
+ return "repetition"
69
+ if not ARABIC_RE.search(ar):
70
+ return "not_arabic"
71
+ latin = len(LATIN_RE.findall(ar))
72
+ if latin > 0.25 * len(ar.replace(" ", "")):
73
+ return "latin_residue"
74
+ return None
75
+
76
+
77
+ def main():
78
+ trans = {}
79
+ with open(OUT / "translations.jsonl", encoding="utf-8") as fh:
80
+ for line in fh:
81
+ try:
82
+ r = json.loads(line)
83
+ except json.JSONDecodeError:
84
+ continue
85
+ trans[r["src"]] = r["tgt"]
86
+ print(f"[*] {len(trans)} cached translations")
87
+
88
+ rows = [json.loads(l) for l in open(OUT / "selected_rows.jsonl", encoding="utf-8")]
89
+ print(f"[*] {len(rows)} selected rows")
90
+
91
+ kept, rejects = [], []
92
+ reasons = collections.Counter()
93
+ for r in rows:
94
+ q_ar, t_ar = trans.get(r["question"]), trans.get(r["thinking"])
95
+ if q_ar is None or t_ar is None:
96
+ reasons["missing"] += 1
97
+ rejects.append({**r, "reason": "missing"})
98
+ continue
99
+ why = check(r["question"], q_ar, strict=False) or check(r["thinking"], t_ar, strict=True)
100
+ if why:
101
+ reasons[why] += 1
102
+ rejects.append({**r, "question_ar": q_ar, "thinking_ar": t_ar, "reason": why})
103
+ continue
104
+ kept.append(
105
+ {
106
+ "text": build_record(q_ar, t_ar, r["answer"]),
107
+ "question": q_ar,
108
+ "thinking": t_ar,
109
+ "answer": r["answer"],
110
+ "question_en": r["question"],
111
+ "thinking_en": r["thinking"],
112
+ "source_index": r["idx"],
113
+ }
114
+ )
115
+
116
+ print(f"[*] kept {len(kept)}/{len(rows)} ({len(kept)/len(rows):.2%})")
117
+ print(f"[*] rejects: {dict(reasons)}")
118
+
119
+ table = pa.table({k: [row[k] for row in kept] for k in kept[0]})
120
+ pq.write_table(table, OUT / "gsm8k_reasoning_ar.parquet", compression="zstd")
121
+ print(f"[+] wrote {OUT / 'gsm8k_reasoning_ar.parquet'} ({table.num_rows} rows)")
122
+
123
+ with open(OUT / "rejects.jsonl", "w", encoding="utf-8") as fh:
124
+ for r in rejects:
125
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
126
+ print(f"[+] wrote {OUT / 'rejects.jsonl'} ({len(rejects)} rows)")
127
+
128
+ stats = {
129
+ "selected": len(rows),
130
+ "kept": len(kept),
131
+ "kept_pct": 100 * len(kept) / len(rows),
132
+ "rejects": dict(reasons),
133
+ "unique_translations": len(trans),
134
+ }
135
+ (OUT / "build_stats.json").write_text(json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8")
136
+ for row in kept[:3]:
137
+ print("-" * 70)
138
+ print("EN:", row["question_en"][:120])
139
+ print("AR:", row["question"][:120])
140
+ print("AR think:", row["thinking"][:160])
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
code/build_release_code.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage the training code that ships inside the released model repo, under `code/`.
3
+
4
+ Copies a curated subset of the working directory — the scripts that actually produce the released
5
+ model, not the whole scratch dir — into `release_code/`, and writes the recipe README beside them.
6
+ `push_release.py` uploads the result to `code/` in the model repo.
7
+
8
+ The list below is deliberate. Excluded: dead ends (`latex_segment.py`, `pilot_*.py`), the
9
+ per-version push scripts for models that stay private, and the logs. Included: everything a reader
10
+ needs to rebuild both datasets and rerun the training and eval end to end.
11
+ """
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ OUT = Path("release_code")
16
+
17
+ FILES = {
18
+ # ── data: translation pipeline (gsm8k-reasoning-ar) ───────────────────────
19
+ "gsm_common.py": "data",
20
+ "translate_gsm.py": "data",
21
+ "build_dataset.py": "data",
22
+ "retry_failed.py": "data",
23
+ "push_dataset.py": "data",
24
+ # ── data: synthetic pipeline (arabic-math-reasoning-synth) ────────────────
25
+ "synth_common.py": "data",
26
+ "synth_generate.py": "data",
27
+ "build_synth_dataset.py": "data",
28
+ "split_synth_v6.py": "data",
29
+ "finish_merge_push.sh": "data",
30
+ "push_synth_dataset.py": "data",
31
+ # ── SFT splits ────────────────────────────────────────────────────────────
32
+ "prepare_data.py": "data",
33
+ "prepare_gsm_sft.py": "data",
34
+ "prepare_v6_sft.py": "data",
35
+ # ── train / eval / RL ─────────────────────────────────────────────────────
36
+ "train_reasoning.py": "train",
37
+ "eval_reasoning.py": "train",
38
+ "passk_diag.py": "train",
39
+ "grpo_train.py": "train",
40
+ "chat.py": "train",
41
+ # ── release ───────────────────────────────────────────────────────────────
42
+ "push_release.py": "release",
43
+ "build_release_code.py": "release",
44
+ }
45
+
46
+ SPACE_FILES = ["app.py", "requirements.txt", "README.md"]
47
+
48
+ README = """# Nawah-Math-Reasoning — training code
49
+
50
+ Everything that produced the model in this repo: two dataset pipelines, the SFT trainer, the eval
51
+ harness, a `pass@k` diagnostic, and a GRPO implementation. Run top to bottom and you get the
52
+ release; run any single stage and it resumes from what is already on disk.
53
+
54
+ Paths below assume this directory is the working directory and `$P` is a Python with
55
+ `torch`, `transformers>=5.15`, `pyarrow` and `huggingface_hub`.
56
+
57
+ ## 0. Two environments, and why
58
+
59
+ | env | for | pins |
60
+ |---|---|---|
61
+ | training / eval / Hub | `train_reasoning.py`, `eval_reasoning.py`, everything else | torch 2.9 + cu126, **transformers 5.15** |
62
+ | generation | `translate_gsm.py`, `synth_generate.py` | **vLLM 0.8.5.post1**, torch 2.6 + cu124, **transformers 4.51.3** |
63
+
64
+ They are not interchangeable. The model configs are transformers-v5 format (`rope_parameters`,
65
+ `dtype`, `tokenizer_class: TokenizersBackend`) and transformers 4.x cannot read them — but vLLM
66
+ 0.8.5 breaks on transformers 5.x (`TokenizersBackend has no attribute
67
+ all_special_tokens_extended`), so the generation env must stay at 4.51.3. Do not `pip install -U`
68
+ in it.
69
+
70
+ vLLM 0.8.5 is itself a pin: every release from 0.20.2 up requires torch 2.11, which is a CUDA 13
71
+ build needing driver ≥ 580. On a CUDA 12.4 host that is a hard stop, and cu12 wheels do not rescue
72
+ it (`ImportError: libcudart.so.13`). 0.8.5 is the last cu124 release.
73
+
74
+ In transformers v5 `TrainingArguments` it is `eval_strategy` (not `evaluation_strategy`) and
75
+ `warmup_steps` (there is no `warmup_ratio`).
76
+
77
+ ## 1. `gsm8k-reasoning-ar` — 142,969 machine-translated rows
78
+
79
+ ```bash
80
+ $V translate_gsm.py 150000 # vLLM env; ~95 min on 1x A6000; resumable
81
+ $P build_dataset.py # -> out_gsm/*.parquet + rejects.jsonl
82
+ $P push_dataset.py --repo <user>/gsm8k-reasoning-ar
83
+ ```
84
+
85
+ `Ajhesh7/gsm8k-reasoning-SFT-datas` translated with `ByteDance-Seed/Seed-X-PPO-7B`. The trailing
86
+ language tag in the prompt is **mandatory**: `Translate the following English sentence into
87
+ Arabic:\\n{text} <ar>`.
88
+
89
+ Only 150k of the source's 600,000 rows are translated, and the sample is stratified by question
90
+ pattern with a floor of 13 rows/pattern: the corpus expands from just **2,814** question patterns,
91
+ so translating all 600k is ~99% redundant.
92
+
93
+ **The reason `build_dataset.py` has a validator at all:** Seed-X silently corrupts arithmetic in
94
+ ~0.6% of segments — `$13751` → `13571`, `4 × 44 = 176` → `4 × 46 = 176`. These read as fluent
95
+ Arabic and pass any fluency check. So every row is numeral-audited, asymmetrically: **reasoning
96
+ chains strictly** (every numeral must survive exactly), **questions leniently** (a value ≤ 12 may
97
+ be verbalised — `6 friends` → `أصدقائها الستة` — but any number the English never contained is
98
+ rejected). Naive exact-multiset matching rejects ~6% of *correct* translations; do not "simplify"
99
+ it back to that.
100
+
101
+ `translate_gsm.py` appends every chunk to `out_gsm/translations.jsonl` and skips cached segments on
102
+ restart, so an interruption costs at most one 20k chunk. Do not pass `enable_prefix_caching=True`
103
+ — it hangs at startup on this vLLM/V1 engine.
104
+
105
+ ## 2. `arabic-math-reasoning-synth` — 120,462 verified rows
106
+
107
+ ```bash
108
+ # general pool: 100,323 rows, ~22 h
109
+ GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth TARGET=100000 \\
110
+ BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 \\
111
+ nohup $V -u synth_generate.py > synth_run.log 2>&1 &
112
+
113
+ # relational pool: 20,139 rows, ~3.5 h. Disjoint task-id range, on purpose.
114
+ GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth_rel POOL=relational \\
115
+ START_TASK=1000000 TARGET=20000 BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 SEED=1234 \\
116
+ nohup $V -u synth_generate.py > synth_rel.log 2>&1 &
117
+
118
+ ./finish_merge_push.sh # merge primary cache + any node shards -> out_merged_v6/
119
+ $P split_synth_v6.py # -> data_synth_v6_sft/{train,eval,eval_rel}.jsonl
120
+ $P push_synth_dataset.py --repo <user>/arabic-math-reasoning-synth
121
+ ```
122
+
123
+ **Resumability is the design, not a feature.** A task is one generation call asking for 4 problems,
124
+ and task *N*'s prompt is a pure function of *N* (`synth_common.build_task`) — nothing about the
125
+ plan is persisted, so a restart redraws identical prompts. Finished tasks are appended to
126
+ `generations.jsonl` and fsynced; a torn final line is dropped with a warning. The stop condition is
127
+ **accepted rows, not tasks**.
128
+
129
+ **Raw completions are stored, never just the parsed rows.** Every validator change can be re-scored
130
+ over the whole cache with no GPU — which is how the accept rate went 48.4% → 64.9% without
131
+ regenerating anything.
132
+
133
+ **The arithmetic audit** (`synth_common.validate`) re-evaluates every `a op b = c` in the reasoning
134
+ and one wrong equation rejects the row. Two things it must keep doing, both of which were bugs that
135
+ rejected *correct* rows:
136
+
137
+ 1. **Equation chains.** Models write `75 + 15 × 5 = 75 + 75 = 150`. Reading only to the first `=`
138
+ compares 150 against 75 and rejects a correct chain. Split the whole chain, require every
139
+ segment to agree.
140
+ 2. **Rounding.** `3200 / 60 = 53.33` is arithmetic as people write it. `_close()` forgives rounding
141
+ *at the precision the model displayed* (and floor/ceil for integers), so a genuinely wrong
142
+ number still fails.
143
+
144
+ Also rejected: `noop_step` (`63 + 0 = 63`, padding to hit a step count), `meta_commentary`, Latin
145
+ residue, missing conclusion marker, and answers that disagree with the last computed value.
146
+
147
+ **The relational pool is a separate pool** (`RELATIONAL_OPS`), deliberately not appended to
148
+ `OPERATIONS`. Appending would change `rng.choice()` for every task id and silently break
149
+ reproducibility of the first 100,323 rows. `build_task(task_id, seed, pool)` takes
150
+ `pool="default"` or `"relational"`; keep it that way.
151
+
152
+ Check `unique_templates` in `build_stats.json` rather than assuming the variation grid worked.
153
+
154
+ ## 3. SFT
155
+
156
+ ```bash
157
+ $P prepare_data.py # Arabic_Reasoning_Dataset -> data/{train,eval}.jsonl
158
+ $P prepare_gsm_sft.py # -> data_gsm_sft/{train,eval}.jsonl
159
+ $P prepare_v6_sft.py # three-way mix -> data_v6_sft/{train,eval}.jsonl
160
+
161
+ BASE_MODEL=<base> OUTPUT_DIR=./Nawah-Math-Reasoning \\
162
+ TRAIN_FILE=data_v6_sft/train.jsonl EVAL_FILE=data_v6_sft/eval.jsonl \\
163
+ MAX_LENGTH=768 EPOCHS=5 BATCH_SIZE=64 GRAD_ACCUM=1 WARMUP_STEPS=200 EVAL_STEPS=1000 LOAD_BEST=0 \\
164
+ $P -u train_reasoning.py 2>&1 | tee train.log # 21,535 steps, ~85 min on 1x A6000
165
+ ```
166
+
167
+ **`LOAD_BEST=0` is load-bearing.** Eval loss selects the *worse* checkpoint on this ladder, and
168
+ that was measured rather than assumed: on a corpus with no repeated rows, the minimum-loss
169
+ checkpoint scored 30.9% where the final scored 35.6%. It held for four consecutive runs. Ship the
170
+ final checkpoint.
171
+
172
+ **Answer styles are not normalised.** GSM8K rows end in a bare numeral, the other two corpora in an
173
+ `إذن، …` sentence. Rewriting them into one style deletes what the mix adds, so a `source` tag rides
174
+ on every row and eval scores each half on its own terms.
175
+
176
+ The eval splits are **pinned across versions** — the same 400 `Arabic_Reasoning` rows and the same
177
+ 600 GSM8K rows since the first model, and `split_synth_v6.py` copies the previous synth eval rows
178
+ through verbatim rather than re-shuffling. Re-drawing would have moved 1,955 of 2,000 held-out
179
+ items into train.
180
+
181
+ ## 4. Eval
182
+
183
+ ```bash
184
+ EVAL_FILE=data_v6_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1800
185
+ EVAL_FILE=data_synth_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1000
186
+ ```
187
+
188
+ Greedy. Reports well-formedness, **number agreement**, exact match and reasoning length, broken
189
+ down per `source` when the rows carry the tag. `EVAL_FILE` comes from the env — pointing it at the
190
+ wrong split silently scores against the wrong data.
191
+
192
+ Score on **number agreement**, not exact match: with two answer styles in the mix, exact match
193
+ measures style compliance, not arithmetic.
194
+
195
+ ## 5. RL — run the diagnostic first
196
+
197
+ ```bash
198
+ $P -u passk_diag.py ./Nawah-Math-Reasoning # 240 problems x k=8 @ T=1.0, ~25 min
199
+ ```
200
+
201
+ RLVR reweights samples the model already produces. A group where every sample is wrong scores
202
+ all-zero, the advantage is zero, and there is no gradient — so exploitable headroom is bounded by
203
+ `pass@k − pass@1`. On the previous version that was **+27.1 points**, with 40.4% of problems never
204
+ solved in 8 tries. Run this before spending a GPU-hour.
205
+
206
+ That diagnostic is also what produced this release: the dead 40% turned out to be a *data* gap
207
+ (relational comparisons were 1.34% of the corpus), so the fix was §2's relational pool through
208
+ SFT, not RL. **Data first, then RL** — running RL first leaves the dead tail untouched and caps
209
+ the gain.
210
+
211
+ ```bash
212
+ MODEL=./Nawah-Math-Reasoning OUTPUT_DIR=./Nawah-Math-Reasoning-grpo \\
213
+ GROUP=8 PROMPTS_PER=8 STEPS=500 LR=1e-6 BETA=0.02 \\
214
+ $P -u grpo_train.py 2>&1 | tee grpo.log
215
+ ```
216
+
217
+ `grpo_train.py` is hand-rolled — TRL is not installable against these pins. Group-normalised
218
+ advantage with no value network, binary final-answer reward (not partial credit — the failure being
219
+ fixed is fluent reasoning landing on a wrong number), KL to a frozen reference via Schulman's k3
220
+ estimator, and no importance ratio or PPO clipping because sampling is on-policy with one step per
221
+ batch. Zero-spread groups are skipped **and counted**; the dead-group percentage in the log is the
222
+ number to watch.
223
+
224
+ ## 6. Release
225
+
226
+ ```bash
227
+ $P build_release_code.py # stage this directory
228
+ $P push_release.py --dry-run # render the card only
229
+ $P push_release.py
230
+ ```
231
+
232
+ Every number in the model card is read from an eval JSON on disk. Nothing is typed by hand, so the
233
+ card cannot drift from the measurements.
234
+
235
+ ## Demo
236
+
237
+ `space/` is the Gradio demo, deployable as-is to a Space. Two things that break it if "cleaned up":
238
+ the streamer must use `skip_special_tokens=False` (`<think>`/`</think>` are real special tokens and
239
+ stripping them destroys the reasoning/answer split), and `render_prompt()` must stay byte-identical
240
+ to the trainer's rendering — a 52M model is very sensitive to format drift.
241
+
242
+ On ZeroGPU, `import spaces` must come **before** torch, and `model.to("cuda")` at startup must stay
243
+ unguarded: no GPU is attached at startup and the call is replayed inside the forked GPU process.
244
+ """
245
+
246
+
247
+ def main():
248
+ if OUT.exists():
249
+ shutil.rmtree(OUT)
250
+ OUT.mkdir()
251
+
252
+ missing = [f for f in FILES if not Path(f).is_file()]
253
+ if missing:
254
+ raise SystemExit(f"missing source files: {missing}")
255
+
256
+ for name in FILES:
257
+ shutil.copy2(name, OUT / name)
258
+
259
+ space_out = OUT / "space"
260
+ space_out.mkdir()
261
+ for name in SPACE_FILES:
262
+ shutil.copy2(Path("space") / name, space_out / name)
263
+
264
+ (OUT / "README.md").write_text(README, encoding="utf-8")
265
+
266
+ n = sum(1 for _ in OUT.rglob("*") if _.is_file())
267
+ print(f"[+] {OUT}/ — {n} files")
268
+ for p in sorted(OUT.rglob("*")):
269
+ if p.is_file():
270
+ print(f" {p.relative_to(OUT)} ({p.stat().st_size:,} B)")
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
code/build_synth_dataset.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Turn out_synth/generations.jsonl into the finished synthetic corpus.
3
+
4
+ Re-parses and re-validates every cached generation with synth_common (the generator's inline
5
+ accounting is only a progress estimate; this is the authoritative pass), drops duplicates, and
6
+ writes a parquet plus the SFT splits train_reasoning.py reads directly.
7
+
8
+ Duplicates are keyed on the question with its numbers masked out, so "3 apples at 5 riyal" and
9
+ "7 apples at 9 riyal" collapse to one template — this is the exact failure the translated GSM8K
10
+ set has (142,969 rows from 2,814 templates), and the whole point of the variation grid is to not
11
+ repeat it. The template count is reported so it can be checked rather than assumed.
12
+
13
+ Writes:
14
+ out_synth/arabic_math_reasoning_synth.parquet the corpus
15
+ out_synth/rejects.jsonl every rejected item with its reason
16
+ out_synth/build_stats.json counts, reject histogram, axis coverage
17
+ data_synth_sft/{train,eval}.jsonl ready for train_reasoning.py
18
+ """
19
+ import collections
20
+ import json
21
+ import os
22
+ import random
23
+ from pathlib import Path
24
+
25
+ import pyarrow as pa
26
+ import pyarrow.parquet as pq
27
+
28
+ import synth_common as sc
29
+
30
+ OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
31
+ CACHE = OUT_DIR / "generations.jsonl"
32
+ SFT_DIR = Path(os.environ.get("SFT_DIR", "data_synth_sft"))
33
+ EVAL_N = int(os.environ.get("EVAL_N", 2000))
34
+ LIMIT = int(os.environ.get("LIMIT", 0)) # 0 = keep everything that validates
35
+ SEED = 42
36
+
37
+
38
+ def main():
39
+ stats = collections.Counter()
40
+ rejects_by_reason = collections.Counter()
41
+ axes_seen = collections.defaultdict(collections.Counter)
42
+ rows, rejects = [], []
43
+ seen = {}
44
+
45
+ with open(CACHE, encoding="utf-8") as fh:
46
+ for line in fh:
47
+ try:
48
+ rec = json.loads(line)
49
+ except json.JSONDecodeError:
50
+ stats["truncated_lines"] += 1 # a crash mid-write; the rest is still good
51
+ continue
52
+ stats["tasks"] += 1
53
+ items = sc.parse_items(rec["raw"])
54
+ stats["parsed_items"] += len(items)
55
+ if not items:
56
+ stats["tasks_with_no_parsable_item"] += 1
57
+ for item in items:
58
+ ok, reason = sc.validate(item)
59
+ if not ok:
60
+ rejects_by_reason[reason] += 1
61
+ rejects.append({**item, "reason": reason, "task_id": rec["task_id"]})
62
+ continue
63
+ key = sc.dedup_key(item["instruction"])
64
+ if key in seen:
65
+ stats["dropped_duplicate_template"] += 1
66
+ continue
67
+ seen[key] = True
68
+ for axis, value in rec["axes"].items():
69
+ axes_seen[axis][value] += 1
70
+ rows.append({**item, **{f"axis_{k}": v for k, v in rec["axes"].items()},
71
+ "task_id": rec["task_id"],
72
+ # rows cached before multi-node generation carry no model field
73
+ "gen_model": rec.get("model", "gemma-3-12b-it")})
74
+
75
+ for r in rows:
76
+ stats[f"model_{r['gen_model']}"] += 1
77
+
78
+ stats["kept"] = len(rows)
79
+ stats["rejected"] = sum(rejects_by_reason.values())
80
+
81
+ random.Random(SEED).shuffle(rows)
82
+ if LIMIT:
83
+ rows = rows[:LIMIT]
84
+
85
+ OUT_DIR.mkdir(exist_ok=True)
86
+ pq.write_table(pa.Table.from_pylist(rows), OUT_DIR / "arabic_math_reasoning_synth.parquet")
87
+ with open(OUT_DIR / "rejects.jsonl", "w", encoding="utf-8") as fh:
88
+ for r in rejects:
89
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
90
+
91
+ SFT_DIR.mkdir(exist_ok=True)
92
+ eval_rows, train_rows = rows[:EVAL_N], rows[EVAL_N:]
93
+ for name, split in (("train", train_rows), ("eval", eval_rows)):
94
+ with open(SFT_DIR / f"{name}.jsonl", "w", encoding="utf-8") as fh:
95
+ for r in split:
96
+ fh.write(json.dumps({"instruction": r["instruction"], "reasoning": r["reasoning"],
97
+ "answer": r["answer"], "source": "synth_math_ar"},
98
+ ensure_ascii=False) + "\n")
99
+ print(f"[+] {name}: {len(split):,} -> {SFT_DIR / f'{name}.jsonl'}")
100
+
101
+ summary = {
102
+ "stats": dict(stats),
103
+ "reject_reasons": dict(rejects_by_reason.most_common()),
104
+ "accept_rate": stats["kept"] / max(stats["parsed_items"], 1),
105
+ "unique_templates": len(seen),
106
+ "axis_coverage": {k: len(v) for k, v in axes_seen.items()},
107
+ }
108
+ (OUT_DIR / "build_stats.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2),
109
+ encoding="utf-8")
110
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
111
+ print(f"[+] {OUT_DIR / 'arabic_math_reasoning_synth.parquet'}")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
code/chat.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick manual probe: python chat.py "سؤالك هنا" [model_dir]"""
2
+ import sys, torch
3
+ from transformers import AutoModelForCausalLM, AutoTokenizer
4
+
5
+ question = sys.argv[1] if len(sys.argv) > 1 else "إذا كان لديك 1500 ريال وأنفقت 20% منها على الكتب، فكم تبقى معك؟"
6
+ model_dir = sys.argv[2] if len(sys.argv) > 2 else "./Nawah-Reasoning-v1"
7
+
8
+ tok = AutoTokenizer.from_pretrained(model_dir)
9
+ model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.bfloat16).cuda().eval()
10
+
11
+ prompt = tok.apply_chat_template([{"role": "user", "content": question}], tokenize=False, add_generation_prompt=True)
12
+ ids = tok(prompt, return_tensors="pt").to("cuda")
13
+ out = model.generate(**ids, max_new_tokens=512, do_sample=False,
14
+ eos_token_id=[tok.eos_token_id, tok.convert_tokens_to_ids("<|im_end|>")],
15
+ pad_token_id=tok.pad_token_id)
16
+ print(tok.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=False).split("<|im_end|>")[0])
code/eval_reasoning.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Greedy evaluation of the reasoning model on the held-out split.
3
+
4
+ Reports:
5
+ * format compliance — a single well-formed <think>…</think> block followed by an answer
6
+ * numeric agreement — do the numbers in the generated conclusion match the reference's
7
+ * length stats — how long the produced reasoning is
8
+
9
+ When the eval rows carry a `source` tag (the v3 mix), every metric is also broken down per
10
+ source, since the two corpora answer in different styles.
11
+
12
+ Usage: python eval_reasoning.py [model_dir] [n_samples]
13
+ """
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import torch
21
+ from transformers import AutoModelForCausalLM, AutoTokenizer
22
+
23
+ MODEL_DIR = sys.argv[1] if len(sys.argv) > 1 else "./Nawah-Reasoning-v1"
24
+ LIMIT = int(sys.argv[2]) if len(sys.argv) > 2 else 400
25
+ EVAL_FILE = os.environ.get("EVAL_FILE", "data/eval.jsonl")
26
+ MAX_NEW = 512
27
+ BATCH = 16
28
+
29
+ AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩٫٬", "0123456789.,")
30
+ NUM_RE = re.compile(r"\d+(?:\.\d+)?")
31
+
32
+
33
+ def numbers(text: str):
34
+ text = text.translate(AR_DIGITS).replace(",", "")
35
+ out = []
36
+ for tok in NUM_RE.findall(text):
37
+ val = float(tok)
38
+ out.append(int(val) if val.is_integer() else val)
39
+ return out
40
+
41
+
42
+ def parse(completion: str):
43
+ """-> (reasoning, final_answer, well_formed)"""
44
+ m = re.match(r"\s*<think>(.*?)</think>(.*)", completion, re.S)
45
+ if not m:
46
+ return None, completion.strip(), False
47
+ reasoning, final = m.group(1).strip(), m.group(2).strip()
48
+ well_formed = (
49
+ completion.count("<think>") == 1
50
+ and completion.count("</think>") == 1
51
+ and bool(reasoning)
52
+ and bool(final)
53
+ )
54
+ return reasoning, final, well_formed
55
+
56
+
57
+ def metrics(results):
58
+ n = len(results)
59
+ return {
60
+ "n": n,
61
+ "well_formed_pct": 100 * sum(r["well_formed"] for r in results) / n,
62
+ "numbers_match_pct": 100 * sum(r["numbers_match"] for r in results) / n,
63
+ "primary_number_match_pct": 100 * sum(r["primary_number_match"] for r in results) / n,
64
+ "answer_exact_pct": 100 * sum(r["answer_exact"] for r in results) / n,
65
+ "mean_reasoning_tokens": sum(r["reasoning_tokens"] for r in results) / n,
66
+ }
67
+
68
+
69
+ def main():
70
+ tok = AutoTokenizer.from_pretrained(MODEL_DIR)
71
+ model = AutoModelForCausalLM.from_pretrained(MODEL_DIR, dtype=torch.bfloat16).cuda().eval()
72
+ model.config.use_cache = True
73
+
74
+ rows = [json.loads(l) for l in open(EVAL_FILE, encoding="utf-8")][:LIMIT]
75
+ im_end = tok.convert_tokens_to_ids("<|im_end|>")
76
+
77
+ results = []
78
+ for start in range(0, len(rows), BATCH):
79
+ chunk = rows[start : start + BATCH]
80
+ prompts = [
81
+ f"<|im_start|>user\n{r['instruction']}<|im_end|>\n<|im_start|>assistant\n" for r in chunk
82
+ ]
83
+ encoded = [[tok.bos_token_id] + tok.encode(p, add_special_tokens=False) for p in prompts]
84
+ width = max(len(e) for e in encoded)
85
+ # left-pad so every row's generation starts at the same offset
86
+ input_ids = torch.tensor([[tok.pad_token_id] * (width - len(e)) + e for e in encoded]).cuda()
87
+ attn = torch.tensor([[0] * (width - len(e)) + [1] * len(e) for e in encoded]).cuda()
88
+
89
+ with torch.no_grad():
90
+ out = model.generate(
91
+ input_ids=input_ids,
92
+ attention_mask=attn,
93
+ max_new_tokens=MAX_NEW,
94
+ do_sample=False,
95
+ eos_token_id=[im_end, tok.eos_token_id],
96
+ pad_token_id=tok.pad_token_id,
97
+ )
98
+ for row, seq in zip(chunk, out):
99
+ gen = tok.decode(seq[width:], skip_special_tokens=False)
100
+ gen = gen.split("<|im_end|>")[0].replace("</s>", "").replace("<pad>", "")
101
+ reasoning, final, ok = parse(gen)
102
+ ref_nums, gen_nums = numbers(row["answer"]), numbers(final)
103
+ results.append(
104
+ {
105
+ "source": row.get("source"),
106
+ "instruction": row["instruction"],
107
+ "reference_reasoning": row["reasoning"],
108
+ "reference_answer": row["answer"],
109
+ "generated_reasoning": reasoning,
110
+ "generated_answer": final,
111
+ "well_formed": ok,
112
+ "answer_exact": final.strip() == row["answer"].strip(),
113
+ "numbers_match": bool(ref_nums) and ref_nums == gen_nums,
114
+ "primary_number_match": bool(ref_nums) and ref_nums[0] in gen_nums,
115
+ "reasoning_tokens": len(tok.encode(reasoning or "", add_special_tokens=False)),
116
+ }
117
+ )
118
+ print(f" {min(start + BATCH, len(rows))}/{len(rows)}", flush=True)
119
+
120
+ summary = metrics(results)
121
+ summary["model"] = MODEL_DIR
122
+ # A mixed corpus (v3) answers in two different styles, so a single exact-match number is
123
+ # meaningless — score each source on its own terms.
124
+ sources = sorted({r["source"] for r in results if r["source"]})
125
+ if len(sources) > 1:
126
+ summary["by_source"] = {s: metrics([r for r in results if r["source"] == s]) for s in sources}
127
+ print(json.dumps(summary, indent=2))
128
+
129
+ Path(MODEL_DIR, "eval_reasoning.json").write_text(
130
+ json.dumps({"summary": summary, "samples": results}, ensure_ascii=False, indent=2), encoding="utf-8"
131
+ )
132
+ print(f"[+] wrote {Path(MODEL_DIR, 'eval_reasoning.json')}")
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
code/finish_merge_push.sh ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Merge the primary cache with every node shard, rebuild the corpus, push to the Hub.
3
+ #
4
+ # Run this after the primary generator exits. Idempotent: it re-merges from the two
5
+ # caches every time, so re-running after a resumed generation just produces a larger
6
+ # corpus. Neither node's live cache is touched — the merge lands in out_merged/.
7
+ #
8
+ # ./finish_merge_push.sh # build + card, no push (review, then re-run with push)
9
+ # ./finish_merge_push.sh --push # build + card + upload
10
+ set -euo pipefail
11
+ cd /notebooks/50M/reasonsing
12
+ P=/notebooks/50M/.venv-lfm2/bin/python
13
+ REPO=oddadmix/arabic-math-reasoning-synth
14
+ SHARD_DIR=shards_dl/shards
15
+
16
+ # 1. merge — primary cache first, then every shard pulled from the Hub
17
+ mkdir -p out_merged
18
+ cat out_synth/generations.jsonl > out_merged/generations.jsonl
19
+ for gz in "$SHARD_DIR"/*.jsonl.gz; do
20
+ echo "[*] merging shard $(basename "$gz")"
21
+ gunzip -c "$gz" >> out_merged/generations.jsonl
22
+ done
23
+ echo "[+] merged cache: $(wc -l < out_merged/generations.jsonl) tasks"
24
+
25
+ # 2. authoritative re-parse / re-validate / dedup over the whole merged cache
26
+ OUT_DIR=out_merged SFT_DIR=data_synth_sft $P build_synth_dataset.py > /tmp/build.log 2>&1
27
+ $P - <<'PY'
28
+ import json
29
+ d = json.load(open("out_merged/build_stats.json")); s = d["stats"]
30
+ print(f"[+] kept {s['kept']:,} rows | templates {d['unique_templates']:,} | "
31
+ f"accept {d['accept_rate']:.1%} | dup drops {s.get('dropped_duplicate_template',0):,}")
32
+ for k in sorted(s):
33
+ if k.startswith("model_"):
34
+ print(f" {k[6:]:36s} {s[k]:,}")
35
+ PY
36
+
37
+ # 3. card + upload
38
+ if [[ "${1:-}" == "--push" ]]; then
39
+ OUT_DIR=out_merged $P push_synth_dataset.py --repo "$REPO"
40
+ else
41
+ OUT_DIR=out_merged $P push_synth_dataset.py --repo "$REPO" --dry-run
42
+ fi
code/grpo_train.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GRPO (Group Relative Policy Optimization) with a verifiable reward, for the Nawah reasoning models.
3
+
4
+ Why hand-rolled: TRL is not installed and cannot be added cleanly here — .venv-lfm2 runs
5
+ transformers 5.15 while current TRL targets 4.x, and .venv-vllm is pinned to transformers 4.51.3
6
+ and must not be upgraded (see HANDOFF.md). At 51.8M parameters the algorithm is small enough that
7
+ a direct implementation is less risk than a third environment.
8
+
9
+ The method, briefly: sample G completions per prompt, score each with a mechanical verifier,
10
+ normalise the rewards WITHIN the group to get advantages, and do a policy-gradient step. No value
11
+ network — the group mean is the baseline, which is the whole point of GRPO.
12
+
13
+ A_i = (r_i - mean(r)) / (std(r) + eps)
14
+ loss = -mean_i( A_i * mean_t log pi(token_t) ) + beta * KL_k3(pi || pi_ref)
15
+
16
+ Sampling is on-policy and each batch takes exactly one gradient step, so there is no importance
17
+ ratio and no PPO clipping to get wrong — the ratio would be identically 1.
18
+
19
+ ⚠️ The measured precondition: a group where every sample scores the same has zero advantage and
20
+ contributes NO gradient. On v5, 40.4% of synth problems are never solved in 8 tries, so those
21
+ groups are dead weight — they are skipped and counted, not silently averaged in. The exploitable
22
+ headroom is pass@k - pass@1, measured at +27.1 points (32.5% -> 59.6%) by passk_diag.py.
23
+ """
24
+ import json
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ import torch
30
+ import torch.nn.functional as F
31
+ from transformers import AutoModelForCausalLM, AutoTokenizer
32
+
33
+ sys.path.insert(0, ".")
34
+ from eval_reasoning import numbers, parse
35
+
36
+ MODEL = os.environ.get("MODEL", "./Nawah-Reasoning-v5")
37
+ OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "./Nawah-Reasoning-v6-grpo")
38
+ TRAIN_FILE = os.environ.get("TRAIN_FILE", "data_synth_sft/train.jsonl")
39
+
40
+ GROUP = int(os.environ.get("GROUP", 8)) # completions per prompt
41
+ PROMPTS_PER = int(os.environ.get("PROMPTS_PER", 8)) # prompts per optimiser step
42
+ STEPS = int(os.environ.get("STEPS", 500))
43
+ LR = float(os.environ.get("LR", 1e-6)) # RL wants far less than SFT's 3e-4
44
+ BETA = float(os.environ.get("BETA", 0.02)) # KL to the frozen reference
45
+ TEMP = float(os.environ.get("TEMP", 1.0))
46
+ MAX_NEW = int(os.environ.get("MAX_NEW", 320))
47
+ MAX_GRAD = 1.0
48
+ SAVE_EVERY = int(os.environ.get("SAVE_EVERY", 100))
49
+ LOG_EVERY = int(os.environ.get("LOG_EVERY", 5))
50
+ SEED = int(os.environ.get("SEED", 42))
51
+ FORMAT_PENALTY = float(os.environ.get("FORMAT_PENALTY", 0.1))
52
+
53
+
54
+ def reward(completion: str, ref: float) -> float:
55
+ """Verifiable reward: does the stated final answer equal the reference number?
56
+
57
+ Deliberately NOT a partial-credit score. The failure this is meant to fix is a model that
58
+ reasons plausibly and lands on the wrong number, so rewarding anything short of the right
59
+ number would reinforce exactly that.
60
+ """
61
+ _, ans, well_formed = parse(completion)
62
+ ns = numbers(ans or "")
63
+ correct = bool(ns) and ref is not None and ns[-1] == ref
64
+ r = 1.0 if correct else 0.0
65
+ if not well_formed:
66
+ r -= FORMAT_PENALTY
67
+ return r
68
+
69
+
70
+ def completion_logprobs(model, ids, attn, prompt_len):
71
+ """-> (mean log pi over completion tokens, per-token log pi, completion mask).
72
+
73
+ Prompt tokens are masked out: the prompt is not an action the policy chose, so including it
74
+ would add a term with no gradient meaning and would dilute the per-sequence mean.
75
+ """
76
+ out = model(input_ids=ids, attention_mask=attn)
77
+ logits = out.logits[:, :-1] # position t predicts token t+1
78
+ targets = ids[:, 1:]
79
+ logp = torch.log_softmax(logits.float(), dim=-1)
80
+ tok_logp = logp.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
81
+ mask = attn[:, 1:].clone().float()
82
+ mask[:, : prompt_len - 1] = 0.0 # only the generated part is the "action"
83
+ mean_lp = (tok_logp * mask).sum(1) / mask.sum(1).clamp(min=1)
84
+ return mean_lp, tok_logp, mask
85
+
86
+
87
+ def k3_kl(pol_tok, ref_tok, mask):
88
+ """Schulman's k3 estimator of KL(pi || pi_ref): exp(d) - d - 1 where d = log pi_ref - log pi.
89
+
90
+ Unbiased and always non-negative, unlike the naive (log pi - log pi_ref) difference. Computed
91
+ per token and averaged over the completion, which is what GRPO regularises.
92
+ """
93
+ d = (ref_tok - pol_tok).clamp(-20, 20)
94
+ per_tok = torch.exp(d) - d - 1.0
95
+ return (per_tok * mask).sum(1) / mask.sum(1).clamp(min=1)
96
+
97
+
98
+ def main():
99
+ torch.manual_seed(SEED)
100
+ tok = AutoTokenizer.from_pretrained(MODEL)
101
+ policy = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32).cuda()
102
+ ref = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32).cuda().eval()
103
+ for p in ref.parameters():
104
+ p.requires_grad_(False)
105
+ policy.config.use_cache = True
106
+
107
+ rows = [json.loads(l) for l in open(TRAIN_FILE, encoding="utf-8")]
108
+ refs = []
109
+ for r in rows:
110
+ ns = numbers(r["answer"])
111
+ if ns:
112
+ refs.append((r["instruction"], ns[-1]))
113
+ print(f"[*] {len(refs):,} prompts with a parseable reference answer", flush=True)
114
+
115
+ opt = torch.optim.AdamW(policy.parameters(), lr=LR, weight_decay=0.0)
116
+ rng = torch.Generator().manual_seed(SEED)
117
+ Path(OUTPUT_DIR).mkdir(exist_ok=True)
118
+ history, dead_groups, seen = [], 0, 0
119
+
120
+ for step in range(1, STEPS + 1):
121
+ idx = torch.randint(0, len(refs), (PROMPTS_PER,), generator=rng).tolist()
122
+ batch = [refs[i] for i in idx]
123
+ step_rewards, step_solved, losses = [], [], []
124
+ opt.zero_grad(set_to_none=True)
125
+ used_groups = 0
126
+
127
+ for question, ref_num in batch:
128
+ prompt = tok.apply_chat_template([{"role": "user", "content": question}],
129
+ tokenize=False, add_generation_prompt=True)
130
+ enc = tok(prompt, return_tensors="pt").to("cuda")
131
+ plen = enc["input_ids"].shape[1]
132
+
133
+ policy.eval()
134
+ with torch.no_grad():
135
+ gen = policy.generate(**enc, max_new_tokens=MAX_NEW, do_sample=True,
136
+ temperature=TEMP, top_p=0.95,
137
+ num_return_sequences=GROUP,
138
+ pad_token_id=tok.pad_token_id)
139
+ policy.train()
140
+
141
+ texts = tok.batch_decode(gen[:, plen:], skip_special_tokens=True)
142
+ r = torch.tensor([reward(t, ref_num) for t in texts], dtype=torch.float32)
143
+ step_rewards.append(r.mean().item())
144
+ step_solved.append(float((r > 0.5).any()))
145
+ seen += 1
146
+
147
+ # A group with no reward spread carries no learning signal — skip it rather than
148
+ # letting a zero-advantage group dilute the batch.
149
+ if r.std() < 1e-6:
150
+ dead_groups += 1
151
+ continue
152
+ adv = ((r - r.mean()) / (r.std() + 1e-6)).cuda()
153
+
154
+ attn = (gen != tok.pad_token_id).long()
155
+ attn[:, :plen] = 1
156
+ mean_lp, pol_tok, mask = completion_logprobs(policy, gen, attn, plen)
157
+ with torch.no_grad():
158
+ _, ref_tok, _ = completion_logprobs(ref, gen, attn, plen)
159
+
160
+ pg = -(adv * mean_lp).mean()
161
+ kl = k3_kl(pol_tok, ref_tok, mask).mean()
162
+ loss = (pg + BETA * kl) / PROMPTS_PER
163
+ loss.backward()
164
+ losses.append(pg.item())
165
+ used_groups += 1
166
+
167
+ if used_groups:
168
+ torch.nn.utils.clip_grad_norm_(policy.parameters(), MAX_GRAD)
169
+ opt.step()
170
+
171
+ history.append({"step": step, "mean_reward": sum(step_rewards) / len(step_rewards),
172
+ "any_solved": sum(step_solved) / len(step_solved),
173
+ "pg_loss": (sum(losses) / len(losses)) if losses else None,
174
+ "live_groups": used_groups})
175
+ if step % LOG_EVERY == 0:
176
+ h = history[-1]
177
+ print(f"[{step:>4}/{STEPS}] reward {h['mean_reward']:.3f} "
178
+ f"pass@{GROUP} {h['any_solved']:.2f} live {used_groups}/{PROMPTS_PER} "
179
+ f"dead so far {100*dead_groups/max(seen,1):.0f}%", flush=True)
180
+
181
+ if step % SAVE_EVERY == 0 or step == STEPS:
182
+ policy.config.use_cache = True
183
+ policy.save_pretrained(OUTPUT_DIR)
184
+ tok.save_pretrained(OUTPUT_DIR)
185
+ Path(OUTPUT_DIR, "grpo_history.json").write_text(
186
+ json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
187
+ print(f" [+] saved at step {step}", flush=True)
188
+
189
+ print(f"[+] done -> {OUTPUT_DIR} ({100*dead_groups/max(seen,1):.1f}% of groups had no signal)")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
code/gsm_common.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared parsing/masking helpers for the GSM8K reasoning dataset translation."""
2
+ import re
3
+
4
+ RECORD_RE = re.compile(
5
+ r"<question>(.*?)</question>\s*<thinking>(.*?)</thinking>\s*<answer>(.*?)</answer>", re.S
6
+ )
7
+ NUM_RE = re.compile(r"\d+(?:\.\d+)?")
8
+ PH_RE = re.compile(r"#(\d+)#")
9
+
10
+ SRC_PROMPT = "Translate the following English sentence into Arabic:\n{text} <ar>"
11
+
12
+
13
+ def parse(text):
14
+ m = RECORD_RE.match(text.strip())
15
+ if not m:
16
+ return None
17
+ return m.group(1).strip(), m.group(2).strip(), m.group(3).strip()
18
+
19
+
20
+ def mask_numbers(text):
21
+ """'168 + 19 = 187' -> ('#0# + #1# = #2#', ['168', '19', '187'])"""
22
+ nums = []
23
+
24
+ def repl(m):
25
+ nums.append(m.group(0))
26
+ return f"#{len(nums) - 1}#"
27
+
28
+ return NUM_RE.sub(repl, text), nums
29
+
30
+
31
+ def unmask_numbers(text, nums):
32
+ """Restore. Returns (text, ok) — ok is False if any placeholder was lost or duplicated."""
33
+ seen = []
34
+
35
+ def repl(m):
36
+ i = int(m.group(1))
37
+ seen.append(i)
38
+ return nums[i] if i < len(nums) else m.group(0)
39
+
40
+ out = PH_RE.sub(repl, text)
41
+ return out, sorted(seen) == list(range(len(nums)))
42
+
43
+
44
+ def build_record(question, thinking, answer):
45
+ return f"<question>{question}</question> <thinking>{thinking}</thinking> <answer>{answer}</answer>"
code/passk_diag.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pass@1 vs pass@k on the synthetic held-out set, broken down by step count.
3
+
4
+ This is the go/no-go diagnostic for RL with verifiable rewards. RLVR (GRPO/RLOO) reweights samples
5
+ the model ALREADY produces: if a problem is never solved in k tries, every sample in the group gets
6
+ reward 0, the advantage is 0, and there is no gradient. So the headroom RL can capture is bounded
7
+ by (pass@k - pass@1), and only on problems where pass@k > 0.
8
+ """
9
+ import json, os, sys, collections
10
+ import torch, pyarrow.parquet as pq
11
+ from transformers import AutoModelForCausalLM, AutoTokenizer
12
+ sys.path.insert(0, ".")
13
+ from eval_reasoning import numbers, parse
14
+
15
+ MODEL = sys.argv[1] if len(sys.argv) > 1 else "./Nawah-Reasoning-v5"
16
+ PER_BUCKET = int(os.environ.get("PER_BUCKET", 60))
17
+ K = int(os.environ.get("K", 8))
18
+ TEMP = float(os.environ.get("TEMP", 1.0))
19
+
20
+ tok = AutoTokenizer.from_pretrained(MODEL)
21
+ model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).cuda().eval()
22
+
23
+ rows = pq.read_table("out_merged/arabic_math_reasoning_synth.parquet").to_pylist()[:2000]
24
+ buckets = collections.defaultdict(list)
25
+ for r in rows:
26
+ buckets[r["axis_steps"]].append(r)
27
+ sample = [r for b in buckets.values() for r in b[:PER_BUCKET]]
28
+ print(f"[*] {len(sample)} problems x k={K} @ T={TEMP} -> {len(sample)*K} generations", flush=True)
29
+
30
+ def ref_number(r):
31
+ ns = numbers(r["answer"])
32
+ return ns[-1] if ns else None
33
+
34
+ stats = collections.defaultdict(lambda: {"n": 0, "p1": 0, "pk": 0})
35
+ BATCH = 16
36
+ for start in range(0, len(sample), BATCH):
37
+ chunk = sample[start:start + BATCH]
38
+ prompts = [tok.apply_chat_template([{"role": "user", "content": r["instruction"]}],
39
+ tokenize=False, add_generation_prompt=True) for r in chunk]
40
+ enc = tok(prompts, return_tensors="pt", padding=True, padding_side="left").to("cuda")
41
+ torch.manual_seed(1234 + start)
42
+ out = model.generate(**enc, max_new_tokens=320, do_sample=True, temperature=TEMP,
43
+ top_p=0.95, num_return_sequences=K)
44
+ gen = tok.batch_decode(out[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)
45
+ for i, r in enumerate(chunk):
46
+ ref = ref_number(r)
47
+ hits = []
48
+ for j in range(K):
49
+ _, ans, _ = parse(gen[i * K + j])
50
+ ns = numbers(ans or "")
51
+ hits.append(bool(ns) and ref is not None and ns[-1] == ref)
52
+ s = stats[r["axis_steps"]]
53
+ s["n"] += 1
54
+ s["p1"] += hits[0]
55
+ s["pk"] += any(hits)
56
+ print(f" {start + len(chunk)}/{len(sample)}", flush=True)
57
+
58
+ print("\n| steps | n | pass@1 | pass@%d | headroom |" % K)
59
+ print("|---|---:|---:|---:|---:|")
60
+ tot = {"n": 0, "p1": 0, "pk": 0}
61
+ for k, s in sorted(stats.items(), key=lambda kv: kv[1]["n"], reverse=True):
62
+ for f in tot: tot[f] += s[f]
63
+ print(f"| {k} | {s['n']} | {100*s['p1']/s['n']:.1f}% | {100*s['pk']/s['n']:.1f}% | "
64
+ f"{100*(s['pk']-s['p1'])/s['n']:+.1f} |")
65
+ print(f"| **all** | {tot['n']} | {100*tot['p1']/tot['n']:.1f}% | {100*tot['pk']/tot['n']:.1f}% | "
66
+ f"{100*(tot['pk']-tot['p1'])/tot['n']:+.1f} |")
67
+ print(f"\nnever-solved (pass@{K}=0): {100*(tot['n']-tot['pk'])/tot['n']:.1f}% of problems "
68
+ f"-> zero RL gradient on these")
code/prepare_data.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prepare Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset for reasoning SFT.
3
+
4
+ Each row (instruction, answer) becomes a ChatML sample where the derivation lives
5
+ inside <think>...</think> and the conclusion follows it:
6
+
7
+ <|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n<think>\n{reasoning}\n</think>\n{answer}<|im_end|>
8
+
9
+ Only rows whose derivation ends in an explicit conclusion line ("إذن، ...") are kept.
10
+ Expository rows without one have no real "final answer" to place after </think> — their
11
+ closing paragraph is a side remark, so training on them would teach the model to think and
12
+ then trail off. They are dropped rather than force-split.
13
+
14
+ Writes data/train.jsonl and data/eval.jsonl.
15
+ """
16
+ import json, re, random, unicodedata, collections
17
+ from pathlib import Path
18
+
19
+ import pyarrow.parquet as pq
20
+
21
+ SRC = Path("data/data/train-00000-of-00001.parquet")
22
+ OUT_DIR = Path("data")
23
+ EVAL_N = 400
24
+ SEED = 42
25
+
26
+ # Lines that mark the final conclusion of a derivation.
27
+ CONCLUSION = ["إذن،", "إذن ", "لذا،", "لذلك،", "باختصار،", "وبالتالي،", "في النهاية،",
28
+ "الخلاصة", "النتيجة النهائية", "النتيجة:", "الإجابة", "الجواب", "بالتالي،"]
29
+ # Chatty sign-offs that are not part of the answer.
30
+ FLUFF = ["تذكر", "آمل", "أتمنى", "يرجى", "لا تتردد", "إذا كان لديك أي", "هل لديك",
31
+ "أرجو", "نصيحة:", "ملاحظة:", "إذا كانت لديك"]
32
+ # Prompt suffix present on a subset of instructions; stripped so reasoning is unconditional.
33
+ SUFFIX_RE = re.compile(r"\s*خذ\s+نفسًا?\s+عميقًا.*$", re.S)
34
+
35
+ MIN_THINK_CHARS = 60
36
+ MIN_ANSWER_CHARS = 10
37
+
38
+ MD_NOISE_RE = re.compile(r"\*\*|__|#{2,}")
39
+ # Some source rows bundle several problems; the conclusion of one is followed by the next
40
+ # problem's header. Anything from that header on is not part of the answer.
41
+ NEXT_PROBLEM_RE = re.compile(r"^\s*(المشكلة|المسألة|السؤال|التمرين|مثال|Problem|Question|Example)\b")
42
+ ANSWER_ARTIFACT_RE = re.compile(r"^\s*(\*\*)?Answer:\s*", re.I)
43
+
44
+
45
+ def norm(text: str) -> str:
46
+ text = unicodedata.normalize("NFC", text.replace("‏", "").replace("‎", ""))
47
+ text = re.sub(r"[ \t]+", " ", text)
48
+ text = re.sub(r"\n{3,}", "\n\n", text)
49
+ return text.strip()
50
+
51
+
52
+ def clean_instruction(text: str) -> str:
53
+ return norm(SUFFIX_RE.sub("", norm(text))).rstrip(". ").strip() or norm(text)
54
+
55
+
56
+ def is_fluff(line: str) -> bool:
57
+ head = line.lstrip("*#- ").strip()
58
+ return any(head.startswith(f) for f in FLUFF)
59
+
60
+
61
+ def is_conclusion(line: str) -> bool:
62
+ head = line.lstrip("*#- ").strip()
63
+ return any(head.startswith(m) for m in CONCLUSION)
64
+
65
+
66
+ def strip_markup(line: str) -> str:
67
+ """Drop markdown emphasis and the stray "**Answer:" prefix some rows carry."""
68
+ return norm(MD_NOISE_RE.sub("", ANSWER_ARTIFACT_RE.sub("", line)))
69
+
70
+
71
+ def split_answer(answer: str, instruction: str):
72
+ """-> (reasoning, final_answer) or None when the row can't be split cleanly."""
73
+ lines = [strip_markup(l) for l in norm(answer).split("\n")]
74
+ lines = [l for l in lines if l]
75
+ if len(lines) < 2:
76
+ return None
77
+
78
+ # Drop trailing chatter first — it belongs to neither part.
79
+ while lines and is_fluff(lines[-1]):
80
+ lines.pop()
81
+ if len(lines) < 2:
82
+ return None
83
+
84
+ # A leading restatement of the question adds nothing to the derivation.
85
+ if lines and lines[0][:40] == instruction.strip()[:40]:
86
+ lines.pop(0)
87
+
88
+ idx = next((i for i in range(len(lines) - 1, 0, -1) if is_conclusion(lines[i])), None)
89
+ if idx is None:
90
+ return None
91
+ reasoning = "\n".join(lines[:idx])
92
+ tail = lines[idx:]
93
+ cut = next((i for i in range(1, len(tail)) if NEXT_PROBLEM_RE.match(tail[i])), len(tail))
94
+ final = "\n".join(tail[:cut])
95
+
96
+ if len(reasoning) < MIN_THINK_CHARS or len(final) < MIN_ANSWER_CHARS:
97
+ return None
98
+ # A "conclusion" longer than the derivation means the split went the wrong way.
99
+ if len(final) > len(reasoning):
100
+ return None
101
+ return reasoning, final
102
+
103
+
104
+ def main():
105
+ table = pq.read_table(SRC).to_pydict()
106
+ rows = list(zip(table["instruction"], table["answer"]))
107
+ stats = collections.Counter(total=len(rows))
108
+
109
+ seen, samples = set(), []
110
+ for raw_ins, raw_ans in rows:
111
+ ins = clean_instruction(raw_ins)
112
+ key = re.sub(r"\W+", "", ins)
113
+ if key in seen:
114
+ stats["dropped_duplicate"] += 1
115
+ continue
116
+ seen.add(key)
117
+
118
+ split = split_answer(raw_ans, ins)
119
+ if split is None:
120
+ stats["dropped_no_conclusion"] += 1
121
+ continue
122
+ reasoning, final = split
123
+ stats["kept"] += 1
124
+ samples.append({"instruction": ins, "reasoning": reasoning, "answer": final})
125
+
126
+ random.Random(SEED).shuffle(samples)
127
+ eval_set, train_set = samples[:EVAL_N], samples[EVAL_N:]
128
+
129
+ OUT_DIR.mkdir(exist_ok=True)
130
+ for name, split in (("train", train_set), ("eval", eval_set)):
131
+ with open(OUT_DIR / f"{name}.jsonl", "w", encoding="utf-8") as fh:
132
+ for s in split:
133
+ fh.write(json.dumps(s, ensure_ascii=False) + "\n")
134
+ print(f"[+] {name}: {len(split)} samples -> {OUT_DIR / f'{name}.jsonl'}")
135
+
136
+ print("[*] stats:", dict(stats))
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
code/prepare_gsm_sft.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Convert the Arabic GSM8K dataset into the SFT schema train_reasoning.py already reads
3
+ ({instruction, reasoning, answer}), so v2 trains in the same ChatML + <think> format as v1.
4
+
5
+ The final answer is kept as the bare numeral: it matches the source's intent and makes
6
+ exact-match evaluation trivial.
7
+ """
8
+ import json
9
+ import random
10
+ from pathlib import Path
11
+
12
+ import pyarrow.parquet as pq
13
+
14
+ SRC = Path("out_gsm/gsm8k_reasoning_ar.parquet")
15
+ OUT = Path("data_gsm_sft")
16
+ EVAL_N = 2000
17
+ SEED = 42
18
+
19
+
20
+ def main():
21
+ d = pq.read_table(SRC).to_pydict()
22
+ rows = [
23
+ {"instruction": q, "reasoning": t, "answer": a}
24
+ for q, t, a in zip(d["question"], d["thinking"], d["answer"])
25
+ ]
26
+ random.Random(SEED).shuffle(rows)
27
+ eval_rows, train_rows = rows[:EVAL_N], rows[EVAL_N:]
28
+
29
+ OUT.mkdir(exist_ok=True)
30
+ for name, split in (("train", train_rows), ("eval", eval_rows)):
31
+ with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh:
32
+ for r in split:
33
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
34
+ print(f"[+] {name}: {len(split)} -> {OUT / f'{name}.jsonl'}")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
code/prepare_v6_sft.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build the v6 SFT corpus: the v5 three-way mix, with the synthetic corpus swapped for the larger
3
+ one that carries the relational pool.
4
+
5
+ This is `prepare_v5_sft.py` with `SYNTH_DIR` repointed and a fourth eval column added. Separate
6
+ file, same reason v5 was separate from v3: v5 is the shipped model and must stay reproducible.
7
+
8
+ The synth split comes from `split_synth_v6.py`, not `build_synth_dataset.py` — the v6 corpus is
9
+ 20,139 rows larger, so a re-drawn shuffle would put 1,955 of v5's held-out rows into v6's train.
10
+ The eval rows are pinned to v5's instead, which keeps the synth cell comparable and, because
11
+ `eval.jsonl` is byte-identical to v5's, makes the mix-eval synth column the *same 400 rows* v5 was
12
+ scored on.
13
+
14
+ `eval_rel.jsonl` (400 relational rows, held out of train) is the column that actually measures
15
+ what v6 was built for — none of the legacy eval rows test a relational comparison.
16
+
17
+ data/{train,eval}.jsonl <- prepare_data.py (Arabic_Reasoning_Dataset)
18
+ data_gsm_sft/{train,eval}.jsonl <- prepare_gsm_sft.py (oddadmix/gsm8k-reasoning-ar)
19
+ data_synth_v6_sft/{train,eval,eval_rel}.jsonl <- split_synth_v6.py (120,462-row corpus)
20
+
21
+ Why all three: v3 scores 77.3% on GSM8K-ar but 2.0% on the synth held-out set, and v4 (synth only)
22
+ inverts that — 35.6% synth, 19.5% GSM. Neither corpus alone covers the other's distribution, so v5
23
+ trains on all three at once.
24
+
25
+ Answer styles are NOT normalised — GSM8K ends in a bare numeral, Arabic_Reasoning and the synth
26
+ corpus in an "إذن، …" sentence. A `source` tag rides on every row so eval_reasoning.py scores each
27
+ on its own terms. The eval set keeps v1's 400 AR rows and v2's first 600 GSM rows *unchanged*, so
28
+ the AR and GSM cells stay directly comparable to v1/v2/v3.
29
+
30
+ Writes data_v6_sft/{train,eval}.jsonl.
31
+ """
32
+ import json
33
+ import os
34
+ import random
35
+ from collections import Counter
36
+ from pathlib import Path
37
+
38
+ AR_DIR = Path("data") # v1 splits
39
+ GSM_DIR = Path("data_gsm_sft") # v2 splits
40
+ SYNTH_DIR = Path("data_synth_v6_sft") # the 120,462-row corpus, relational pool included
41
+ OUT = Path("data_v6_sft")
42
+
43
+ # Arabic_Reasoning is ~25x smaller than GSM8K, so it is repeated (same value v3 used).
44
+ REPEAT = int(os.environ.get("REPEAT", 3))
45
+ SYNTH_REPEAT = int(os.environ.get("SYNTH_REPEAT", 1))
46
+ # Eval: v1's 400 AR rows are all kept and v2's first 600 GSM rows, exactly as v3 built them, plus
47
+ # synth rows so all three distributions are scored in one pass.
48
+ EVAL_GSM = int(os.environ.get("EVAL_GSM", 600))
49
+ EVAL_SYNTH = int(os.environ.get("EVAL_SYNTH", 400))
50
+ EVAL_REL = int(os.environ.get("EVAL_REL", 400))
51
+ SEED = 42
52
+
53
+
54
+ def load(path):
55
+ with open(path, encoding="utf-8") as fh:
56
+ return [json.loads(line) for line in fh]
57
+
58
+
59
+ def tag(rows, source):
60
+ return [{**r, "source": source} for r in rows]
61
+
62
+
63
+ def token_stats(rows, tok):
64
+ per_source, lengths = Counter(), []
65
+ for r in rows:
66
+ text = (f"<|im_start|>user\n{r['instruction']}<|im_end|>\n<|im_start|>assistant\n"
67
+ f"<think>\n{r['reasoning']}\n</think>\n{r['answer']}<|im_end|>")
68
+ n = len(tok.encode(text, add_special_tokens=False))
69
+ per_source[r["source"]] += n
70
+ lengths.append(n)
71
+ lengths.sort()
72
+ pct = lambda p: lengths[min(int(len(lengths) * p / 100), len(lengths) - 1)]
73
+ return per_source, {"p50": pct(50), "p95": pct(95), "p99": pct(99), "max": lengths[-1]}
74
+
75
+
76
+ def interleave(groups):
77
+ """Round-robin proportional to each group's size, so a truncated eval run still covers all
78
+ three sources instead of whichever landed first."""
79
+ groups = [g for g in groups if g]
80
+ if not groups:
81
+ return []
82
+ total = sum(len(g) for g in groups)
83
+ out, idx = [], [0] * len(groups)
84
+ for _ in range(total):
85
+ # pick the group that is furthest behind its target share
86
+ pick = min(range(len(groups)), key=lambda i: (idx[i] / len(groups[i])) if idx[i] < len(groups[i]) else 2.0)
87
+ if idx[pick] >= len(groups[pick]):
88
+ break
89
+ out.append(groups[pick][idx[pick]])
90
+ idx[pick] += 1
91
+ for g, i in zip(groups, idx): # anything the loop could not place
92
+ out.extend(g[i:])
93
+ return out
94
+
95
+
96
+ def main():
97
+ for d in (AR_DIR, GSM_DIR, SYNTH_DIR):
98
+ for split in ("train", "eval"):
99
+ if not (d / f"{split}.jsonl").exists():
100
+ raise SystemExit(f"missing {d/f'{split}.jsonl'}")
101
+
102
+ ar_train = tag(load(AR_DIR / "train.jsonl"), "arabic_reasoning")
103
+ ar_eval = tag(load(AR_DIR / "eval.jsonl"), "arabic_reasoning")
104
+ gsm_train = tag(load(GSM_DIR / "train.jsonl"), "gsm8k_ar")
105
+ gsm_eval = tag(load(GSM_DIR / "eval.jsonl"), "gsm8k_ar")
106
+ synth_train = tag(load(SYNTH_DIR / "train.jsonl"), "synth_math_ar")
107
+ synth_eval = tag(load(SYNTH_DIR / "eval.jsonl"), "synth_math_ar")
108
+ rel_eval = tag(load(SYNTH_DIR / "eval_rel.jsonl"), "synth_relational_ar")
109
+
110
+ train = gsm_train + ar_train * REPEAT + synth_train * SYNTH_REPEAT
111
+ random.Random(SEED).shuffle(train)
112
+
113
+ eval_rows = interleave([ar_eval, gsm_eval[:EVAL_GSM], synth_eval[:EVAL_SYNTH],
114
+ rel_eval[:EVAL_REL]])
115
+
116
+ OUT.mkdir(exist_ok=True)
117
+ for name, split in (("train", train), ("eval", eval_rows)):
118
+ with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh:
119
+ for r in split:
120
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
121
+ print(f"[+] {name}: {len(split):,} -> {OUT / f'{name}.jsonl'} "
122
+ f"{dict(Counter(r['source'] for r in split))}")
123
+
124
+ try:
125
+ from transformers import AutoTokenizer
126
+ tok = AutoTokenizer.from_pretrained(os.environ.get("BASE_MODEL", "/notebooks/50M/50M-2048-Emhotob"))
127
+ tok.add_special_tokens({"additional_special_tokens": ["<|im_start|>", "<|im_end|>", "<think>", "</think>"]})
128
+ per_source, pct = token_stats(train, tok)
129
+ total = sum(per_source.values())
130
+ print(f"[*] train tokens: {total/1e6:.1f}M/epoch (REPEAT={REPEAT}, SYNTH_REPEAT={SYNTH_REPEAT})")
131
+ for src, n in per_source.most_common():
132
+ print(f" {src:<18} {n/1e6:6.2f}M {100*n/total:5.1f}%")
133
+ print(f"[*] sample length: p50 {pct['p50']} p95 {pct['p95']} p99 {pct['p99']} max {pct['max']}")
134
+ except Exception as exc:
135
+ print(f"[!] token stats skipped: {exc}")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
code/push_dataset.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Push the Arabic GSM8K reasoning dataset to the Hub as a PRIVATE dataset repo.
3
+
4
+ Usage: python push_dataset.py [--repo oddadmix/gsm8k-reasoning-ar] [--dry-run]
5
+ """
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import pyarrow.parquet as pq
11
+ from huggingface_hub import HfApi
12
+
13
+ OUT = Path("out_gsm")
14
+ PARQUET = OUT / "gsm8k_reasoning_ar.parquet"
15
+ SOURCE = "Ajhesh7/gsm8k-reasoning-SFT-datas"
16
+ MT_MODEL = "ByteDance-Seed/Seed-X-PPO-7B"
17
+
18
+ CARD = """---
19
+ license: apache-2.0
20
+ language:
21
+ - ar
22
+ - en
23
+ task_categories:
24
+ - text-generation
25
+ tags:
26
+ - arabic
27
+ - reasoning
28
+ - chain-of-thought
29
+ - math
30
+ - gsm8k
31
+ - machine-translated
32
+ size_categories:
33
+ - 100K<n<1M
34
+ dataset_info:
35
+ features:
36
+ - name: text
37
+ dtype: string
38
+ - name: question
39
+ dtype: string
40
+ - name: thinking
41
+ dtype: string
42
+ - name: answer
43
+ dtype: string
44
+ - name: question_en
45
+ dtype: string
46
+ - name: thinking_en
47
+ dtype: string
48
+ - name: source_index
49
+ dtype: int64
50
+ splits:
51
+ - name: train
52
+ num_examples: {rows}
53
+ configs:
54
+ - config_name: default
55
+ data_files:
56
+ - split: train
57
+ path: data/train-*
58
+ ---
59
+
60
+ # GSM8K Reasoning — Arabic (مترجم آليًا)
61
+
62
+ **{rows:,}** grade-school math reasoning items translated from English into Arabic with
63
+ [`{mt}`](https://huggingface.co/{mt}), a 7B translation model.
64
+
65
+ Source: [`{source}`](https://huggingface.co/datasets/{source}) (600,000 rows).
66
+
67
+ > **بالعربية:** مجموعة بيانات للاستدلال الرياضي بالعربية، مترجمة آليًا من الإنجليزية.
68
+ > كل مثال يحتوي على سؤال، وخطوات التفكير، والإجابة النهائية.
69
+
70
+ ## Format
71
+
72
+ `text` keeps the source's tag layout, with Arabic content:
73
+
74
+ ```
75
+ <question>يجمع فريا 168 صندوقًا وجمعت هانا 19 صندوقًا...</question> <thinking>دعونا نفكر خطوة بخطوة...</thinking> <answer>187</answer>
76
+ ```
77
+
78
+ The parts are also available as separate columns — `question`, `thinking`, `answer` (Arabic;
79
+ `answer` is the untouched numeral) — with `question_en` / `thinking_en` carrying the English
80
+ source so every row is auditable, and `source_index` pointing back into the source dataset.
81
+
82
+ ## How it was built
83
+
84
+ 1. **Sampling.** {selected:,} of the 600,000 source rows. The corpus is generated from only
85
+ **2,814** underlying question patterns (numbers and names masked), so the sample is stratified
86
+ by pattern with a floor of {floor} rows per pattern — every pattern is represented rather than
87
+ over-weighting the common ones.
88
+ 2. **Translation.** Question and reasoning translated separately, each as its own sentence, with
89
+ `Translate the following English sentence into Arabic:\\n{{text}} <ar>` and greedy decoding.
90
+ Numbers and names were left in place rather than masked, so Arabic gender agreement follows the
91
+ actual name (`اشترت` for Aisha) and number agreement follows the actual quantity. The final
92
+ `answer` numeral is never sent to the translator.
93
+ 3. **Validation.** A row is kept only if, for **both** segments, the numbers in the Arabic exactly
94
+ match the English (order-insensitive), the output is non-empty Arabic script, has no degenerate
95
+ repetition loop, and has no significant Latin-script residue. **{kept_pct:.2f}%** of translated
96
+ rows passed.
97
+
98
+ Rejection breakdown: `{rejects}`
99
+
100
+ ## Limitations
101
+
102
+ This is **machine translation**, not human-verified Arabic. It inherits the source's synthetic,
103
+ templated phrasing — {selected:,} rows expand from 2,814 patterns, so linguistic diversity is far
104
+ lower than the row count suggests.
105
+
106
+ **Gender agreement.** The English source pairs names with pronouns arbitrarily ("This week Emil
107
+ did chores and earned $76. **She** bought a bottle…"), which English mostly hides but Arabic does
108
+ not: a row can read `قام جورج …` and then `اشترت …` for the same person. The translator rendered
109
+ the source faithfully; the disagreement is upstream, and it is visible throughout. The arithmetic itself is copied from the source and was not
110
+ re-verified; in the source, the reasoning's final number agrees with the `answer` field ~96.6% of
111
+ the time, so a small fraction of items are internally inconsistent. Suitable for SFT on reasoning
112
+ *format* and basic Arabic math phrasing; not a benchmark.
113
+ """
114
+
115
+
116
+ def main():
117
+ ap = argparse.ArgumentParser()
118
+ ap.add_argument("--repo", default="oddadmix/gsm8k-reasoning-ar")
119
+ ap.add_argument("--dry-run", action="store_true")
120
+ args = ap.parse_args()
121
+
122
+ stats = json.loads((OUT / "build_stats.json").read_text(encoding="utf-8"))
123
+ rows = pq.ParquetFile(PARQUET).metadata.num_rows
124
+ floor = max(1, stats["selected"] // (2814 * 4))
125
+
126
+ card = CARD.format(
127
+ rows=rows, mt=MT_MODEL, source=SOURCE, selected=stats["selected"],
128
+ kept_pct=stats["kept_pct"], rejects=stats["rejects"], floor=floor,
129
+ )
130
+ (OUT / "README.md").write_text(card, encoding="utf-8")
131
+ print(f"[+] wrote card ({len(card)} chars), {rows} rows")
132
+
133
+ if args.dry_run:
134
+ print("[dry-run] not pushing")
135
+ return
136
+
137
+ api = HfApi()
138
+ api.create_repo(args.repo, repo_type="dataset", private=True, exist_ok=True)
139
+ api.upload_file(path_or_fileobj=str(PARQUET), path_in_repo="data/train-00000-of-00001.parquet",
140
+ repo_id=args.repo, repo_type="dataset")
141
+ api.upload_file(path_or_fileobj=str(OUT / "README.md"), path_in_repo="README.md",
142
+ repo_id=args.repo, repo_type="dataset")
143
+ for script in ("gsm_common.py", "translate_gsm.py", "build_dataset.py"):
144
+ api.upload_file(path_or_fileobj=script, path_in_repo=f"scripts/{script}",
145
+ repo_id=args.repo, repo_type="dataset")
146
+ print(f"[+] https://huggingface.co/datasets/{args.repo}")
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
code/push_release.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Public release of the v6 weights as **oddadmix/Nawah-Math-Reasoning**.
3
+
4
+ This is `push_model_v6.py` retargeted at a public repo under the release name. The differences
5
+ that matter:
6
+
7
+ * `private=False` — this is the release, unlike every rung of the v1→v6 ladder.
8
+ * The card is written for someone who has never seen the ladder. v1–v5 stay private, so the
9
+ comparison table names them but does not link them, and the text does not send readers to
10
+ repos they cannot open.
11
+ * The training code ships **inside the repo** under `code/`, so the recipe and the weights are
12
+ one link. `code/README.md` is generated by `build_release_code.py`.
13
+
14
+ Every number in the card is still read from an eval file on disk — nothing is typed by hand:
15
+
16
+ Nawah-Reasoning-v6/eval_reasoning.json v6 on the 1,800-row v6 mix eval (by_source, 4 sources)
17
+ Nawah-Reasoning-v6/eval_on_synth.json v6 on the same 1,000-row synth set v4/v5 used
18
+ Nawah-Reasoning-v5/eval_reasoning.json v5 on the 1,400-row v5 mix eval
19
+ Nawah-Reasoning-v5/eval_on_synth.json v5, same 1,000 synth rows
20
+ Nawah-Reasoning-v5/eval_on_rel.json v5 on v6's 400 held-out relational rows (the baseline)
21
+ Nawah-Reasoning-v4/eval_on_mix.json v4 on v3's mix eval
22
+ Nawah-Reasoning-v4/eval_on_synth.json v4, same 1,000 synth rows
23
+ Nawah-Reasoning-v3/eval_reasoning.json v3 on the same mix eval
24
+ Nawah-Reasoning-v3/eval_on_synth.json v3, same 1,000 synth rows
25
+
26
+ Usage: python push_release.py [--repo oddadmix/Nawah-Math-Reasoning] [--dry-run] [--no-code]
27
+ """
28
+ import argparse
29
+ import json
30
+ from pathlib import Path
31
+
32
+ from huggingface_hub import HfApi
33
+
34
+ MODEL_DIR = Path("./Nawah-Reasoning-v6")
35
+ V5_DIR = Path("./Nawah-Reasoning-v5")
36
+ V4_DIR = Path("./Nawah-Reasoning-v4")
37
+ V3_DIR = Path("./Nawah-Reasoning-v3")
38
+ CODE_DIR = Path("./release_code")
39
+ BASE = "oddadmix/50M-2048-Emhotob"
40
+ DEMO = "oddadmix/Nawah-Math-Reasoning-Demo"
41
+
42
+ CARD = """---
43
+ license: apache-2.0
44
+ language:
45
+ - ar
46
+ base_model: {base}
47
+ datasets:
48
+ - oddadmix/arabic-math-reasoning-synth
49
+ - oddadmix/gsm8k-reasoning-ar
50
+ - Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset
51
+ library_name: transformers
52
+ pipeline_tag: text-generation
53
+ tags:
54
+ - arabic
55
+ - reasoning
56
+ - chain-of-thought
57
+ - math
58
+ - gsm8k
59
+ - small-language-model
60
+ - slm
61
+ - llama
62
+ - sft
63
+ ---
64
+
65
+ # Nawah-Math-Reasoning — نموذج استدلال رياضي عربي
66
+
67
+ A **{params:.1f}M-parameter** Arabic math reasoning model. It writes its derivation step by step
68
+ inside `<think>…</think>`, then gives the answer. It is small enough to run on a CPU.
69
+
70
+ > **بالعربية:** نموذج عربي صغير (~{params:.0f} مليون معامل) لحل المسائل الحسابية: يكتب خطوات
71
+ > تفكيره داخل وسم `<think>` ثم يعطي الإجابة. صغير بما يكفي ليعمل على المعالج (CPU).
72
+
73
+ | | |
74
+ |---|---|
75
+ | 🤗 **Demo** | [`{demo}`](https://huggingface.co/spaces/{demo}) |
76
+ | 🧩 **Base model** | [`{base}`](https://huggingface.co/{base}) — Llama architecture, 12 layers, hidden 512, 2048 ctx, pre-trained from scratch on ~20B Arabic tokens |
77
+ | 📚 **Data** | [`arabic-math-reasoning-synth`](https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth) · [`gsm8k-reasoning-ar`](https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar) · [`Arabic_Reasoning_Dataset`](https://huggingface.co/datasets/Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset) |
78
+ | 🛠️ **Training code** | [`code/`](https://huggingface.co/{repo}/tree/main/code) in this repo — data generation, translation, SFT, eval, GRPO |
79
+ | 🔤 **Vocab** | {vocab} (4 chat/reasoning tokens added to the 32000 base vocab) |
80
+
81
+ ## Results
82
+
83
+ Number agreement, greedy decoding. **Every cell is measured on identical held-out rows.** The
84
+ `Arabic_Reasoning` and `GSM8K-ar` rows are the eval splits fixed at the start of the project and
85
+ never re-drawn; the synthetic rows are pinned to the same 1,000 items every earlier version was
86
+ scored on.
87
+
88
+ The `v3 / v4 / v5` columns are internal development runs, kept here because they are what makes
89
+ the release number mean something. They are not published — the numbers are, so the ablation is
90
+ readable without them.
91
+
92
+ | eval set | n | v3 | v4 | v5 | **release** |
93
+ |---|---:|---:|---:|---:|---:|
94
+ | GSM8K-ar | {g_n} | {v3_gsm:.1f}% | {v4_gsm:.1f}% | {v5_gsm:.1f}% | **{v6_gsm:.1f}%** |
95
+ | Arabic_Reasoning | {a_n} | {v3_ar:.1f}% | {v4_ar:.1f}% | **{v5_ar:.1f}%** | {v6_ar:.1f}% |
96
+ | synthetic math | 1000 | {v3_synth:.1f}% | {v4_synth:.1f}% | {v5_synth:.1f}% | **{v6_synth:.1f}%** |
97
+ | **synthetic relational** | {r_n} | — | — | {v5_rel:.1f}% | **{v6_rel:.1f}%** |
98
+
99
+ **The relational row is what this release adds.** On problems whose difficulty is the *relation*
100
+ between quantities (`ضعف`, `نصف`, `أكثر بـ…`) rather than the arithmetic, it scores
101
+ **{v6_rel:.1f}%** where the previous run scores {v5_rel:.1f}% — a **{rel_gain:+.1f} point** gain and
102
+ the largest single-cell move anywhere in the development ladder. It did not cost the other
103
+ distributions: GSM8K-ar is simultaneously the best of the series at **{v6_gsm:.1f}%**, and
104
+ synthetic math gains {synth_delta:+.1f}.
105
+
106
+ The one regression is `Arabic_Reasoning` at **{ar_delta:+.1f}** against v5 — on {a_n} rows that is
107
+ close to sampling noise, but it is the second consecutive mix where this column is the give.
108
+
109
+ | detail | GSM8K-ar | Arabic_Reasoning | synth math | synth relational |
110
+ |---|---:|---:|---:|---:|
111
+ | final-answer number correct | {g_primary:.1f}% | {a_primary:.1f}% | {s_primary:.1f}% | {r_primary:.1f}% |
112
+ | all numbers match | {v6_gsm:.1f}% | {v6_ar:.1f}% | {s_nums:.1f}% | {v6_rel:.1f}% |
113
+ | well-formed `<think>` + answer | {g_well:.1f}% | {a_well:.1f}% | {s_well:.1f}% | {r_well:.1f}% |
114
+ | mean reasoning length | {g_tokens:.0f} tok | {a_tokens:.0f} tok | {s_tokens:.0f} tok | {r_tokens:.0f} tok |
115
+
116
+ *(the synth-math column here is the 400-row mix cell; the {v6_synth:.1f}% in the table above is the
117
+ 1,000-row set used for the cross-model comparison.)*
118
+
119
+ Reproduce any cell with `code/eval_reasoning.py` — it is the same script for every model and every
120
+ row, which is the only reason these are comparable.
121
+
122
+ ### The final checkpoint ships, and eval loss disagrees
123
+
124
+ Loss bottoms at **{best_loss:.4f}** (epoch {best_epoch:.2f}) and rises to **{shipped_loss:.4f}** by
125
+ epoch {epochs} — yet the epoch-{epochs} weights are the better model. This was measured directly on
126
+ an earlier run whose corpus contained **no repeated rows**, which rules out memorisation: the
127
+ minimum-loss checkpoint scored 30.9% where the final scored 35.6%. It happened on four consecutive
128
+ runs. `train_reasoning.py` therefore takes `LOAD_BEST=0`, and that is not an oversight.
129
+
130
+ ## Training mix
131
+
132
+ {train_n:,} rows, {tok_per_epoch:.1f}M tokens/epoch:
133
+
134
+ | source | rows | tokens/epoch | share |
135
+ |---|---:|---:|---:|
136
+ | [`oddadmix/arabic-math-reasoning-synth`](https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth) | {synth_rows:,} | {synth_tok:.2f}M | {synth_share:.1f}% |
137
+ | [`oddadmix/gsm8k-reasoning-ar`](https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar) | {gsm_rows:,} | {gsm_tok:.2f}M | {gsm_share:.1f}% |
138
+ | [`Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset`](https://huggingface.co/datasets/Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset) | {ar_rows:,} (5,536 × 3) | {ar_tok:.2f}M | {ar_share:.1f}% |
139
+
140
+ Of the synthetic corpus's {corpus_rows:,} rows, {rel_rows:,} are **relational** problems generated
141
+ specifically for this release, after a `pass@k` diagnostic showed the previous model went 0/8 on
142
+ `ضعف`-style problems and a corpus audit found the relation appears in only 1.34% of rows. The
143
+ synthetic eval split was **pinned, not re-drawn** when those rows were added: re-shuffling would
144
+ have moved 1,955 of the 2,000 previously held-out items into train, turning that column into a
145
+ memorisation score.
146
+
147
+ Full fine-tune from the base (not from the previous version). Loss on the assistant turn only, user
148
+ prompt masked with `-100`. `Arabic_Reasoning` is ~25× smaller than GSM8K, so it is repeated 3×.
149
+
150
+ | | |
151
+ |---|---|
152
+ | epochs | {epochs} ({steps:,} steps) |
153
+ | effective batch | 64 |
154
+ | learning rate | 3e-4 cosine, 200 warmup steps |
155
+ | max length | 768 tokens (mix p100 is {p100} — nothing truncated) |
156
+ | precision | bf16 |
157
+ | checkpoint | final (`load_best_model_at_end` disabled — it picks the worse model) |
158
+ | hardware | 1× RTX A6000, ~{minutes} min |
159
+
160
+ ## Usage
161
+
162
+ ```python
163
+ from transformers import AutoModelForCausalLM, AutoTokenizer
164
+ import torch
165
+
166
+ model_id = "{repo}"
167
+ tok = AutoTokenizer.from_pretrained(model_id)
168
+ model = AutoModelForCausalLM.from_pretrained(model_id, dtype=torch.bfloat16).eval()
169
+
170
+ messages = [{{"role": "user", "content": "اشترى خالد 4 دفاتر بسعر 15 جنيهًا للدفتر، ودفع بورقة 100 جنيه. كم المبلغ المتبقي؟"}}]
171
+ prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
172
+ ids = tok(prompt, return_tensors="pt")
173
+
174
+ out = model.generate(**ids, max_new_tokens=384, do_sample=False)
175
+ print(tok.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=False))
176
+ ```
177
+
178
+ Split the parts with `re.match(r"\\s*<think>(.*?)</think>(.*)", completion, re.S)`.
179
+ Decode with `skip_special_tokens=False` — `<think>` and `</think>` are real tokens in this
180
+ tokenizer, and stripping them destroys the split.
181
+
182
+ It is **single-turn**: one user message per call. Chat history is out of distribution.
183
+
184
+ **Answer style is not something you can request.** The three corpora disagree — GSM8K rows end in a
185
+ bare numeral, the other two in an `إذن، …` sentence — and arithmetic word problems look alike in
186
+ all of them, so the model picks a style per prompt. **Score it on number agreement, not exact
187
+ string match**, and parse the answer by extracting its numbers.
188
+
189
+ ## Limitations
190
+
191
+ At ~{params:.0f}M parameters this is a **proof of concept**, and the honest headline is the
192
+ synthetic columns — **{v6_synth:.1f}%** and **{v6_rel:.1f}%** on multi-step problems, well below the
193
+ {v6_gsm:.1f}% it scores on GSM8K's narrower phrasing. Arithmetic is the dominant failure mode: the
194
+ reasoning is usually structurally right, one computation step is wrong, and the model then stays
195
+ faithful to its own bad number.
196
+
197
+ Each corpus brings its own defect. The GSM8K half is machine-translated, its 140,969 rows expanding
198
+ from only 2,814 question patterns, so that score partly reflects narrow phrasing. The synthetic
199
+ half is verified for **arithmetic, not for sense** — rows survive where every equation checks out
200
+ but a step introduces an entity never mentioned, or the answer resolves the reverse of what was
201
+ asked. The `Arabic_Reasoning` half excludes open-ended expository rows (they have no final answer
202
+ to place after `</think>`), so expository prompts remain out of distribution.
203
+
204
+ Everything is MSA; the synthetic corpus's region axis sets currency and context, not dialect. The
205
+ Arabic inherits source artifacts including inconsistent gender agreement. Its reasoning trace is
206
+ not a faithful account of any internal computation. Do not use it for anything consequential.
207
+
208
+ ## Citation
209
+
210
+ ```bibtex
211
+ @misc{{nawah_math_reasoning_2026,
212
+ title = {{Nawah-Math-Reasoning: a 52M-parameter Arabic chain-of-thought math model}},
213
+ author = {{Ahmed Wasfy}},
214
+ year = {{2026}},
215
+ url = {{https://huggingface.co/{repo}}}
216
+ }}
217
+ ```
218
+ """
219
+
220
+
221
+ def main():
222
+ ap = argparse.ArgumentParser()
223
+ ap.add_argument("--repo", default="oddadmix/Nawah-Math-Reasoning")
224
+ ap.add_argument("--dry-run", action="store_true")
225
+ ap.add_argument("--no-code", action="store_true", help="skip the code/ upload")
226
+ ap.add_argument("--minutes", default="85")
227
+ args = ap.parse_args()
228
+
229
+ def summary(p):
230
+ return json.loads(Path(p).read_text(encoding="utf-8"))["summary"]
231
+
232
+ v6 = summary(MODEL_DIR / "eval_reasoning.json")
233
+ v6_synth = summary(MODEL_DIR / "eval_on_synth.json")
234
+ v5_mix = summary(V5_DIR / "eval_reasoning.json")
235
+ v5_synth = summary(V5_DIR / "eval_on_synth.json")
236
+ v5_rel = summary(V5_DIR / "eval_on_rel.json")
237
+ v4_mix, v4_synth = summary(V4_DIR / "eval_on_mix.json"), summary(V4_DIR / "eval_on_synth.json")
238
+ v3_mix, v3_synth = summary(V3_DIR / "eval_reasoning.json"), summary(V3_DIR / "eval_on_synth.json")
239
+
240
+ g, a, s, r = (v6["by_source"]["gsm8k_ar"], v6["by_source"]["arabic_reasoning"],
241
+ v6["by_source"]["synth_math_ar"], v6["by_source"]["synth_relational_ar"])
242
+
243
+ tm = json.loads(Path(MODEL_DIR, "train_metrics.json").read_text(encoding="utf-8"))
244
+ cfg = json.loads(Path(MODEL_DIR, "config.json").read_text(encoding="utf-8"))
245
+ evals = [h for h in tm["log_history"] if "eval_loss" in h]
246
+ last = max(h["step"] for h in evals)
247
+ shipped_loss = next(h["eval_loss"] for h in evals if h["step"] == last)
248
+ best = min(evals, key=lambda h: h["eval_loss"])
249
+
250
+ v3_gsm, v4_gsm, v5_gsm = (v3_mix["by_source"]["gsm8k_ar"], v4_mix["by_source"]["gsm8k_ar"],
251
+ v5_mix["by_source"]["gsm8k_ar"])
252
+ v3_ar, v4_ar, v5_ar = (v3_mix["by_source"]["arabic_reasoning"],
253
+ v4_mix["by_source"]["arabic_reasoning"],
254
+ v5_mix["by_source"]["arabic_reasoning"])
255
+
256
+ # measured from data_v6_sft/train.jsonl with the base tokenizer (+12 tok/row of chat scaffold)
257
+ MIX = {"synth_math_ar": (118062, 16.79, 53.9), "gsm8k_ar": (140969, 11.88, 38.2),
258
+ "arabic_reasoning": (16608, 2.45, 7.9)}
259
+
260
+ card = CARD.format(
261
+ repo=args.repo, base=BASE, demo=DEMO, params=51.79, vocab=cfg["vocab_size"],
262
+ g_n=g["n"], a_n=a["n"], r_n=r["n"],
263
+ v3_gsm=v3_gsm["numbers_match_pct"], v4_gsm=v4_gsm["numbers_match_pct"],
264
+ v5_gsm=v5_gsm["numbers_match_pct"], v6_gsm=g["numbers_match_pct"],
265
+ v3_ar=v3_ar["numbers_match_pct"], v4_ar=v4_ar["numbers_match_pct"],
266
+ v5_ar=v5_ar["numbers_match_pct"], v6_ar=a["numbers_match_pct"],
267
+ v3_synth=v3_synth["numbers_match_pct"], v4_synth=v4_synth["numbers_match_pct"],
268
+ v5_synth=v5_synth["numbers_match_pct"], v6_synth=v6_synth["numbers_match_pct"],
269
+ v5_rel=v5_rel["numbers_match_pct"], v6_rel=r["numbers_match_pct"],
270
+ rel_gain=r["numbers_match_pct"] - v5_rel["numbers_match_pct"],
271
+ ar_delta=a["numbers_match_pct"] - v5_ar["numbers_match_pct"],
272
+ synth_delta=v6_synth["numbers_match_pct"] - v5_synth["numbers_match_pct"],
273
+ g_primary=g["primary_number_match_pct"], a_primary=a["primary_number_match_pct"],
274
+ s_primary=s["primary_number_match_pct"], r_primary=r["primary_number_match_pct"],
275
+ s_nums=s["numbers_match_pct"],
276
+ g_well=g["well_formed_pct"], a_well=a["well_formed_pct"],
277
+ s_well=s["well_formed_pct"], r_well=r["well_formed_pct"],
278
+ g_tokens=g["mean_reasoning_tokens"], a_tokens=a["mean_reasoning_tokens"],
279
+ s_tokens=s["mean_reasoning_tokens"], r_tokens=r["mean_reasoning_tokens"],
280
+ best_loss=best["eval_loss"], best_epoch=best["epoch"], shipped_loss=shipped_loss,
281
+ epochs=round(max(h.get("epoch", 0) for h in tm["log_history"])),
282
+ steps=max(h.get("step", 0) for h in tm["log_history"]),
283
+ train_n=sum(v[0] for v in MIX.values()),
284
+ tok_per_epoch=sum(v[1] for v in MIX.values()),
285
+ synth_rows=MIX["synth_math_ar"][0], synth_tok=MIX["synth_math_ar"][1],
286
+ synth_share=MIX["synth_math_ar"][2],
287
+ gsm_rows=MIX["gsm8k_ar"][0], gsm_tok=MIX["gsm8k_ar"][1], gsm_share=MIX["gsm8k_ar"][2],
288
+ ar_rows=MIX["arabic_reasoning"][0], ar_tok=MIX["arabic_reasoning"][1],
289
+ ar_share=MIX["arabic_reasoning"][2],
290
+ corpus_rows=120462, rel_rows=20139, p100=703, minutes=args.minutes,
291
+ )
292
+ out = Path("release_README.md")
293
+ out.write_text(card, encoding="utf-8")
294
+ print(f"[+] wrote {out} ({len(card)} chars)")
295
+
296
+ if args.dry_run:
297
+ print("[dry-run] not pushing")
298
+ return
299
+
300
+ api = HfApi()
301
+ api.create_repo(args.repo, private=False, exist_ok=True)
302
+
303
+ # Weights first, then the card, so the repo is never public-and-uncarded for long.
304
+ api.upload_folder(
305
+ folder_path=str(MODEL_DIR), repo_id=args.repo,
306
+ ignore_patterns=["checkpoint-*/*", "checkpoint-*", "eval_on_*.json", "README.md"],
307
+ commit_message="Nawah-Math-Reasoning: weights, tokenizer, eval + training metrics")
308
+ api.upload_file(path_or_fileobj=str(out), path_in_repo="README.md", repo_id=args.repo,
309
+ commit_message="model card")
310
+
311
+ if not args.no_code:
312
+ if not CODE_DIR.is_dir():
313
+ raise SystemExit(f"{CODE_DIR} missing — run build_release_code.py first")
314
+ api.upload_folder(folder_path=str(CODE_DIR), repo_id=args.repo, path_in_repo="code",
315
+ commit_message="training code: data generation, SFT, eval, GRPO")
316
+
317
+ print(f"[+] https://huggingface.co/{args.repo}")
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()
code/push_synth_dataset.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Push the synthetic Arabic math-reasoning corpus to the Hub as a PRIVATE dataset with a card.
3
+
4
+ Usage: python push_synth_dataset.py [--repo oddadmix/arabic-math-reasoning-synth] [--dry-run]
5
+ """
6
+ import argparse
7
+ import collections
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import pyarrow.parquet as pq
13
+ from huggingface_hub import HfApi
14
+
15
+ OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
16
+ PARQUET = OUT_DIR / "arabic_math_reasoning_synth.parquet"
17
+
18
+ # Hub ids for the generators, keyed by the `model` field the nodes record per row.
19
+ GEN_MODEL_HUB = {
20
+ "gemma-3-12b-it": "google/gemma-3-12b-it",
21
+ "Qwen3.6-27B-NVFP4": "nvidia/Qwen3.6-27B-NVFP4",
22
+ "Qwen3.8-27B-Uncensored-NVFP4": "orcarouter/Qwen3.8-27B-Uncensored-NVFP4",
23
+ }
24
+
25
+ CARD = """---
26
+ license: apache-2.0
27
+ language:
28
+ - ar
29
+ size_categories:
30
+ - {size_cat}
31
+ task_categories:
32
+ - text-generation
33
+ tags:
34
+ - arabic
35
+ - math
36
+ - reasoning
37
+ - chain-of-thought
38
+ - gsm8k
39
+ - synthetic
40
+ configs:
41
+ - config_name: default
42
+ data_files:
43
+ - split: train
44
+ path: arabic_math_reasoning_synth.parquet
45
+ ---
46
+
47
+ # Arabic Math Reasoning (synthetic) — مسائل رياضيات عربية مع خطوات الحل
48
+ {status_note}
49
+ **{n:,}** Arabic grade-school math word problems, each with a step-by-step derivation and a
50
+ concluding sentence. Generated with {gen_blurb} and
51
+ **arithmetically verified** — every equation the reasoning states was re-evaluated, and rows whose
52
+ own arithmetic does not check out were dropped.
53
+
54
+ {gen_table}
55
+
56
+ > **بالعربية:** {n:,} مسألة حسابية عربية مع خطوات حل مفصّلة وجملة إجابة نهائية. جميع المعادلات
57
+ > داخل خطوات الحل تم التحقق منها حسابيًا، والصفوف التي تحتوي على خطأ حسابي مستبعدة.
58
+
59
+ ## Schema
60
+
61
+ | field | description |
62
+ |---|---|
63
+ | `instruction` | the word problem, ending in a question |
64
+ | `reasoning` | numbered derivation, each step containing an explicit equation |
65
+ | `answer` | one concluding sentence (`إذن، …`) carrying the final number and its unit |
66
+ | `axis_*` | the variation axes this item was drawn from (domain, operation, steps, numbers, region, twist) |
67
+ | `task_id` | the generation task it came from |
68
+ | `gen_model` | which model generated the row |
69
+
70
+ It matches the `{{instruction, reasoning, answer}}` schema used by the Nawah-Reasoning training
71
+ scripts, so it drops straight into ChatML + `<think>` SFT.
72
+
73
+ ## How it was built
74
+
75
+ Prompts are drawn from a **variation grid** — {n_domains} domains × {n_ops} operation types ×
76
+ step counts × number styles × {n_regions} country/currency pairs × {n_twists} structural twists —
77
+ seeded deterministically from the task index. This is a deliberate reaction to the corpus this was
78
+ built to supplement: a machine-translated Arabic GSM8K whose {gsm_rows:,} rows expand from only
79
+ 2,814 underlying question templates. Here, **{templates:,}** distinct templates survive
80
+ number-masked deduplication.
81
+ {pool_note}
82
+
83
+ ### Verification
84
+
85
+ Generated math is fluent and often wrong, so acceptance is mechanical rather than stylistic:
86
+
87
+ 1. every `a op b = c` in the reasoning is re-evaluated with real arithmetic — one bad equation
88
+ rejects the row;
89
+ 2. the number in the answer sentence must equal the last computed result (a chain that is
90
+ internally correct but ends on a different number is the failure that survives fluency checks);
91
+ 3. no Latin characters, length bounds, and the answer must open with a conclusion marker.
92
+
93
+ **{accept_rate:.1%}** of parsed items passed. Rejections:
94
+
95
+ {reject_table}
96
+
97
+ `out_synth/rejects.jsonl` in the source repo keeps every rejected item with its reason, so the
98
+ filter itself can be audited.
99
+
100
+ ## Limitations
101
+
102
+ This is **synthetic** data: the problems are machine-written and reflect that model's habits of
103
+ phrasing, its distribution of scenarios, and its idea of what a "typical" Arabic math problem
104
+ looks like. Verification proves each row's arithmetic is self-consistent — it does **not** prove
105
+ the problem is well-posed, that the setup is the only reasonable reading, or that the wording is
106
+ natural to any particular dialect region (the `axis_region` field sets currency and context, not
107
+ dialect; everything is MSA). Rows whose reasoning states no explicit equation are dropped, which
108
+ biases the corpus toward problems that decompose into clean arithmetic steps.
109
+ """
110
+
111
+
112
+ def main():
113
+ ap = argparse.ArgumentParser()
114
+ ap.add_argument("--repo", default="oddadmix/arabic-math-reasoning-synth")
115
+ ap.add_argument("--target", type=int, default=100_000)
116
+ ap.add_argument("--dry-run", action="store_true")
117
+ args = ap.parse_args()
118
+
119
+ import synth_common as sc
120
+
121
+ n = pq.read_metadata(PARQUET).num_rows
122
+ stats = json.loads((OUT_DIR / "build_stats.json").read_text(encoding="utf-8"))
123
+
124
+ # Credit every generator that actually contributed, from the build's own per-model counts.
125
+ by_model = collections.Counter({k[len("model_"):]: v
126
+ for k, v in stats["stats"].items() if k.startswith("model_")})
127
+ def _link(name):
128
+ hub = GEN_MODEL_HUB.get(name)
129
+ return f"[`{name}`](https://huggingface.co/{hub})" if hub else f"`{name}`"
130
+ names = [m for m, _ in by_model.most_common()]
131
+ gen_blurb = " and ".join(filter(None, [", ".join(_link(m) for m in names[:-1]),
132
+ _link(names[-1])])) if names else "an unrecorded model"
133
+ gen_table = ("| generator | rows | share |\n|---|---:|---:|\n" + "\n".join(
134
+ f"| {_link(m)} | {c:,} | {100 * c / max(n, 1):.1f}% |"
135
+ for m, c in by_model.most_common())) if len(names) > 1 else ""
136
+ total_rejects = max(sum(stats["reject_reasons"].values()), 1)
137
+ folded = collections.Counter()
138
+ for k, v in stats["reject_reasons"].items():
139
+ folded[k.split(":", 1)[0]] += v
140
+ reject_table = "| reason | count | share of rejects |\n|---|---:|---:|\n" + "\n".join(
141
+ f"| `{k}` | {v:,} | {100 * v / total_rejects:.1f}% |"
142
+ for k, v in folded.most_common(8))
143
+
144
+ if n < args.target:
145
+ status_note = (
146
+ f"\n> ⚠️ **Interim snapshot — generation is still running.** This is "
147
+ f"{n:,} of a planned {args.target:,} rows, published early so it can be reviewed and "
148
+ f"trained against. Rows are only ever *appended*: the generator is resumable and "
149
+ f"deterministic per task index, so everything here stays in the finished corpus "
150
+ f"unchanged. Expect this repo to be overwritten with a larger version.\n")
151
+ else:
152
+ status_note = ""
153
+
154
+ # The relational pool (task ids from 1,000,000) is a second, disjoint operation set added
155
+ # after the first 100,323 rows shipped. Count it from the data rather than asserting it.
156
+ n_rel = sum(1 for tid in pq.read_table(PARQUET, columns=["task_id"]).column("task_id").to_pylist()
157
+ if tid >= 1_000_000)
158
+ pool_note = ("""
159
+ The operation axis is drawn from **two disjoint pools**. The first {n_default:,} rows use 15
160
+ general operations (totals, percentages, unit rates, remainders, …). A later pass added a
161
+ **relational pool** of 8 operations — *ضعف* / *أضعاف*, *نصف*, *ثلث* و *ربع*, absolute increase and
162
+ decrease, three-way chains, and the inverse direction — contributing **{n_rel:,}** rows. These
163
+ express a quantity *relative to another entity* ("Ziad bought **double that number**"), which
164
+ forces a derivation step that a percentage problem does not. In the original corpus *ضعف /
165
+ أضعاف* appeared in 1.34% of rows and *أكثر بـ / أقل بـ* in 0.26% — against 17.6% for percentages —
166
+ and that gap is the documented weak spot of models trained on it, so the pool was generated
167
+ deliberately rather than left to the grid.
168
+ """.format(n_rel=n_rel, n_default=n - n_rel) if n_rel else "")
169
+
170
+ size_cat = "100K<n<1M" if n >= 100_000 else "10K<n<100K" if n >= 10_000 else "1K<n<10K"
171
+ card = CARD.format(
172
+ n=n, gen_blurb=gen_blurb, gen_table=gen_table, size_cat=size_cat,
173
+ n_domains=len(sc.DOMAINS), n_ops=stats["axis_coverage"]["op"], n_regions=len(sc.REGIONS),
174
+ n_twists=len(sc.TWISTS), gsm_rows=142_969,
175
+ templates=stats["unique_templates"], accept_rate=stats["accept_rate"],
176
+ status_note=status_note,
177
+ reject_table=reject_table,
178
+ pool_note=pool_note,
179
+ )
180
+ (OUT_DIR / "README.md").write_text(card, encoding="utf-8")
181
+ print(f"[+] wrote card ({len(card)} chars) | {n:,} rows | accept {stats['accept_rate']:.1%}")
182
+
183
+ if args.dry_run:
184
+ print("[dry-run] not pushing")
185
+ return
186
+
187
+ api = HfApi()
188
+ api.create_repo(args.repo, repo_type="dataset", private=True, exist_ok=True)
189
+ api.upload_file(path_or_fileobj=str(PARQUET), path_in_repo=PARQUET.name,
190
+ repo_id=args.repo, repo_type="dataset")
191
+ api.upload_file(path_or_fileobj=str(OUT_DIR / "README.md"), path_in_repo="README.md",
192
+ repo_id=args.repo, repo_type="dataset")
193
+ for script in ("synth_common.py", "synth_generate.py", "build_synth_dataset.py"):
194
+ api.upload_file(path_or_fileobj=script, path_in_repo=script,
195
+ repo_id=args.repo, repo_type="dataset")
196
+ print(f"[+] https://huggingface.co/datasets/{args.repo}")
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
code/retry_failed.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Second pass over segments whose greedy translation failed validation.
3
+
4
+ Two recovery strategies, tried in order, keeping the first candidate that validates:
5
+ 1. beam search (beam_width=4) — what the Seed-X authors recommend
6
+ 2. sampled best-of-8 — a different part of the distribution when beam search repeats the error
7
+
8
+ Recovered translations are appended to the cache, overriding the greedy result.
9
+ """
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, ".")
16
+ from build_dataset import check
17
+ from gsm_common import SRC_PROMPT
18
+
19
+ OUT = Path("out_gsm")
20
+ CACHE = OUT / "translations.jsonl"
21
+ MODEL = "./models/Seed-X-PPO-7B"
22
+
23
+
24
+ def load():
25
+ trans = {}
26
+ with open(CACHE, encoding="utf-8") as fh:
27
+ for line in fh:
28
+ try:
29
+ r = json.loads(line)
30
+ except json.JSONDecodeError:
31
+ continue
32
+ trans[r["src"]] = r["tgt"]
33
+ rows = [json.loads(l) for l in open(OUT / "selected_rows.jsonl", encoding="utf-8")]
34
+ kind = {}
35
+ for r in rows:
36
+ kind.setdefault(r["question"], "question")
37
+ kind.setdefault(r["thinking"], "thinking")
38
+ return trans, kind
39
+
40
+
41
+ def main():
42
+ trans, kind = load()
43
+ failed = [
44
+ s for s, t in trans.items()
45
+ if check(s, t, strict=(kind.get(s) == "thinking")) is not None
46
+ ]
47
+ print(f"[*] {len(failed)}/{len(trans)} segments failed validation ({len(failed)/len(trans):.2%})")
48
+ if not failed:
49
+ return
50
+
51
+ from vllm import LLM, SamplingParams
52
+ from vllm.sampling_params import BeamSearchParams
53
+
54
+ llm = LLM(model=MODEL, max_num_seqs=256, gpu_memory_utilization=0.92, max_model_len=1024)
55
+ prompts = [SRC_PROMPT.format(text=s) for s in failed]
56
+ recovered = {}
57
+
58
+ # Beam search is disabled by default: vLLM 0.8.5 runs it as a Python-level loop and it took
59
+ # >50 min on ~4.8k segments without finishing, versus ~2 min for the batched sampling path
60
+ # below. Set RETRY_BEAM=1 to use it anyway.
61
+ if os.environ.get("RETRY_BEAM") == "1":
62
+ print("[*] pass 1: beam search")
63
+ outs = llm.beam_search([{"prompt": p} for p in prompts], BeamSearchParams(beam_width=4, max_tokens=256))
64
+ still = []
65
+ for src, o in zip(failed, outs):
66
+ strict = kind.get(src) == "thinking"
67
+ for seq in o.sequences:
68
+ cand = seq.text.strip()
69
+ if check(src, cand, strict=strict) is None:
70
+ recovered[src] = cand
71
+ break
72
+ else:
73
+ still.append(src)
74
+ print(f" recovered {len(recovered)}, still failing {len(still)}")
75
+ else:
76
+ print("[*] pass 1: skipped (beam search disabled)")
77
+ still = list(failed)
78
+
79
+ if still:
80
+ print("[*] pass 2: sampled best-of-8")
81
+ params = SamplingParams(n=8, temperature=0.8, top_p=0.95, max_tokens=256, skip_special_tokens=True)
82
+ outs = llm.generate([SRC_PROMPT.format(text=s) for s in still], params)
83
+ final = []
84
+ for src, o in zip(still, outs):
85
+ strict = kind.get(src) == "thinking"
86
+ for cand in o.outputs:
87
+ text = cand.text.strip()
88
+ if check(src, text, strict=strict) is None:
89
+ recovered[src] = text
90
+ break
91
+ else:
92
+ final.append(src)
93
+ print(f" recovered {len(recovered)} total, unrecoverable {len(final)}")
94
+
95
+ with open(CACHE, "a", encoding="utf-8") as fh:
96
+ for src, tgt in recovered.items():
97
+ fh.write(json.dumps({"src": src, "tgt": tgt}, ensure_ascii=False) + "\n")
98
+ print(f"[+] appended {len(recovered)} recovered translations to {CACHE}")
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
code/space/README.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Nawah Math Reasoning
3
+ emoji: 🧠
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 6.26.0
8
+ python_version: '3.12'
9
+ app_file: app.py
10
+ pinned: false
11
+ models:
12
+ - oddadmix/Nawah-Math-Reasoning
13
+ datasets:
14
+ - oddadmix/arabic-math-reasoning-synth
15
+ - oddadmix/gsm8k-reasoning-ar
16
+ short_description: نموذج استدلال رياضي عربي (52M) يفكّر خطوة بخطوة قبل الإجابة
17
+ ---
18
+
19
+ # Nawah-Math-Reasoning — Demo
20
+
21
+ A **51.8M-parameter** Arabic math reasoning model. It writes its derivation inside
22
+ `<think>…</think>` and then gives the final answer; the demo splits the two live as it streams —
23
+ the reasoning trace in one panel, the answer in the other.
24
+
25
+ Fine-tuned from [`oddadmix/50M-2048-Emhotob`](https://huggingface.co/oddadmix/50M-2048-Emhotob),
26
+ a Llama-architecture base pre-trained from scratch on ~20B Arabic tokens (12 layers, hidden 512,
27
+ 2048 context).
28
+
29
+ > **بالعربية:** نموذج عربي صغير (~52 مليون معامل) يكتب خطوات تفكيره داخل وسم `<think>` ثم يعطي
30
+ > الإجابة النهائية. الديمو بيفصل الاتنين وانت بتتفرج على النموذج وهو بيكتب.
31
+
32
+ ## Everything is open — Apache 2.0
33
+
34
+ | | |
35
+ |---|---|
36
+ | 🧠 **Model** | [`oddadmix/Nawah-Math-Reasoning`](https://huggingface.co/oddadmix/Nawah-Math-Reasoning) |
37
+ | 🛠️ **Training code** | [`code/`](https://huggingface.co/oddadmix/Nawah-Math-Reasoning/tree/main/code) — data generation, translation, SFT, eval, GRPO |
38
+ | 📚 **Synthetic corpus** | [`oddadmix/arabic-math-reasoning-synth`](https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth) — 120,462 arithmetically verified rows |
39
+ | 📚 **Translated corpus** | [`oddadmix/gsm8k-reasoning-ar`](https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar) — 142,969 rows |
40
+
41
+ ## Results
42
+
43
+ Number agreement, greedy decoding, on held-out splits. Every version of the model was scored on
44
+ identical rows, so the numbers are comparable across the whole development ladder.
45
+
46
+ | eval set | n | score |
47
+ |---|---:|---:|
48
+ | GSM8K-ar | 600 | **79.0%** |
49
+ | Arabic_Reasoning | 400 | **73.0%** |
50
+ | synthetic math | 1000 | **40.4%** |
51
+ | synthetic relational | 400 | **52.2%** |
52
+
53
+ The last row is what this release adds: problems where the difficulty is the *relation* between
54
+ quantities (`ضعف`, `نصف`, `أكثر بـ…`) rather than the arithmetic. The previous version scored
55
+ 34.0% there — the relation appeared in barely 1.3% of the training corpus, so 20,139 rows were
56
+ generated specifically to fill the gap.
57
+
58
+ ## Limitations
59
+
60
+ A 52M proof of concept. It reliably produces the *shape* of Arabic step-by-step reasoning, but
61
+ **arithmetic errors are the dominant failure mode** — the derivation is usually structurally
62
+ right, one computation is wrong, and the model then stays faithful to its own bad number. The
63
+ 40.4% and 52.2% above are the honest ceiling on multi-step problems. Single-turn only; open-ended
64
+ and non-mathematical questions are out of distribution.
65
+
66
+ نموذج تجريبي: بيعرف يمشي خطوة خطوة بالعربي، بس بيغلط في الحساب كتير.
67
+
68
+ Runs on **ZeroGPU**. The model is small enough for CPU too — switch the Space to `cpu-basic` and
69
+ it still works, just slower.
70
+
71
+ ## Configuration
72
+
73
+ | Variable | Purpose |
74
+ |---|---|
75
+ | `MODEL_ID` | Model repo to load (default `oddadmix/Nawah-Math-Reasoning`) |
76
+ | `MODEL_HF_TOKEN` | Only needed if `MODEL_ID` points at a **private** repo. (`HF_TOKEN` is reserved by Spaces and does not reach the container.) |
code/space/app.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nawah-Math-Reasoning — Gradio demo.
3
+
4
+ The prompt rendering (ChatML + BOS prepend) is IDENTICAL to train_reasoning.py. A 51M model
5
+ is very sensitive to format drift, so do not change render_prompt() without changing training.
6
+
7
+ Runs on ZeroGPU. The model is only ~52M parameters and works on CPU too, but ZeroGPU keeps
8
+ responses snappy. `import spaces` must come BEFORE torch so it can patch the CUDA calls.
9
+
10
+ The model emits <think>…</think> before its answer, so the stream is split live into two
11
+ panels: the reasoning trace and the final answer.
12
+
13
+ Deploy: push this + requirements.txt + README.md to a Gradio Space. The released model is
14
+ public, so no token is needed; MODEL_HF_TOKEN is still read for pointing MODEL_ID at a private
15
+ checkpoint.
16
+ """
17
+
18
+ import os
19
+ import re
20
+ import threading
21
+
22
+ import spaces # import BEFORE torch so it can patch CUDA calls
23
+ import gradio as gr
24
+ import torch
25
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
26
+
27
+ # ── Config ────────────────────────────────────────────────────────────────────
28
+
29
+ MODEL_ID = os.environ.get("MODEL_ID", "oddadmix/Nawah-Math-Reasoning")
30
+ # Unused for the public release; needed only if MODEL_ID is repointed at a private repo.
31
+ # HF_TOKEN is reserved by Spaces (a secret set under that name does not reach the container),
32
+ # so MODEL_HF_TOKEN is the one to set.
33
+ HF_TOKEN = os.environ.get("MODEL_HF_TOKEN") or os.environ.get("HF_TOKEN")
34
+
35
+ IM_START, IM_END = "<|im_start|>", "<|im_end|>"
36
+ THINK_OPEN, THINK_CLOSE = "<think>", "</think>"
37
+ MAX_NEW_TOKENS_CAP = 1500
38
+
39
+ # ── Load (once, at startup) ───────────────────────────────────────────────────
40
+
41
+ print("[*] token env vars present:",
42
+ [k for k in ("MODEL_HF_TOKEN", "HF_TOKEN") if os.environ.get(k)] or "NONE")
43
+ print(f"[*] Loading {MODEL_ID} ...")
44
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
45
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16, token=HF_TOKEN)
46
+ model.to("cuda").eval()
47
+
48
+ CTX = getattr(model.config, "max_position_embeddings", 2048)
49
+
50
+ _eos = {tokenizer.eos_token_id} if tokenizer.eos_token_id is not None else set()
51
+ _im_end = tokenizer.convert_tokens_to_ids(IM_END)
52
+ if isinstance(_im_end, int) and _im_end >= 0:
53
+ _eos.add(_im_end)
54
+ EOS_IDS = list(_eos) or None
55
+ PAD_ID = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
56
+
57
+ print(f"[+] {model.num_parameters():,} params | eos_ids={EOS_IDS} | ctx={CTX}")
58
+
59
+ # ── Prompt rendering — must match train_reasoning.py ──────────────────────────
60
+
61
+ def render_prompt(question: str) -> str:
62
+ # Single-turn only: the model was trained on one user turn per sample, so a chat
63
+ # history would be out of distribution.
64
+ return f"{IM_START}user\n{question.strip()}{IM_END}\n{IM_START}assistant\n"
65
+
66
+
67
+ STRIP_RE = re.compile(r"<\|im_end\|>|</s>|<pad>|<s>")
68
+
69
+
70
+ def split_stream(text: str):
71
+ """-> (reasoning_so_far, answer_so_far). Handles the partial state mid-stream."""
72
+ text = STRIP_RE.sub("", text)
73
+ if THINK_CLOSE in text:
74
+ reasoning, answer = text.split(THINK_CLOSE, 1)
75
+ return reasoning.replace(THINK_OPEN, "").strip(), answer.strip()
76
+ return text.replace(THINK_OPEN, "").strip(), ""
77
+
78
+
79
+ # ── Generate ──────────────────────────────────────────────────────────────────
80
+
81
+ @spaces.GPU(duration=60)
82
+ def solve(question, max_new_tokens, temperature, repetition_penalty):
83
+ question = (question or "").strip()
84
+ if not question:
85
+ yield "", "", ""
86
+ return
87
+
88
+ ids = tokenizer(render_prompt(question), add_special_tokens=False)["input_ids"]
89
+ if tokenizer.bos_token_id is not None:
90
+ ids = [tokenizer.bos_token_id] + ids # match training's explicit BOS
91
+ input_ids = torch.tensor([ids], device=model.device)
92
+
93
+ # skip_special_tokens must stay False — <think>/</think> are real special tokens
94
+ # in this tokenizer, and stripping them would destroy the split.
95
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
96
+
97
+ kwargs = dict(
98
+ input_ids=input_ids,
99
+ attention_mask=torch.ones_like(input_ids),
100
+ max_new_tokens=int(max_new_tokens),
101
+ repetition_penalty=float(repetition_penalty),
102
+ eos_token_id=EOS_IDS,
103
+ pad_token_id=PAD_ID,
104
+ streamer=streamer,
105
+ )
106
+ if temperature and temperature > 0:
107
+ kwargs.update(do_sample=True, temperature=float(temperature), top_p=0.95)
108
+ else:
109
+ kwargs.update(do_sample=False) # greedy — how the model was evaluated
110
+
111
+ threading.Thread(target=model.generate, kwargs=kwargs).start()
112
+
113
+ out = ""
114
+ for chunk in streamer:
115
+ out += chunk
116
+ reasoning, answer = split_stream(out)
117
+ yield reasoning, (answer or "…"), out
118
+
119
+ reasoning, answer = split_stream(out)
120
+ if not answer:
121
+ answer = "⚠️ لم يُغلق النموذج وسم التفكير — جرّب سؤالًا أقرب لأمثلة التدريب.\n" \
122
+ "(The model never closed `</think>` — try a question closer to its training distribution.)"
123
+ yield reasoning, answer, out
124
+
125
+
126
+ # ── UI ────────────────────────────────────────────────────────────────────────
127
+
128
+ DESCRIPTION = """
129
+ <div style="text-align:center">
130
+ <h1>🧠 Nawah-Math-Reasoning</h1>
131
+ <p>نموذج استدلال عربي صغير (~52M بارامتر) يفكّر خطوة بخطوة داخل وسم <code>&lt;think&gt;</code>
132
+ ثم يعطي الإجابة النهائية.<br>
133
+ A ~52M-parameter Arabic reasoning model that thinks step by step inside
134
+ <code>&lt;think&gt;</code> before answering.</p>
135
+ <p><i>نموذج صغير بما يكفي ليعمل حتى على المعالج (CPU).<br>
136
+ Small enough to run on a CPU — this Space uses ZeroGPU for snappier responses.</i></p>
137
+ <p>
138
+ <a href="https://huggingface.co/oddadmix/Nawah-Math-Reasoning">Model</a> ·
139
+ <a href="https://huggingface.co/oddadmix/Nawah-Math-Reasoning/tree/main/code">Training code</a> ·
140
+ <a href="https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth">Synthetic dataset</a> ·
141
+ <a href="https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar">GSM8K-ar dataset</a>
142
+ <br><i>Weights, both datasets and the full training code are open — Apache 2.0.</i>
143
+ </p>
144
+ </div>
145
+ """
146
+
147
+ NOTE = """
148
+ ### 📊 النتائج / Results
149
+
150
+ Number agreement, greedy decoding, on held-out splits — the same rows for every version of the
151
+ model, so the numbers are comparable.
152
+
153
+ | eval set | n | score |
154
+ |---|---:|---:|
155
+ | GSM8K-ar | 600 | **79.0%** |
156
+ | Arabic_Reasoning | 400 | **73.0%** |
157
+ | synthetic math | 1000 | **40.4%** |
158
+ | synthetic relational (`ضعف`, `نصف`, `أكثر بـ…`) | 400 | **52.2%** |
159
+
160
+ ### ⚠️ حدود النموذج / Limitations
161
+ نموذج تجريبي بحجم 52M: يجيد **شكل** الاستدلال العربي ويحلّ مسائل النِّسب والحساب البسيطة،
162
+ لكنه **يخطئ في الحساب كثيرًا** — غالبًا خطوات الحل سليمة ثم تقع غلطة في عملية حسابية واحدة
163
+ ويكمل النموذج على رقمه الخاطئ. الأسئلة المفتوحة وغير الحسابية خارج نطاقه، والحوار متعدد
164
+ الأدوار كذلك.
165
+
166
+ A 52M proof of concept. It reliably produces the *shape* of Arabic step-by-step reasoning, but
167
+ **arithmetic errors are the dominant failure mode**: the derivation is usually structurally
168
+ right, one computation is wrong, and the model then stays faithful to its own bad number. The
169
+ 40.4% and 52.2% above are the honest ceiling on multi-step problems. Single-turn only;
170
+ open-ended and non-mathematical questions are out of distribution.
171
+ """
172
+
173
+ EXAMPLES = [
174
+ "إذا كان لديك 1500 ريال وأنفقت 20% منها على الكتب، فكم تبقى معك؟",
175
+ "في مصنع تم إنتاج 5000 وحدة، وكانت نسبة الوحدات المعيبة 2%، فما عدد الوحدات السليمة؟",
176
+ "في مدرسة بها 500 طالب، إذا كانت نسبة الذكور 55%، فما عدد الطالبات؟",
177
+ "لدى تاجر 240 كيلوغرامًا من الأرز، باع منها 35%، فكم كيلوغرامًا تبقى لديه؟",
178
+ "إذا كان عمر أحمد 12 سنة وعمر أخيه ضعف عمره، فما مجموع عمريهما؟",
179
+ "في حديقة 80 حيوانًا، 25% منها طيور، ونصف الطيور بيضاء. كم عدد الطيور البيضاء؟",
180
+ "جمع سامي 45 صدفة، وجمع أخوه ضعف هذا العدد. كم صدفة جمعا معًا؟",
181
+ "لدى ليلى 60 جنيهًا، ولدى ندى أقل منها بـ 18 جنيهًا. كم معهما معًا؟",
182
+ ]
183
+
184
+ with gr.Blocks(title="Nawah-Math-Reasoning") as demo:
185
+ gr.HTML(DESCRIPTION)
186
+
187
+ with gr.Row():
188
+ with gr.Column(scale=3):
189
+ question = gr.Textbox(
190
+ label="السؤال / Question", rtl=True, lines=3,
191
+ placeholder="اكتب مسألة حسابية هنا…",
192
+ )
193
+ with gr.Row():
194
+ submit = gr.Button("🧮 حل / Solve", variant="primary")
195
+ clear = gr.Button("مسح / Clear")
196
+
197
+ with gr.Accordion("⚙️ إعدادات التوليد / Generation settings", open=False):
198
+ max_new_tokens = gr.Slider(32, MAX_NEW_TOKENS_CAP, value=320, step=8,
199
+ label="أقصى عدد توكنز / Max new tokens")
200
+ temperature = gr.Slider(0.0, 1.5, value=0.0, step=0.05,
201
+ label="درجة الحرارة / Temperature (0 = greedy, as evaluated)")
202
+ repetition_penalty = gr.Slider(1.0, 1.5, value=1.0, step=0.01,
203
+ label="عقوبة التكرار / Repetition penalty")
204
+
205
+ with gr.Column(scale=4):
206
+ answer_box = gr.Textbox(label="✅ الإجابة / Answer", rtl=True, lines=3)
207
+ with gr.Accordion("🧠 التفكير / Reasoning trace", open=True):
208
+ reasoning_box = gr.Textbox(label="", rtl=True, lines=12)
209
+ with gr.Accordion("🔍 المخرجات الخام / Raw output", open=False):
210
+ raw_box = gr.Textbox(label="", lines=8)
211
+
212
+ gr.Examples(examples=EXAMPLES, inputs=question, label="أمثلة / Examples")
213
+ gr.Markdown(NOTE)
214
+
215
+ inputs = [question, max_new_tokens, temperature, repetition_penalty]
216
+ outputs = [reasoning_box, answer_box, raw_box]
217
+ submit.click(solve, inputs=inputs, outputs=outputs)
218
+ question.submit(solve, inputs=inputs, outputs=outputs)
219
+ clear.click(lambda: ("", "", "", ""), outputs=[question] + outputs)
220
+
221
+ if __name__ == "__main__":
222
+ demo.queue().launch(theme=gr.themes.Soft())
code/space/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu128
2
+ spaces
3
+ torch==2.8.0
4
+ transformers>=5.15,<6
5
+ gradio
code/split_synth_v6.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Re-split the merged v6 synthetic corpus so v5 -> v6 stays a fair comparison.
3
+
4
+ build_synth_dataset.py shuffles with SEED=42 over whatever rows it is given. The v6 corpus has
5
+ 20,139 more rows than the v5 one, so that shuffle lands differently and **1,955 of v5's 2,000
6
+ held-out synth rows fall into v6's train split**. Training on them and then reporting the synth
7
+ cell would be scoring memorisation.
8
+
9
+ So the eval split is not re-drawn, it is *pinned*: `data_synth_sft/eval.jsonl` (v5's rows, in v5's
10
+ order) is copied through verbatim, and every one of those instructions is removed from train. The
11
+ first 1,000 of them are the same rows v4 and v5 were scored on, so the cell stays comparable
12
+ across all three models.
13
+
14
+ A second held-out set, `eval_rel.jsonl`, is carved from the relational pool (task_id >= 1,000,000)
15
+ — the whole point of v6 is a capability v5 lacks, and none of the legacy eval rows test it.
16
+
17
+ Writes data_synth_v6_sft/{train,eval,eval_rel}.jsonl.
18
+ """
19
+ import json
20
+ import random
21
+ from pathlib import Path
22
+
23
+ import pyarrow.parquet as pq
24
+
25
+ CORPUS = Path("out_merged_v6/arabic_math_reasoning_synth.parquet")
26
+ LEGACY = Path("data_synth_sft/eval.jsonl") # v5's held-out synth rows — pinned, not redrawn
27
+ OUT = Path("data_synth_v6_sft")
28
+ EVAL_REL = 400
29
+ REL_MIN_TASK_ID = 1_000_000
30
+ SEED = 42
31
+
32
+ FIELDS = ("instruction", "reasoning", "answer")
33
+
34
+
35
+ def sft(row, source="synth_math_ar"):
36
+ return {**{k: row[k] for k in FIELDS}, "source": source}
37
+
38
+
39
+ def main():
40
+ rows = pq.read_table(CORPUS).to_pylist()
41
+ by_instruction = {r["instruction"]: r for r in rows}
42
+ print(f"[*] corpus {len(rows):,} rows")
43
+
44
+ legacy = [json.loads(l) for l in open(LEGACY, encoding="utf-8")]
45
+ missing = [r for r in legacy if r["instruction"] not in by_instruction]
46
+ print(f"[*] pinned eval {len(legacy):,} rows, {len(missing)} no longer in the corpus")
47
+
48
+ held = {r["instruction"] for r in legacy}
49
+
50
+ # relational held-out: deterministic sample of the new pool, also excluded from train
51
+ rel = [r for r in rows if r["task_id"] >= REL_MIN_TASK_ID and r["instruction"] not in held]
52
+ rel.sort(key=lambda r: (r["task_id"], r["instruction"])) # parquet order is shuffled
53
+ eval_rel = random.Random(SEED).sample(rel, min(EVAL_REL, len(rel)))
54
+ held |= {r["instruction"] for r in eval_rel}
55
+ print(f"[*] relational rows {len(rel):,}, holding out {len(eval_rel):,}")
56
+
57
+ train = [r for r in rows if r["instruction"] not in held]
58
+ n_rel_train = sum(1 for r in train if r["task_id"] >= REL_MIN_TASK_ID)
59
+
60
+ OUT.mkdir(exist_ok=True)
61
+ for name, split in (("train", [sft(r) for r in train]),
62
+ ("eval", legacy), # verbatim, v5's order
63
+ ("eval_rel", [sft(r) for r in eval_rel])):
64
+ with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh:
65
+ for r in split:
66
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
67
+ print(f"[+] {name}: {len(split):,} -> {OUT / f'{name}.jsonl'}")
68
+ print(f"[*] train carries {n_rel_train:,} relational rows ({n_rel_train/len(train):.1%})")
69
+
70
+ leak = sum(1 for r in train if r["instruction"] in held)
71
+ print(f"[{'+' if leak == 0 else '!'}] contamination check: {leak} held-out rows in train")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
code/synth_common.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared pieces for the synthetic Arabic math-reasoning corpus.
3
+
4
+ Holds everything both the generator and the dataset builder need, so the accept/reject decision
5
+ is made by exactly one implementation:
6
+
7
+ * AXES — the variation grid every prompt is drawn from
8
+ * build_task — task_id -> (axes draw, prompt). Deterministic: task 7 always has the same draw,
9
+ which is what makes the run resumable and reproducible.
10
+ * parse_items — split a raw completion into {instruction, reasoning, answer} records
11
+ * audit — verify the arithmetic *inside* the reasoning and reject rows that don't check out
12
+
13
+ Why the audit matters: a 12B model writes fluent Arabic derivations that are wrong roughly one
14
+ time in ten. The reasoning states its own equations ("35 × 4 = 140"), so they can be re-evaluated
15
+ exactly. This is the same discipline as the numeral audit in build_dataset.py, applied to
16
+ generated arithmetic rather than translated arithmetic.
17
+ """
18
+ import random
19
+ import re
20
+ import unicodedata
21
+
22
+ # ---------------------------------------------------------------- variation grid
23
+
24
+ DOMAINS = [
25
+ "التسوق وشراء البقالة", "الرواتب والأجور اليومية", "المسافات والسفر بالسيارة",
26
+ "وصفات الطبخ والمقادير", "المدرسة والطلاب والدرجات", "المزرعة والمحاصيل والحيوانات",
27
+ "الرياضة والمباريات والنقاط", "الادخار والميزانية الشهرية", "البناء والدهان والبلاط",
28
+ "فواتير الهاتف والإنترنت", "الوقود واستهلاك السيارة", "السوق وبيع الخضار والفواكه",
29
+ "المكتبة وشراء الكتب", "ورشة الخياطة والأقمشة", "توصيل الطلبات والمندوبين",
30
+ "المستشفى والجرعات والمواعيد", "الحديقة والأشجار والري", "المخبز وإنتاج الخبز",
31
+ "محل الحلويات والطلبيات", "النقل المدرسي والحافلات", "تربية الدواجن والبيض",
32
+ "مصنع صغير وخط الإنتاج", "الرحلات المدرسية والتذاكر", "تأجير المعدات باليوم",
33
+ "صيد السمك وبيعه", "محطة تحلية ومياه الشرب", "متجر إلكتروني والشحن",
34
+ "معرض السيارات والأقساط", "الزكاة وتوزيع الصدقات", "تنظيم حفل وعدد الضيوف",
35
+ ]
36
+
37
+ OPERATIONS = [
38
+ ("الجمع والطرح", "خطوتان من الجمع والطرح على أعداد صحيحة"),
39
+ ("الضرب والقسمة", "الضرب ثم القسمة للوصول إلى نصيب الواحد"),
40
+ ("النسبة المئوية", "حساب نسبة مئوية من مبلغ ثم الزيادة أو النقصان"),
41
+ ("الكسور", "أخذ كسر من كمية ثم التعامل مع الباقي"),
42
+ ("النسبة والتناسب", "توزيع كمية بنسبة معلومة بين طرفين أو ثلاثة"),
43
+ ("سعر الوحدة", "استنتاج سعر الوحدة ثم حساب تكلفة كمية مختلفة"),
44
+ ("المتوسط الحسابي", "حساب متوسط مجموعة قيم ثم استنتاج قيمة ناقصة"),
45
+ ("السرعة والزمن والمسافة", "علاقة السرعة بالزمن والمسافة على مرحلتين"),
46
+ ("المساحة والمحيط", "مساحة أو محيط شكل مستطيل ثم تكلفة تغطيته"),
47
+ ("الربح والخسارة", "شراء بسعر وبيع بسعر آخر وحساب الربح أو نسبته"),
48
+ ("الخصم والضريبة", "خصم على السعر ثم إضافة ضريبة القيمة المضافة"),
49
+ ("العمل والزمن", "عاملان أو ثلاثة ينجزون عملاً في أزمنة مختلفة"),
50
+ ("تحويل الوحدات", "تحويل بين الوحدات (كجم/جرام، ساعة/دقيقة، متر/سم) ضمن الحل"),
51
+ ("المتتاليات البسيطة", "نمط يزيد بمقدار ثابت كل يوم أو كل أسبوع"),
52
+ ("المعادلة ذات المجهول الواحد", "صياغة معادلة بسيطة بمجهول واحد وحلها"),
53
+ ]
54
+
55
+ # --- relational comparison pool -------------------------------------------------------------
56
+ # A SEPARATE pool, not appended to OPERATIONS, so build_task(N) stays byte-identical for the task
57
+ # ids that produced the original 100,323-row corpus — that corpus must remain reproducible.
58
+ #
59
+ # Why this exists: v5 scores pass@8 = 0 on prompts like "عمر أخيه ضعف عمره" because the grid never
60
+ # generated the relation. Measured coverage in the 100k corpus: ضعف/أضعاف 1.34%, أكثر بـ/أقل بـ
61
+ # 0.26%. RL cannot repair that class (no correct sample in the group -> no gradient), so it has to
62
+ # come from data.
63
+ #
64
+ # The defining trait: one quantity is stated ONLY in terms of another, so the solver must compute
65
+ # the second quantity before it can do anything else. That first step is exactly what v5 skips.
66
+ RELATIONAL_OPS = [
67
+ ("المقارنة بالمضاعفة", "كمية ثانية تساوي ضعف الأولى، ثم يُطلب المجموع أو الفرق"),
68
+ ("المقارنة بالتضعيف المتعدد", "كمية ثانية تساوي ثلاثة أو أربعة أضعاف الأولى، ثم المجموع"),
69
+ ("المقارنة بالتنصيف", "كمية ثانية تساوي نصف الأولى، ثم المجموع أو الباقي"),
70
+ ("المقارنة بالكسر", "كمية ثانية تساوي ثلث أو ربع الأولى، ثم المجموع"),
71
+ ("المقارنة بالزيادة المطلقة", "كمية ثانية تزيد عن الأولى بمقدار ثابت، ثم المجموع"),
72
+ ("المقارنة بالنقصان المطلق", "كمية ثانية تقل عن الأولى بمقدار ثابت، ثم المجموع"),
73
+ ("المقارنة بين ثلاث كميات", "ثلاث كميات كل واحدة معرّفة بدلالة سابقتها، ثم المجموع"),
74
+ ("المقارنة العكسية", "تُعطى الكمية الثانية وعلاقتها بالأولى، والمطلوب الأولى ثم المجموع"),
75
+ ]
76
+
77
+ # Relational problems are naturally short — a 5-step chain forces padding with filler steps.
78
+ RELATIONAL_STEPS = [(2, "خطوتين"), (2, "خطوتين"), (3, "ثلاث خطوات")]
79
+
80
+ STEPS = [(2, "خطوتين"), (3, "ثلاث خطوات"), (3, "ثلاث خطوات"), (4, "أربع خطوات")]
81
+
82
+ NUMBER_STYLE = [
83
+ "أعداد صحيحة صغيرة (أقل من 100)",
84
+ "أعداد صحيحة متوسطة (بين 100 و 5000)",
85
+ "مبالغ مالية تحتوي على كسور عشرية بمنزلتين",
86
+ "أعداد كبيرة نسبيًا (بالآلاف) تقبل القسمة بدون باقٍ",
87
+ ]
88
+
89
+ REGIONS = [
90
+ ("السعودية", "ريال"), ("مصر", "جنيه"), ("الإمارات", "درهم"), ("الأردن", "دينار"),
91
+ ("المغرب", "درهم"), ("الكويت", "دينار"), ("تونس", "دينار"), ("العراق", "دينار"),
92
+ ("سوريا", "ليرة"), ("عُمان", "ريال"), ("قطر", "ريال"), ("الجزائر", "دينار"),
93
+ ]
94
+
95
+ NAMES = [
96
+ "أحمد", "فاطمة", "محمد", "سارة", "خالد", "نور", "يوسف", "مريم", "عمر", "ليلى",
97
+ "سلمان", "هند", "طارق", "رنا", "بلال", "أسماء", "زياد", "دعاء", "كريم", "شيماء",
98
+ "ياسر", "بثينة", "حسن", "رغد", "إبراهيم", "جنى", "مصطفى", "لمى", "سعيد", "أروى",
99
+ ]
100
+
101
+ TWISTS = [
102
+ "أضف رقمًا واحدًا في نص المسألة لا يُستخدم في الحل (معلومة زائدة)",
103
+ "اجعل المسألة تقارن بين شخصين أو بين يومين",
104
+ "اجعل السؤال يطلب الباقي أو المتبقي وليس المجموع",
105
+ "اجعل السؤال يطلب عدد المرات أو عدد المجموعات",
106
+ "اجعل المسألة على مرحلتين زمنيتين (اليوم الأول ثم اليوم الثاني)",
107
+ "اجعل الإجابة النهائية تحتاج تقريبًا لأقرب عدد صحيح مع توضيح سبب التقريب",
108
+ "لا تضف أي تعقيد إضافي، اجعلها مباشرة وواضحة",
109
+ "اجعل السؤال يطلب النسبة المئوية للنتيجة من الإجمالي",
110
+ ]
111
+
112
+ ITEMS_PER_TASK = 4
113
+
114
+ # ---------------------------------------------------------------- prompt
115
+
116
+ Q, T, A, E = "### مسألة", "### تفكير", "### الإجابة", "### نهاية"
117
+
118
+ PROMPT = """أنت معلم رياضيات عربي تكتب مسائل تدريبية عالية الجودة باللغة العربية الفصحى.
119
+
120
+ اكتب {k} مسائل حسابية **مختلفة تمامًا عن بعضها** بالمواصفات التالية:
121
+
122
+ - المجال: {domain}
123
+ - نوع العملية: {op_name} — {op_hint}
124
+ - عدد خطوات الحل: {steps_word} تقريبًا (اكتب فقط الخطوات التي يحتاجها الحل فعلًا)
125
+ - طبيعة الأرقام: {numbers}
126
+ - السياق: {region}، والعملة {currency}
127
+ - استخدم أسماء مثل: {names}
128
+ - {twist}
129
+
130
+ اكتب كل مسألة بهذا الشكل بالضبط، ولا تكتب أي شيء آخر خارج هذه الوسوم:
131
+
132
+ {Q}
133
+ نص المسألة في جملة أو جملتين، وينتهي بسؤال واضح.
134
+ {T}
135
+ خطوات الحل مرقمة، وكل خطوة تحتوي على معادلة صريحة بالأرقام مثل: 45 × 3 = 135
136
+ {A}
137
+ إذن، جملة واحدة تذكر الإجابة النهائية بالرقم مع وحدتها.
138
+ {E}
139
+
140
+ قواعد إلزامية:
141
+ 1. كل معادلة تكتبها يجب أن تكون **صحيحة حسابيًا**. تحقق من كل عملية قبل كتابتها.
142
+ 2. الرقم في سطر الإجابة يجب أن يساوي ناتج آخر خطوة في التفكير.
143
+ 3. اكتب الأرقام بالأرقام الإنجليزية (0-9) وليس بالحروف ولا بالأرقام الهندية.
144
+ 4. اكتب بالعربية فقط، بدون أي كلمة إنجليزية.
145
+ 5. لا تكرر مسألة سبق أن كتبتها في هذه الإجابة.
146
+ 6. لا تكتب خطوات فارغة أو بلا فائدة مثل «63 + 0 = 63» أو «4500 = 4500» أو «24 × 1 = 24».
147
+ كل خطوة يجب أن تُنتج قيمة جديدة لم تكن معروفة قبلها.
148
+ 7. إذا لم تناسب العملية المطلوبة المجالَ المطلوب، غيّر تفاصيل المسألة لتناسبها من البداية،
149
+ ولا تعلّق على ذلك ولا تعِد صياغة المسألة داخل خطوات الحل.
150
+ """
151
+
152
+
153
+ def build_task(task_id: int, seed: int = 1234, pool: str = "default"):
154
+ """task_id -> (axes, prompt). Deterministic, so a resumed run redraws identical prompts.
155
+
156
+ pool="default" the original 15-operation grid — draws are byte-identical to the run that
157
+ produced the 100,323-row corpus, so that corpus stays reproducible.
158
+ pool="relational" the relational-comparison pool (RELATIONAL_OPS). Use a disjoint task-id
159
+ range for it, the same rule that keeps two generating nodes from colliding.
160
+ """
161
+ ops, steps_pool = (RELATIONAL_OPS, RELATIONAL_STEPS) if pool == "relational" else (OPERATIONS, STEPS)
162
+ rng = random.Random(seed * 1_000_003 + task_id)
163
+ domain = rng.choice(DOMAINS)
164
+ op_name, op_hint = rng.choice(ops)
165
+ _, steps_word = rng.choice(steps_pool)
166
+ numbers = rng.choice(NUMBER_STYLE)
167
+ region, currency = rng.choice(REGIONS)
168
+ names = "، ".join(rng.sample(NAMES, 3))
169
+ twist = rng.choice(TWISTS)
170
+ axes = {"domain": domain, "op": op_name, "steps": steps_word, "numbers": numbers,
171
+ "region": region, "twist": twist}
172
+ prompt = PROMPT.format(k=ITEMS_PER_TASK, domain=domain, op_name=op_name, op_hint=op_hint,
173
+ steps_word=steps_word, numbers=numbers, region=region,
174
+ currency=currency, names=names, twist=twist, Q=Q, T=T, A=A, E=E)
175
+ return axes, prompt
176
+
177
+
178
+ # ---------------------------------------------------------------- parsing
179
+
180
+ AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩٪", "0123456789%")
181
+ BLOCK_RE = re.compile(
182
+ re.escape(Q) + r"(?P<q>.*?)" + re.escape(T) + r"(?P<t>.*?)" + re.escape(A) + r"(?P<a>.*?)"
183
+ + r"(?:" + re.escape(E) + r"|$)", re.S)
184
+ LATIN_RE = re.compile(r"[A-Za-z]")
185
+ CONCLUSION = ("إذن", "لذلك", "بالتالي", "وبالتالي", "لذا", "في النهاية", "الخلاصة")
186
+ # The model sometimes breaks frame and rewrites the problem inside its own derivation
187
+ # ("*تصحيح للمسألة لتناسب العملية المطلوبة*") when a drawn operation type does not fit the drawn
188
+ # domain. The arithmetic in those rows often still checks out, so the audit alone won't catch them.
189
+ META_MARKERS = ("تصحيح للمسألة", "سنعيد صياغة", "لتناسب العملية", "المسألة تطلب",
190
+ "إعادة صياغة", "لنفترض أن المسألة", "تصحيح السؤال", "بما أن المسألة")
191
+
192
+
193
+ def norm(text: str) -> str:
194
+ text = unicodedata.normalize("NFC", text.replace("‏", "").replace("‎", ""))
195
+ text = text.translate(AR_DIGITS)
196
+ text = re.sub(r"[ \t]+", " ", text)
197
+ text = re.sub(r"\n{3,}", "\n\n", text)
198
+ return text.strip()
199
+
200
+
201
+ def parse_items(raw: str):
202
+ """Raw completion -> list of {instruction, reasoning, answer}. Malformed blocks are skipped."""
203
+ out = []
204
+ for m in BLOCK_RE.finditer(raw):
205
+ q, t, a = (norm(m.group(g)) for g in ("q", "t", "a"))
206
+ if q and t and a:
207
+ out.append({"instruction": q, "reasoning": t, "answer": a})
208
+ return out
209
+
210
+
211
+ # ---------------------------------------------------------------- arithmetic audit
212
+
213
+ NUM = r"\d+(?:\.\d+)?"
214
+ # "x + 0 = x", "x × 1 = x", "x = x" — vacuous steps the model emits to hit a requested step count.
215
+ NOOP_RE = re.compile(r"(?<![\d.])(" + NUM + r")\s*(?:[+\-−–]\s*0+(?:\.0+)?|"
216
+ r"[×xX*]\s*1(?:\.0+)?|[÷/]\s*1(?:\.0+)?)?\s*=\s*\1(?![\d.])")
217
+
218
+ # One arithmetic segment: digits and operators only, at least one digit. Deliberately excludes
219
+ # newlines — a chain never spans two steps.
220
+ _SC = r"[\d.()+\-−–×xX*÷/ \t]"
221
+ SEG = _SC + r"*\d" + _SC + r"*"
222
+ # "1." / "2)" list numbering at the head of a step is not part of the arithmetic; left in, it
223
+ # turns "1. 75 + 15" into the un-evaluatable "1. 75 + 15".
224
+ LIST_NUM_RE = re.compile(r"^[ \t]*\d+[.)][ \t]*", re.M)
225
+ # A *chain*: "a × b = c + d = e". Models routinely show their working this way, and reading only
226
+ # the first "=" turns a correct chain into a false rejection — every segment must be compared.
227
+ CHAIN_RE = re.compile(r"(?<![\d.])(" + SEG + r"(?:=" + SEG + r")+)")
228
+
229
+
230
+ def _evaluate(expr: str):
231
+ """Evaluate a pure-arithmetic expression with normal precedence. None if it isn't one."""
232
+ expr = expr.replace("−", "-").replace("–", "-").replace("×", "*").replace("x", "*") \
233
+ .replace("X", "*").replace("÷", "/")
234
+ expr = expr.strip()
235
+ if not expr or not re.fullmatch(r"[\d.+\-*/() ]+", expr) or not re.search(r"\d", expr):
236
+ return None
237
+ try:
238
+ # The regex above admits only digits, operators, dots, parens and spaces — no names,
239
+ # no calls, no attribute access — so this cannot execute anything else.
240
+ value = eval(expr, {"__builtins__": {}}, {}) # noqa: S307
241
+ except Exception:
242
+ return None
243
+ return value if isinstance(value, (int, float)) else None
244
+
245
+
246
+ def _decimals(text: str) -> int:
247
+ text = text.strip()
248
+ return len(text.split(".")[1]) if "." in text else 0
249
+
250
+
251
+ def _close(a, b, shown: str | None = None):
252
+ """
253
+ Is the stated value `b` an acceptable rendering of the true value `a`?
254
+
255
+ Exact, or `b` is `a` rounded to the precision `b` is written with — "3200 / 60 = 53.33" and
256
+ "9.44 × 60 = 566.4 → 566" are how people write arithmetic, not arithmetic errors. Only
257
+ rounding at the displayed precision is forgiven, so a genuinely wrong number still fails.
258
+ """
259
+ if abs(a - b) <= max(1e-6, abs(b) * 1e-9):
260
+ return True
261
+ if shown is not None:
262
+ d = _decimals(shown)
263
+ if abs(round(a, d) - b) <= 1e-9:
264
+ return True
265
+ if d == 0 and b in (int(a), int(a) + 1 if a > 0 else int(a) - 1):
266
+ return True # a step that floors or ceils, e.g. "3 crates with a remainder"
267
+ return False
268
+
269
+
270
+ def audit(reasoning: str, answer: str, min_equations: int = 1):
271
+ """
272
+ -> (ok, reason, n_checked)
273
+
274
+ Every "a op b = c [= d ...]" chain the reasoning states is re-evaluated end to end. One
275
+ segment that disagrees rejects the row. The answer's number must also match the last chain's
276
+ final value — a derivation that is internally correct but ends on a different number is the
277
+ failure mode that survives any fluency check.
278
+ """
279
+ checked = 0
280
+ last = None
281
+ body = LIST_NUM_RE.sub("", reasoning)
282
+ for m in CHAIN_RE.finditer(body):
283
+ parts = m.group(1).split("=")
284
+ values = [_evaluate(part) for part in parts]
285
+ if any(v is None for v in values) or len(values) < 2:
286
+ continue # not pure arithmetic (units, words, %) — not our call
287
+ checked += 1
288
+ head = values[0]
289
+ for part, v in zip(parts[1:], values[1:]):
290
+ # Compare each stated value against the *first* segment, which is the one written in
291
+ # full precision; `part` carries how many decimals the model chose to show.
292
+ if not _close(head, v, part):
293
+ return False, f"bad_equation:{m.group(1).strip()[:60]}", checked
294
+ last = values[-1]
295
+ if checked < min_equations:
296
+ return False, "no_equations", checked
297
+
298
+ ans_shown = re.findall(NUM, answer)
299
+ if not ans_shown:
300
+ return False, "answer_has_no_number", checked
301
+ if last is not None and not any(_close(last, float(x), x) for x in ans_shown):
302
+ return False, "answer_not_last_result", checked
303
+ return True, "ok", checked
304
+
305
+
306
+ def validate(item, min_reasoning=40, max_reasoning=1200):
307
+ """Full accept/reject for one parsed item -> (ok, reason)."""
308
+ q, t, a = item["instruction"], item["reasoning"], item["answer"]
309
+ if LATIN_RE.search(q) or LATIN_RE.search(t) or LATIN_RE.search(a):
310
+ return False, "latin_residue"
311
+ if not (20 <= len(q) <= 600):
312
+ return False, "question_length"
313
+ if not (min_reasoning <= len(t) <= max_reasoning):
314
+ return False, "reasoning_length"
315
+ if not (10 <= len(a) <= 300):
316
+ return False, "answer_length"
317
+ if not a.lstrip("*-# ").startswith(CONCLUSION):
318
+ return False, "no_conclusion_marker"
319
+ if any(mark in t for mark in (Q, A, E)):
320
+ return False, "tag_leak"
321
+ if any(mark in t or mark in q for mark in META_MARKERS):
322
+ return False, "meta_commentary"
323
+ if NOOP_RE.search(t):
324
+ return False, "noop_step"
325
+ ok, reason, _ = audit(t, a)
326
+ return ok, reason
327
+
328
+
329
+ def dedup_key(instruction: str) -> str:
330
+ """Numbers masked out, so the same template with different values collapses to one key."""
331
+ return re.sub(r"\d+(?:\.\d+)?", "#", re.sub(r"\W+", "", instruction))
code/synth_generate.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate the synthetic Arabic math-reasoning corpus with google/gemma-4-12B-it.
3
+
4
+ Resumable by construction:
5
+
6
+ * A "task" is one generation call that asks for ITEMS_PER_TASK problems. Task N's prompt is a
7
+ pure function of N (synth_common.build_task), so nothing about the plan is stored — restart
8
+ the script and it redraws the identical prompts.
9
+ * Every completed task is appended to out_synth/generations.jsonl as one line and fsynced.
10
+ On start the file is replayed, completed task_ids are skipped, and a torn final line (a
11
+ machine that died mid-write) is dropped. Worst case a crash costs one in-flight batch.
12
+ * The stop condition is *accepted rows*, not tasks: each cached generation is re-validated on
13
+ load, so a resumed run knows how many good rows it already has and issues only what's missing.
14
+
15
+ Backends:
16
+ BACKEND=hf transformers batched generation (default, works here)
17
+ BACKEND=vllm vLLM continuous batching (see the note below)
18
+
19
+ vLLM cannot serve Gemma 4 on this box: every vLLM release that knows the gemma4 architecture
20
+ (>= 0.20.2) pins torch 2.11, which is a CUDA 13 build, and this host's driver (550 / CUDA 12.4)
21
+ caps at CUDA 12.x — vllm imports die on `libcudart.so.13`. The vLLM path is kept working for a
22
+ box with driver >= 580, or for a gemma3/Qwen generator on the pinned .venv-vllm stack.
23
+
24
+ Usage:
25
+ P=/notebooks/50M/.venv-lfm2/bin/python
26
+ TARGET=100000 $P -u synth_generate.py
27
+ """
28
+ import json
29
+ import os
30
+ import sys
31
+ import time
32
+ from pathlib import Path
33
+
34
+ import synth_common as sc
35
+
36
+ MODEL_DIR = os.environ.get("GEN_MODEL", "./models/gemma-4-12B-it")
37
+ OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
38
+ CACHE = OUT_DIR / "generations.jsonl"
39
+ TARGET = int(os.environ.get("TARGET", 100_000))
40
+ BATCH = int(os.environ.get("BATCH", 32))
41
+ MAX_NEW = int(os.environ.get("MAX_NEW", 1400))
42
+ TEMPERATURE = float(os.environ.get("TEMPERATURE", 0.9))
43
+ TOP_P = float(os.environ.get("TOP_P", 0.95))
44
+ SEED = int(os.environ.get("SEED", 1234))
45
+ BACKEND = os.environ.get("BACKEND", "hf")
46
+ # Recorded on every cached row so a merged multi-node corpus stays attributable.
47
+ MODEL_NAME = os.environ.get("GEN_MODEL_NAME", os.path.basename(MODEL_DIR.rstrip("/")))
48
+ # Multimodal wrappers can default to eager attention, which is several times slower to decode.
49
+ ATTN = os.environ.get("ATTN", "sdpa")
50
+ MAX_TASKS = int(os.environ.get("MAX_TASKS", 400_000))
51
+ # Two machines generating at once must own disjoint task-id ranges: build_task() is a pure
52
+ # function of the id, so overlapping ranges redraw byte-identical prompts and every row the
53
+ # second machine produces dies as a duplicate template in build_synth_dataset.py. START_TASK
54
+ # offsets this node's range; MAX_TASKS is counted from there, not from zero.
55
+ START_TASK = int(os.environ.get("START_TASK", 0))
56
+ # Which slice of the variation grid to draw from. "default" is the original 15-operation grid;
57
+ # "relational" is the comparison pool (x = 2y, x = y/2, x = y + n ...) that the default grid never
58
+ # produced. Use a disjoint START_TASK for a relational run — same rule as two nodes not colliding.
59
+ POOL = os.environ.get("POOL", "default")
60
+ # Qwen3-style templates open a <think> block in the generation prompt unless this is passed,
61
+ # and the model then spends the whole MAX_NEW budget reasoning before it ever emits a tag.
62
+ CHAT_KWARGS = {"enable_thinking": False} if os.environ.get("NO_THINK", "0") == "1" else {}
63
+
64
+
65
+ def load_cache():
66
+ """-> (set of finished task_ids, accepted row count). Tolerates a truncated final line."""
67
+ done, accepted = set(), 0
68
+ if not CACHE.exists():
69
+ return done, accepted
70
+ with open(CACHE, encoding="utf-8") as fh:
71
+ for line in fh:
72
+ try:
73
+ rec = json.loads(line)
74
+ except json.JSONDecodeError:
75
+ print("[!] dropping truncated final line of the cache")
76
+ continue
77
+ done.add(rec["task_id"])
78
+ for item in sc.parse_items(rec["raw"]):
79
+ ok, _ = sc.validate(item)
80
+ accepted += ok
81
+ return done, accepted
82
+
83
+
84
+ class HFBackend:
85
+ """Batched transformers generation. No paged attention, but B sequences decode in parallel."""
86
+
87
+ def __init__(self):
88
+ import torch
89
+ import transformers
90
+ from transformers import AutoProcessor, AutoTokenizer
91
+
92
+ self.torch = torch
93
+ try:
94
+ self.tok = AutoProcessor.from_pretrained(MODEL_DIR).tokenizer
95
+ except Exception:
96
+ self.tok = AutoTokenizer.from_pretrained(MODEL_DIR)
97
+ self.tok.padding_side = "left"
98
+ if self.tok.pad_token_id is None:
99
+ self.tok.pad_token = self.tok.eos_token
100
+ print(f"[*] loading {MODEL_DIR} (bf16)", flush=True)
101
+ # gemma-4-*-it is a unified multimodal checkpoint, so the plain causal-LM auto class does
102
+ # not always claim it. Try the multimodal auto classes first and fall back.
103
+ self.model, last = None, None
104
+ for name in ("AutoModelForMultimodalLM", "AutoModelForImageTextToText",
105
+ "AutoModelForCausalLM"):
106
+ cls = getattr(transformers, name, None)
107
+ if cls is None:
108
+ continue
109
+ try:
110
+ self.model = cls.from_pretrained(
111
+ MODEL_DIR, dtype=torch.bfloat16, device_map="cuda:0",
112
+ attn_implementation=ATTN).eval()
113
+ print(f" loaded via {name}", flush=True)
114
+ break
115
+ except Exception as exc: # noqa: BLE001 - report the last failure
116
+ last = f"{name}: {exc}"
117
+ if self.model is None:
118
+ raise RuntimeError(f"could not load {MODEL_DIR}; last error -> {last}")
119
+ self.model.config.use_cache = True
120
+
121
+ def generate(self, prompts):
122
+ texts = [self.tok.apply_chat_template([{"role": "user", "content": p}],
123
+ tokenize=False, add_generation_prompt=True,
124
+ **CHAT_KWARGS)
125
+ for p in prompts]
126
+ enc = self.tok(texts, return_tensors="pt", padding=True, add_special_tokens=False).to("cuda:0")
127
+ with self.torch.no_grad():
128
+ out = self.model.generate(**enc, max_new_tokens=MAX_NEW, do_sample=True,
129
+ temperature=TEMPERATURE, top_p=TOP_P,
130
+ pad_token_id=self.tok.pad_token_id)
131
+ width = enc["input_ids"].shape[1]
132
+ return [self.tok.decode(seq[width:], skip_special_tokens=True) for seq in out]
133
+
134
+
135
+ class VLLMBackend:
136
+ def __init__(self):
137
+ from vllm import LLM, SamplingParams
138
+
139
+ kw = {}
140
+ if os.environ.get("QUANT"): # e.g. QUANT=modelopt for NVFP4 checkpoints
141
+ kw["quantization"] = os.environ["QUANT"]
142
+ self.llm = LLM(model=MODEL_DIR, dtype=os.environ.get("DTYPE", "bfloat16"),
143
+ max_num_seqs=BATCH,
144
+ gpu_memory_utilization=float(os.environ.get("GPU_UTIL", 0.90)),
145
+ max_model_len=int(os.environ.get("MAX_LEN", 4096)), **kw)
146
+ self.params = SamplingParams(temperature=TEMPERATURE, top_p=TOP_P, max_tokens=MAX_NEW,
147
+ seed=None)
148
+ self.tok = self.llm.get_tokenizer()
149
+
150
+ def generate(self, prompts):
151
+ texts = [self.tok.apply_chat_template([{"role": "user", "content": p}],
152
+ tokenize=False, add_generation_prompt=True,
153
+ **CHAT_KWARGS)
154
+ for p in prompts]
155
+ outs = self.llm.generate(texts, self.params)
156
+ return [o.outputs[0].text for o in outs]
157
+
158
+
159
+ def main():
160
+ OUT_DIR.mkdir(exist_ok=True)
161
+ done, accepted = load_cache()
162
+ print(f"[*] cache: {len(done):,} tasks done, {accepted:,} rows accepted "
163
+ f"(target {TARGET:,}) | model {MODEL_NAME} | tasks {START_TASK:,}.."
164
+ f"{START_TASK + MAX_TASKS:,}", flush=True)
165
+ if accepted >= TARGET:
166
+ print("[+] target already met — nothing to do")
167
+ return
168
+
169
+ backend = (VLLMBackend if BACKEND == "vllm" else HFBackend)()
170
+
171
+ next_id = START_TASK
172
+ last_id = START_TASK + MAX_TASKS
173
+ started, gen_rows, gen_tasks = time.time(), 0, 0
174
+ fh = open(CACHE, "a", encoding="utf-8")
175
+ while accepted < TARGET and next_id < last_id:
176
+ batch = []
177
+ while len(batch) < BATCH and next_id < last_id:
178
+ if next_id not in done:
179
+ batch.append((next_id, *sc.build_task(next_id, SEED, POOL)))
180
+ next_id += 1
181
+ if not batch:
182
+ break
183
+
184
+ t0 = time.time()
185
+ raws = backend.generate([p for _, _, p in batch])
186
+ batch_accept = 0
187
+ for (task_id, axes, _), raw in zip(batch, raws):
188
+ fh.write(json.dumps({"task_id": task_id, "axes": axes, "raw": raw,
189
+ "model": MODEL_NAME}, ensure_ascii=False) + "\n")
190
+ for item in sc.parse_items(raw):
191
+ ok, _ = sc.validate(item)
192
+ batch_accept += ok
193
+ fh.flush()
194
+ os.fsync(fh.fileno()) # a crash costs the in-flight batch, never the cache
195
+
196
+ accepted += batch_accept
197
+ gen_rows += batch_accept
198
+ gen_tasks += len(batch)
199
+ dt = time.time() - t0
200
+ rate = gen_rows / max(time.time() - started, 1e-9)
201
+ eta = (TARGET - accepted) / rate / 3600 if rate > 0 else float("inf")
202
+ print(f"[{accepted:>7,}/{TARGET:,}] +{batch_accept:>3} rows "
203
+ f"batch {len(batch)} in {dt:5.1f}s "
204
+ f"accept {batch_accept / (len(batch) * sc.ITEMS_PER_TASK):5.1%} "
205
+ f"{rate * 3600:,.0f} rows/h ETA {eta:4.1f}h", flush=True)
206
+
207
+ fh.close()
208
+ print(f"[+] {accepted:,} accepted rows in cache after {gen_tasks:,} new tasks")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ sys.exit(main())
code/train_reasoning.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reasoning SFT for oddadmix/50M-2048-Emhotob on Arabic_Reasoning_Dataset.
3
+
4
+ ChatML format with the derivation wrapped in <think>...</think>. Loss is computed on the
5
+ assistant turn only — the user prompt is masked out, same as the earlier Emhotob SFT runs.
6
+ No TRL; plain HF Trainer.
7
+ """
8
+ import json
9
+ import os
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import torch
14
+ from torch.utils.data import Dataset
15
+ from transformers import (
16
+ AutoModelForCausalLM,
17
+ AutoTokenizer,
18
+ Trainer,
19
+ TrainingArguments,
20
+ )
21
+
22
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0")
23
+
24
+ # Defaults reproduce v1 (Arabic_Reasoning_Dataset); every value can be overridden by env var
25
+ # so the same recipe can be pointed at a different corpus.
26
+ def _env(name, default, cast=str):
27
+ return cast(os.environ.get(name, default))
28
+
29
+ BASE_MODEL = _env("BASE_MODEL", "/notebooks/50M/50M-2048-Emhotob")
30
+ OUTPUT_DIR = _env("OUTPUT_DIR", "./Nawah-Reasoning-v1")
31
+ TRAIN_FILE = _env("TRAIN_FILE", "data/train.jsonl")
32
+ EVAL_FILE = _env("EVAL_FILE", "data/eval.jsonl")
33
+
34
+ MAX_LENGTH = _env("MAX_LENGTH", 768, int) # v1: p100 of that corpus is 708 tokens
35
+ IGNORE_INDEX = -100
36
+
37
+ LEARNING_RATE = _env("LEARNING_RATE", 3e-4, float) # same as the Emhotob translation SFT ladder
38
+ EPOCHS = _env("EPOCHS", 8, int) # v1 is tiny (~840k tok/epoch); best checkpoint wins
39
+ BATCH_SIZE = _env("BATCH_SIZE", 16, int)
40
+ GRAD_ACCUM = _env("GRAD_ACCUM", 2, int)
41
+ WARMUP_STEPS = _env("WARMUP_STEPS", 100, int)
42
+ EVAL_STEPS = _env("EVAL_STEPS", 100, int)
43
+ # On the v3 mix, eval loss is a bad model selector: the repeated Arabic_Reasoning rows start
44
+ # memorising around epoch 1.4 and drag the loss up while generation quality on *both* halves is
45
+ # still improving. Set LOAD_BEST=0 there and keep the final checkpoint.
46
+ LOAD_BEST = _env("LOAD_BEST", 1, int) == 1
47
+ # Point at a checkpoint dir to continue an interrupted run (optimizer/scheduler/RNG/step are
48
+ # restored from it). Empty = fresh run, so v1-v5 still reproduce exactly.
49
+ RESUME = _env("RESUME", "") or None
50
+ WEIGHT_DECAY = 0.0
51
+ MAX_GRAD_NORM = 1.0
52
+ SEED = 42
53
+
54
+ SPECIAL_TOKENS = ["<|im_start|>", "<|im_end|>", "<think>", "</think>"]
55
+
56
+ CHAT_TEMPLATE = (
57
+ "{% for message in messages %}"
58
+ "{{ '<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n' }}"
59
+ "{% endfor %}"
60
+ "{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}"
61
+ )
62
+
63
+ PROMPT_TMPL = "<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n"
64
+ RESPONSE_TMPL = "<think>\n{reasoning}\n</think>\n{answer}<|im_end|>"
65
+
66
+
67
+ def load_jsonl(path):
68
+ with open(path, encoding="utf-8") as fh:
69
+ return [json.loads(line) for line in fh]
70
+
71
+
72
+ class ReasoningDataset(Dataset):
73
+ """Prompt tokens are masked so loss falls only on <think>…</think> + answer."""
74
+
75
+ def __init__(self, rows, tokenizer, max_length):
76
+ self.rows = rows
77
+ self.tok = tokenizer
78
+ self.max_length = max_length
79
+
80
+ def __len__(self):
81
+ return len(self.rows)
82
+
83
+ def __getitem__(self, idx):
84
+ row = self.rows[idx]
85
+ prompt = PROMPT_TMPL.format(instruction=row["instruction"])
86
+ response = RESPONSE_TMPL.format(reasoning=row["reasoning"], answer=row["answer"])
87
+
88
+ prompt_ids = [self.tok.bos_token_id] + self.tok.encode(prompt, add_special_tokens=False)
89
+ response_ids = self.tok.encode(response, add_special_tokens=False)
90
+
91
+ input_ids = (prompt_ids + response_ids)[: self.max_length]
92
+ prompt_len = min(len(prompt_ids), len(input_ids))
93
+ labels = [IGNORE_INDEX] * prompt_len + input_ids[prompt_len:]
94
+
95
+ return {
96
+ "input_ids": torch.tensor(input_ids, dtype=torch.long),
97
+ "labels": torch.tensor(labels, dtype=torch.long),
98
+ }
99
+
100
+
101
+ @dataclass
102
+ class PaddingCollator:
103
+ pad_token_id: int
104
+
105
+ def __call__(self, features):
106
+ longest = max(len(f["input_ids"]) for f in features)
107
+ input_ids, labels, attention = [], [], []
108
+ for f in features:
109
+ pad = longest - len(f["input_ids"])
110
+ input_ids.append(torch.cat([f["input_ids"], torch.full((pad,), self.pad_token_id, dtype=torch.long)]))
111
+ labels.append(torch.cat([f["labels"], torch.full((pad,), IGNORE_INDEX, dtype=torch.long)]))
112
+ attention.append(torch.cat([torch.ones(len(f["input_ids"]), dtype=torch.long), torch.zeros(pad, dtype=torch.long)]))
113
+ return {
114
+ "input_ids": torch.stack(input_ids),
115
+ "labels": torch.stack(labels),
116
+ "attention_mask": torch.stack(attention),
117
+ }
118
+
119
+
120
+ def main():
121
+ print("[*] loading tokenizer + base model")
122
+ tok = AutoTokenizer.from_pretrained(BASE_MODEL)
123
+ added = tok.add_special_tokens({"additional_special_tokens": SPECIAL_TOKENS})
124
+ tok.chat_template = CHAT_TEMPLATE
125
+ print(f" added {added} special tokens -> vocab {len(tok)}")
126
+
127
+ model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, dtype=torch.float32)
128
+ model.resize_token_embeddings(len(tok))
129
+ model.config.use_cache = False
130
+ print(f" params: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M")
131
+
132
+ train_rows, eval_rows = load_jsonl(TRAIN_FILE), load_jsonl(EVAL_FILE)
133
+ print(f"[*] train {len(train_rows)} / eval {len(eval_rows)}")
134
+
135
+ args = TrainingArguments(
136
+ output_dir=OUTPUT_DIR,
137
+ num_train_epochs=EPOCHS,
138
+ per_device_train_batch_size=BATCH_SIZE,
139
+ per_device_eval_batch_size=BATCH_SIZE,
140
+ gradient_accumulation_steps=GRAD_ACCUM,
141
+ learning_rate=LEARNING_RATE,
142
+ lr_scheduler_type="cosine",
143
+ warmup_steps=WARMUP_STEPS,
144
+ weight_decay=WEIGHT_DECAY,
145
+ max_grad_norm=MAX_GRAD_NORM,
146
+ bf16=True,
147
+ logging_steps=25,
148
+ eval_strategy="steps",
149
+ eval_steps=EVAL_STEPS,
150
+ save_strategy="steps",
151
+ save_steps=EVAL_STEPS,
152
+ save_total_limit=2,
153
+ load_best_model_at_end=LOAD_BEST,
154
+ metric_for_best_model="eval_loss",
155
+ greater_is_better=False,
156
+ report_to=[],
157
+ seed=SEED,
158
+ dataloader_num_workers=2,
159
+ remove_unused_columns=False,
160
+ )
161
+
162
+ trainer = Trainer(
163
+ model=model,
164
+ args=args,
165
+ train_dataset=ReasoningDataset(train_rows, tok, MAX_LENGTH),
166
+ eval_dataset=ReasoningDataset(eval_rows, tok, MAX_LENGTH),
167
+ data_collator=PaddingCollator(pad_token_id=tok.pad_token_id),
168
+ )
169
+
170
+ if RESUME:
171
+ print(f"[*] resuming from {RESUME}")
172
+ trainer.train(resume_from_checkpoint=RESUME)
173
+
174
+ print("[*] saving best checkpoint")
175
+ im_end_id = tok.convert_tokens_to_ids("<|im_end|>")
176
+ model.config.use_cache = True
177
+ model.generation_config.eos_token_id = [tok.eos_token_id, im_end_id]
178
+ model.generation_config.pad_token_id = tok.pad_token_id
179
+ trainer.save_model(OUTPUT_DIR)
180
+ tok.save_pretrained(OUTPUT_DIR)
181
+
182
+ metrics = trainer.evaluate()
183
+ print("[*] final eval:", metrics)
184
+ Path(OUTPUT_DIR, "train_metrics.json").write_text(
185
+ json.dumps({"final_eval": metrics, "log_history": trainer.state.log_history}, ensure_ascii=False, indent=2),
186
+ encoding="utf-8",
187
+ )
188
+ print(f"[+] done -> {OUTPUT_DIR}")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
code/translate_gsm.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Translate a stratified 150k-row subset of Ajhesh7/gsm8k-reasoning-SFT-datas into Arabic
3
+ with ByteDance-Seed/Seed-X-PPO-7B (direct, per-row — numbers and names kept in place).
4
+
5
+ Unique segments are translated once (greedy decoding is deterministic, so identical input
6
+ gives identical output) and cached to a jsonl that makes the run resumable.
7
+
8
+ Usage: python translate_gsm.py [n_rows]
9
+ """
10
+ import json
11
+ import os
12
+ import random
13
+ import re
14
+ import sys
15
+ import time
16
+ from collections import defaultdict
17
+
18
+ import pyarrow.parquet as pq
19
+
20
+ sys.path.insert(0, ".")
21
+ from gsm_common import NUM_RE, SRC_PROMPT, parse
22
+
23
+ SRC_PARQUET = "data_gsm/data/train-00000-of-00001.parquet"
24
+ CACHE = "out_gsm/translations.jsonl"
25
+ N_ROWS = int(sys.argv[1]) if len(sys.argv) > 1 else 150_000
26
+ CHUNK = 20_000
27
+ SEED = 42
28
+ MODEL = "./models/Seed-X-PPO-7B"
29
+
30
+ NAME_RE = re.compile(r"\b[A-Z][a-z]{2,}\b")
31
+
32
+
33
+ def select_rows(texts, n, seed=SEED):
34
+ """Stratify by name/number-agnostic question pattern so every pattern is represented."""
35
+ rows = []
36
+ for i, t in enumerate(texts):
37
+ p = parse(t)
38
+ if p:
39
+ rows.append((i,) + p)
40
+ buckets = defaultdict(list)
41
+ for r in rows:
42
+ key = NAME_RE.sub("@", NUM_RE.sub("#", r[1]))
43
+ buckets[key].append(r)
44
+
45
+ rng = random.Random(seed)
46
+ for b in buckets.values():
47
+ rng.shuffle(b)
48
+
49
+ chosen, leftover = [], []
50
+ floor = max(1, n // (len(buckets) * 4))
51
+ for b in buckets.values():
52
+ chosen.extend(b[:floor])
53
+ leftover.extend(b[floor:])
54
+ rng.shuffle(leftover)
55
+ chosen.extend(leftover[: max(0, n - len(chosen))])
56
+ rng.shuffle(chosen)
57
+ print(f"[*] {len(buckets)} question patterns; floor {floor}/pattern; selected {len(chosen)} rows")
58
+ return chosen[:n]
59
+
60
+
61
+ def load_cache():
62
+ done = {}
63
+ if os.path.exists(CACHE):
64
+ with open(CACHE, encoding="utf-8") as fh:
65
+ for line in fh:
66
+ try:
67
+ rec = json.loads(line)
68
+ done[rec["src"]] = rec["tgt"]
69
+ except json.JSONDecodeError:
70
+ continue # truncated last line from a killed run
71
+ return done
72
+
73
+
74
+ def main():
75
+ os.makedirs("out_gsm", exist_ok=True)
76
+ texts = pq.read_table(SRC_PARQUET).to_pydict()["text"]
77
+ rows = select_rows(texts, N_ROWS)
78
+
79
+ with open("out_gsm/selected_rows.jsonl", "w", encoding="utf-8") as fh:
80
+ for idx, q, t, a in rows:
81
+ fh.write(json.dumps({"idx": idx, "question": q, "thinking": t, "answer": a}, ensure_ascii=False) + "\n")
82
+
83
+ segments = []
84
+ seen = set()
85
+ for _, q, t, _ in rows:
86
+ for s in (q, t):
87
+ if s not in seen:
88
+ seen.add(s)
89
+ segments.append(s)
90
+ done = load_cache()
91
+ todo = [s for s in segments if s not in done]
92
+ print(f"[*] {len(rows)} rows -> {len(segments)} unique segments; {len(done)} cached, {len(todo)} to translate")
93
+ if not todo:
94
+ print("[+] nothing to do")
95
+ return
96
+
97
+ from vllm import LLM, SamplingParams
98
+
99
+ llm = LLM(model=MODEL, max_num_seqs=512, gpu_memory_utilization=0.92, max_model_len=1024)
100
+ params = SamplingParams(temperature=0, max_tokens=256, skip_special_tokens=True)
101
+
102
+ start = time.time()
103
+ with open(CACHE, "a", encoding="utf-8") as fh:
104
+ for i in range(0, len(todo), CHUNK):
105
+ chunk = todo[i : i + CHUNK]
106
+ outs = llm.generate([SRC_PROMPT.format(text=s) for s in chunk], params)
107
+ for src, o in zip(chunk, outs):
108
+ fh.write(json.dumps({"src": src, "tgt": o.outputs[0].text.strip()}, ensure_ascii=False) + "\n")
109
+ fh.flush()
110
+ done_n = i + len(chunk)
111
+ rate = done_n / (time.time() - start)
112
+ eta = (len(todo) - done_n) / rate / 60
113
+ print(f"[*] {done_n}/{len(todo)} segments {rate:.1f} seg/s ETA {eta:.0f} min", flush=True)
114
+
115
+ print(f"[+] done in {(time.time() - start)/60:.1f} min -> {CACHE}")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()