Text Generation
Transformers
Safetensors
Arabic
llama
arabic
reasoning
chain-of-thought
math
gsm8k
small-language-model
slm
sft
conversational
text-generation-inference
File size: 9,281 Bytes
867d0f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
"""
Push the synthetic Arabic math-reasoning corpus to the Hub as a PRIVATE dataset with a card.

Usage: python push_synth_dataset.py [--repo oddadmix/arabic-math-reasoning-synth] [--dry-run]
"""
import argparse
import collections
import json
import os
from pathlib import Path

import pyarrow.parquet as pq
from huggingface_hub import HfApi

OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
PARQUET = OUT_DIR / "arabic_math_reasoning_synth.parquet"

# Hub ids for the generators, keyed by the `model` field the nodes record per row.
GEN_MODEL_HUB = {
    "gemma-3-12b-it": "google/gemma-3-12b-it",
    "Qwen3.6-27B-NVFP4": "nvidia/Qwen3.6-27B-NVFP4",
    "Qwen3.8-27B-Uncensored-NVFP4": "orcarouter/Qwen3.8-27B-Uncensored-NVFP4",
}

CARD = """---
license: apache-2.0
language:
- ar
size_categories:
- {size_cat}
task_categories:
- text-generation
tags:
- arabic
- math
- reasoning
- chain-of-thought
- gsm8k
- synthetic
configs:
- config_name: default
  data_files:
  - split: train
    path: arabic_math_reasoning_synth.parquet
---

# Arabic Math Reasoning (synthetic) — مسائل رياضيات عربية مع خطوات الحل
{status_note}
**{n:,}** Arabic grade-school math word problems, each with a step-by-step derivation and a
concluding sentence. Generated with {gen_blurb} and
**arithmetically verified** — every equation the reasoning states was re-evaluated, and rows whose
own arithmetic does not check out were dropped.

{gen_table}

> **بالعربية:** {n:,} مسألة حسابية عربية مع خطوات حل مفصّلة وجملة إجابة نهائية. جميع المعادلات
> داخل خطوات الحل تم التحقق منها حسابيًا، والصفوف التي تحتوي على خطأ حسابي مستبعدة.

## Schema

| field | description |
|---|---|
| `instruction` | the word problem, ending in a question |
| `reasoning` | numbered derivation, each step containing an explicit equation |
| `answer` | one concluding sentence (`إذن، …`) carrying the final number and its unit |
| `axis_*` | the variation axes this item was drawn from (domain, operation, steps, numbers, region, twist) |
| `task_id` | the generation task it came from |
| `gen_model` | which model generated the row |

It matches the `{{instruction, reasoning, answer}}` schema used by the Nawah-Reasoning training
scripts, so it drops straight into ChatML + `<think>` SFT.

## How it was built

Prompts are drawn from a **variation grid** — {n_domains} domains × {n_ops} operation types ×
step counts × number styles × {n_regions} country/currency pairs × {n_twists} structural twists —
seeded deterministically from the task index. This is a deliberate reaction to the corpus this was
built to supplement: a machine-translated Arabic GSM8K whose {gsm_rows:,} rows expand from only
2,814 underlying question templates. Here, **{templates:,}** distinct templates survive
number-masked deduplication.
{pool_note}

### Verification

Generated math is fluent and often wrong, so acceptance is mechanical rather than stylistic:

1. every `a op b = c` in the reasoning is re-evaluated with real arithmetic — one bad equation
   rejects the row;
2. the number in the answer sentence must equal the last computed result (a chain that is
   internally correct but ends on a different number is the failure that survives fluency checks);
3. no Latin characters, length bounds, and the answer must open with a conclusion marker.

**{accept_rate:.1%}** of parsed items passed. Rejections:

{reject_table}

`out_synth/rejects.jsonl` in the source repo keeps every rejected item with its reason, so the
filter itself can be audited.

## Limitations

This is **synthetic** data: the problems are machine-written and reflect that model's habits of
phrasing, its distribution of scenarios, and its idea of what a "typical" Arabic math problem
looks like. Verification proves each row's arithmetic is self-consistent — it does **not** prove
the problem is well-posed, that the setup is the only reasonable reading, or that the wording is
natural to any particular dialect region (the `axis_region` field sets currency and context, not
dialect; everything is MSA). Rows whose reasoning states no explicit equation are dropped, which
biases the corpus toward problems that decompose into clean arithmetic steps.
"""


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--repo", default="oddadmix/arabic-math-reasoning-synth")
    ap.add_argument("--target", type=int, default=100_000)
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    import synth_common as sc

    n = pq.read_metadata(PARQUET).num_rows
    stats = json.loads((OUT_DIR / "build_stats.json").read_text(encoding="utf-8"))

    # Credit every generator that actually contributed, from the build's own per-model counts.
    by_model = collections.Counter({k[len("model_"):]: v
                                    for k, v in stats["stats"].items() if k.startswith("model_")})
    def _link(name):
        hub = GEN_MODEL_HUB.get(name)
        return f"[`{name}`](https://huggingface.co/{hub})" if hub else f"`{name}`"
    names = [m for m, _ in by_model.most_common()]
    gen_blurb = " and ".join(filter(None, [", ".join(_link(m) for m in names[:-1]),
                                           _link(names[-1])])) if names else "an unrecorded model"
    gen_table = ("| generator | rows | share |\n|---|---:|---:|\n" + "\n".join(
        f"| {_link(m)} | {c:,} | {100 * c / max(n, 1):.1f}% |"
        for m, c in by_model.most_common())) if len(names) > 1 else ""
    total_rejects = max(sum(stats["reject_reasons"].values()), 1)
    folded = collections.Counter()
    for k, v in stats["reject_reasons"].items():
        folded[k.split(":", 1)[0]] += v
    reject_table = "| reason | count | share of rejects |\n|---|---:|---:|\n" + "\n".join(
        f"| `{k}` | {v:,} | {100 * v / total_rejects:.1f}% |"
        for k, v in folded.most_common(8))

    if n < args.target:
        status_note = (
            f"\n> ⚠️ **Interim snapshot — generation is still running.** This is "
            f"{n:,} of a planned {args.target:,} rows, published early so it can be reviewed and "
            f"trained against. Rows are only ever *appended*: the generator is resumable and "
            f"deterministic per task index, so everything here stays in the finished corpus "
            f"unchanged. Expect this repo to be overwritten with a larger version.\n")
    else:
        status_note = ""

    # The relational pool (task ids from 1,000,000) is a second, disjoint operation set added
    # after the first 100,323 rows shipped. Count it from the data rather than asserting it.
    n_rel = sum(1 for tid in pq.read_table(PARQUET, columns=["task_id"]).column("task_id").to_pylist()
                if tid >= 1_000_000)
    pool_note = ("""
The operation axis is drawn from **two disjoint pools**. The first {n_default:,} rows use 15
general operations (totals, percentages, unit rates, remainders, …). A later pass added a
**relational pool** of 8 operations — *ضعف* / *أضعاف*, *نصف*, *ثلث* و *ربع*, absolute increase and
decrease, three-way chains, and the inverse direction — contributing **{n_rel:,}** rows. These
express a quantity *relative to another entity* ("Ziad bought **double that number**"), which
forces a derivation step that a percentage problem does not. In the original corpus *ضعف /
أضعاف* appeared in 1.34% of rows and *أكثر بـ / أقل بـ* in 0.26% — against 17.6% for percentages —
and that gap is the documented weak spot of models trained on it, so the pool was generated
deliberately rather than left to the grid.
""".format(n_rel=n_rel, n_default=n - n_rel) if n_rel else "")

    size_cat = "100K<n<1M" if n >= 100_000 else "10K<n<100K" if n >= 10_000 else "1K<n<10K"
    card = CARD.format(
        n=n, gen_blurb=gen_blurb, gen_table=gen_table, size_cat=size_cat,
        n_domains=len(sc.DOMAINS), n_ops=stats["axis_coverage"]["op"], n_regions=len(sc.REGIONS),
        n_twists=len(sc.TWISTS), gsm_rows=142_969,
        templates=stats["unique_templates"], accept_rate=stats["accept_rate"],
        status_note=status_note,
        reject_table=reject_table,
        pool_note=pool_note,
    )
    (OUT_DIR / "README.md").write_text(card, encoding="utf-8")
    print(f"[+] wrote card ({len(card)} chars) | {n:,} rows | accept {stats['accept_rate']:.1%}")

    if args.dry_run:
        print("[dry-run] not pushing")
        return

    api = HfApi()
    api.create_repo(args.repo, repo_type="dataset", private=True, exist_ok=True)
    api.upload_file(path_or_fileobj=str(PARQUET), path_in_repo=PARQUET.name,
                    repo_id=args.repo, repo_type="dataset")
    api.upload_file(path_or_fileobj=str(OUT_DIR / "README.md"), path_in_repo="README.md",
                    repo_id=args.repo, repo_type="dataset")
    for script in ("synth_common.py", "synth_generate.py", "build_synth_dataset.py"):
        api.upload_file(path_or_fileobj=script, path_in_repo=script,
                        repo_id=args.repo, repo_type="dataset")
    print(f"[+] https://huggingface.co/datasets/{args.repo}")


if __name__ == "__main__":
    main()