42e commited on
Commit
014b14c
·
verified ·
1 Parent(s): 856ec72

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +23 -0
  2. README.md +116 -0
  3. configs/base.yaml +39 -0
  4. configs/generator_g.yaml +24 -0
  5. configs/verifier_v.yaml +24 -0
  6. data/verifier/README.md +21 -0
  7. data/verifier/stats.json +9 -0
  8. data/verifier/train.jsonl +0 -0
  9. data/verifier/val.jsonl +0 -0
  10. notes/Train Your Own Small Learning Model.md +0 -0
  11. notes/brainlift.md +306 -0
  12. notes/research-small-vs-large-math.md +287 -0
  13. pyproject.toml +24 -0
  14. requirements-gpu.txt +9 -0
  15. requirements.txt +19 -0
  16. results/teacher_bakeoff.md +28 -0
  17. scripts/check_teacher.py +45 -0
  18. scripts/download_prm800k.py +48 -0
  19. scripts/publish_hf.py +99 -0
  20. scripts/rebalance_verifier.py +53 -0
  21. scripts/smoke_all.sh +19 -0
  22. scripts/teacher_bakeoff.py +99 -0
  23. src/mathcompose/__init__.py +14 -0
  24. src/mathcompose/common/__init__.py +21 -0
  25. src/mathcompose/common/chat.py +108 -0
  26. src/mathcompose/common/config.py +33 -0
  27. src/mathcompose/common/env.py +39 -0
  28. src/mathcompose/common/io.py +25 -0
  29. src/mathcompose/common/math_grade.py +227 -0
  30. src/mathcompose/common/parallel.py +44 -0
  31. src/mathcompose/common/seeding.py +24 -0
  32. src/mathcompose/data/__init__.py +13 -0
  33. src/mathcompose/data/build_generator_dataset.py +117 -0
  34. src/mathcompose/data/build_verifier_dataset.py +85 -0
  35. src/mathcompose/data/schema.py +93 -0
  36. src/mathcompose/datagen/__init__.py +11 -0
  37. src/mathcompose/datagen/dedup.py +48 -0
  38. src/mathcompose/datagen/gen_generator_data.py +70 -0
  39. src/mathcompose/datagen/gen_verifier_data.py +96 -0
  40. src/mathcompose/datagen/prm800k_loader.py +113 -0
  41. src/mathcompose/eval/__init__.py +11 -0
  42. src/mathcompose/eval/compose.py +101 -0
  43. src/mathcompose/eval/math500.py +62 -0
  44. src/mathcompose/eval/parse.py +46 -0
  45. src/mathcompose/eval/prmbench.py +85 -0
  46. src/mathcompose/eval/processbench.py +116 -0
  47. src/mathcompose/eval/run.py +151 -0
  48. src/mathcompose/eval/runners.py +105 -0
  49. src/mathcompose/infer/__init__.py +0 -0
  50. src/mathcompose/infer/demo.py +67 -0
.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # secrets — NEVER commit
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # data + model artifacts (published to HF Hub, not committed)
7
+ # NOTE: anchored to repo root so they do NOT match the src/mathcompose/data/ package.
8
+ /data/
9
+ /runs/
10
+ *.safetensors
11
+ *.bin
12
+
13
+ # python
14
+ __pycache__/
15
+ *.pyc
16
+ .pytest_cache/
17
+ *.egg-info/
18
+ .ipynb_checkpoints/
19
+ .venv/
20
+ venv/
21
+
22
+ # keep results tables committed
23
+ !results/
README.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # mathcompose — two ≤4B math models that demonstrate the generator–verifier gap
2
+
3
+ **Thesis:** you can't make a 1.5B model *out-reason* a frontier model, but you can make a small
4
+ **specialist** win on the *easy side of the generator–verifier asymmetry* — and you can **measure the
5
+ asymmetry itself** with two small models that compose.
6
+
7
+ - **Model V — a generative process verifier.** Given a problem and a step-by-step solution, it emits
8
+ a paragraph-by-paragraph critique ending in `\boxed{k}` (0-based index of the first wrong step, or
9
+ `-1` if all correct). Evaluated on **ProcessBench** (objective harmonic-mean F1; target: beat
10
+ GPT-4o's ~61.9) with **PRMBench** as the adversarial robustness set.
11
+ - **Model G — a step-by-step solution generator.** Distilled from a frontier teacher and filtered so
12
+ only answer-correct traces survive.
13
+ - **The headline:** V reranks G's samples (V-weighted majority / best-of-n). We report the recovered
14
+ **generator–verifier gap** (`oracle pass@n − majority`) at ≤4B — the Weaver headroom result, but
15
+ ours.
16
+
17
+ Every core metric is an **objective checker** (step-label F1, boxed-answer equivalence) — **no
18
+ LLM-judge**, so there is no judge bias to argue about.
19
+
20
+ Both models share **one base** (`Qwen/Qwen2.5-Math-1.5B-Instruct`, apache-2.0) and **one training
21
+ path** (a generative verifier is just SFT), which is what makes a two-model build tractable.
22
+
23
+ ---
24
+
25
+ ## Why these choices (grounded in `notes/`)
26
+ - `notes/research-small-vs-large-math.md` finds the **only** clean sub-4B "beats GPT-4o" precedent is
27
+ process verification (GenPRM-1.5B on ProcessBench) → **V is the primary model**.
28
+ - The generator–verifier asymmetry is "the key lever" → **G + composition** turn two models into one
29
+ coherent demonstration.
30
+ - `notes/brainlift.md`: distill the *process* not the label, data quality over quantity, eval before
31
+ training → the whole pipeline follows this.
32
+ - Verified constraints baked in: base context is **4096 tokens**; `trl 1.7.1` has **no PRMTrainer**
33
+ (so V is generative SFT, not a discriminative PRM); transformers 5.x renamed `max_seq_length →
34
+ max_length` and `torch_dtype → dtype`.
35
+
36
+ ## Layout
37
+ ```
38
+ configs/ base.yaml + verifier_v.yaml + generator_g.yaml (extends base)
39
+ src/mathcompose/
40
+ common/ chat prompts (ProcessBench critic format), \boxed grading, config, io
41
+ teachers/ pluggable Anthropic/OpenAI teacher (+ offline DummyTeacher)
42
+ datagen/ PRM800K parsing, teacher critique synthesis, answer-filtered CoT, dedup
43
+ data/ V output schema + dataset builders (CLI)
44
+ eval/ ProcessBench, PRMBench, MATH, composition, runners, run.py (CLI)
45
+ train/ shared QLoRA SFT (train.py --task {v,g}) + colab_train.ipynb
46
+ infer/ demo.py (incl. base-vs-tuned side-by-side)
47
+ tests/ CPU smoke suite (26 tests) scripts/smoke_all.sh
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ### 0. Prove the harness on CPU (no GPU, no key)
53
+ ```bash
54
+ pip install -e .
55
+ ./scripts/smoke_all.sh # fast logic tests
56
+ ./scripts/smoke_all.sh --full # + tiny-model train/eval + HF dataset probes
57
+ ```
58
+
59
+ ### 1. Build the datasets (needs a teacher key)
60
+ The default teacher is the **promptlens gateway** (OpenAI-compatible). Copy
61
+ `.env.example` → `.env` and set `TFY_API_KEY=tfy-...` (auto-loaded). Verify with
62
+ `python scripts/check_teacher.py`. (Use `--teacher openai|anthropic` for native
63
+ providers instead.)
64
+ ```bash
65
+ cp .env.example .env && $EDITOR .env # set TFY_API_KEY
66
+ # Model V: PRM800K first-error labels + teacher-synthesized critiques (labels stay authoritative)
67
+ python -m mathcompose.data.build_verifier_dataset --prm800k data/raw/prm800k/phase2_train.jsonl \
68
+ --teacher promptlens --limit 15000
69
+ # Model G: distill CoT, keep only answer-correct traces
70
+ python -m mathcompose.data.build_generator_dataset --source AI-MO/NuminaMath-CoT \
71
+ --teacher promptlens --limit 8000
72
+ ```
73
+ Contamination against ProcessBench/MATH-500 is enforced automatically (`--no-dedup` to disable).
74
+
75
+ ### 2. Train (GPU — free Colab T4 works; see `src/mathcompose/train/colab_train.ipynb`)
76
+ ```bash
77
+ python -m mathcompose.train.train --task v --config configs/verifier_v.yaml
78
+ python -m mathcompose.train.train --task g --config configs/generator_g.yaml
79
+ ```
80
+
81
+ ### 3. Evaluate (objective, base vs tuned) + the composition
82
+ ```bash
83
+ python -m mathcompose.eval.run processbench --tag base --maj-k 1
84
+ python -m mathcompose.eval.run processbench --adapter runs/verifier_v --tag tuned --maj-k 8
85
+ python -m mathcompose.eval.run prmbench --adapter runs/verifier_v --tag tuned
86
+ python -m mathcompose.eval.run math --adapter runs/generator_g --tag tuned
87
+ python -m mathcompose.eval.run compose --gen-adapter runs/generator_g --ver-adapter runs/verifier_v \
88
+ --dataset Maxwell-Jia/AIME_2024 --split train --problem-field Problem --answer-field Answer
89
+ python -m mathcompose.eval.run report # -> results/RESULTS.md
90
+ ```
91
+
92
+ ### 4. Demo (for the video)
93
+ ```bash
94
+ python -m mathcompose.infer.demo --task v --adapter runs/verifier_v --compare \
95
+ --problem "..." --steps "step 0" "step 1 (wrong)"
96
+ ```
97
+
98
+ ## What runs where
99
+ | Piece | Where | Needs |
100
+ |---|---|---|
101
+ | Harness + smoke tests | this repo, CPU | nothing |
102
+ | Data generation | anywhere | a teacher API key (Anthropic/OpenAI) |
103
+ | QLoRA training | Colab/Modal/RunPod GPU | a CUDA GPU (free T4 ok) |
104
+ | Publishing datasets/models | — | your HF token |
105
+ | Demo video | — | you record; script in the plan |
106
+
107
+ ## Honest framing (from the research note)
108
+ "Beats frontier" means **beats GPT-4o**, not o1-mini/o3. Sub-4B evidence is thin (most precedents are
109
+ 7B) — expect a step down. The real, defensible claim is the **base-vs-tuned delta** and the
110
+ **composition lift**, both measured objectively. PRMBench is reported alongside ProcessBench as the
111
+ independent robustness check.
112
+
113
+ ## License / attribution
114
+ Code: MIT. Training data derives from **PRM800K** (MIT, OpenAI) and **NuminaMath-CoT** (apache-2.0);
115
+ evals use **ProcessBench**, **PRMBench**, **MATH-500**, **AIME-2024** (all apache-2.0). Attribute
116
+ these in any published dataset/model card.
configs/base.yaml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Shared defaults inherited by verifier_v.yaml and generator_g.yaml.
2
+ # Verified facts baked in: Qwen2.5-Math-1.5B-Instruct is apache-2.0, ctx=4096,
3
+ # uses the CoT system prompt "Please reason step by step, and put your final
4
+ # answer within \boxed{}." Do NOT use <|endoftext|> as pad token.
5
+
6
+ model:
7
+ base_id: "Qwen/Qwen2.5-Math-1.5B-Instruct"
8
+ max_context: 4096 # HARD ceiling for this base — mind truncation.
9
+ pad_token: "<|fim_pad|>" # distinct from eos <|endoftext|>; avoids infinite-gen bug.
10
+
11
+ lora:
12
+ r: 16
13
+ alpha: 32 # alpha = 2r
14
+ dropout: 0.05
15
+ target_modules: "all-linear" # attention + MLP (LoRA-Learns-Less best practice)
16
+
17
+ train:
18
+ epochs: 3
19
+ learning_rate: 2.0e-4
20
+ lr_scheduler_type: "cosine"
21
+ warmup_ratio: 0.03
22
+ per_device_train_batch_size: 2
23
+ gradient_accumulation_steps: 8
24
+ gradient_checkpointing: true
25
+ optim: "paged_adamw_8bit"
26
+ bf16: true
27
+ packing: false
28
+ completion_only_loss: true # loss on completion only (robust vs assistant_only_loss for Qwen)
29
+ seed: 0
30
+ eval_strategy: "steps" # transformers 5.x name (not evaluation_strategy)
31
+ eval_steps: 50
32
+ save_steps: 50
33
+ logging_steps: 10
34
+
35
+ quant:
36
+ load_in_4bit: true
37
+ bnb_4bit_quant_type: "nf4"
38
+ bnb_4bit_use_double_quant: true
39
+ bnb_4bit_compute_dtype: "bfloat16"
configs/generator_g.yaml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model G — step-by-step solution generator.
2
+ # Inherits configs/base.yaml; keys here override.
3
+ extends: "base.yaml"
4
+
5
+ task: "g"
6
+ output_dir: "runs/generator_g"
7
+ hub_model_id: null # e.g. "your-username/mathcompose-generator-1.5b"
8
+
9
+ data:
10
+ train_path: "data/generator/train.jsonl"
11
+ val_path: "data/generator/val.jsonl"
12
+ # jsonl rows are {"prompt": <problem as chat messages or str>, "completion": <CoT + \boxed{answer}>}
13
+
14
+ train:
15
+ max_length: 2048
16
+ per_device_train_batch_size: 2
17
+ gradient_accumulation_steps: 8
18
+
19
+ eval:
20
+ math500_id: "HuggingFaceH4/MATH-500"
21
+ aime_id: "Maxwell-Jia/AIME_2024"
22
+ # Composition (V reranks G):
23
+ compose_n: 8 # samples per problem
24
+ compose_temperature: 0.8
configs/verifier_v.yaml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model V — generative process verifier / first-error localizer.
2
+ # Inherits configs/base.yaml; keys here override.
3
+ extends: "base.yaml"
4
+
5
+ task: "v"
6
+ output_dir: "runs/verifier_v"
7
+ hub_model_id: null # e.g. "your-username/mathcompose-verifier-1.5b"
8
+
9
+ data:
10
+ train_path: "data/verifier/train.jsonl"
11
+ val_path: "data/verifier/val.jsonl"
12
+ # jsonl rows are {"prompt": <chat messages or str>, "completion": <critique + \boxed{k}>}
13
+
14
+ train:
15
+ # Verifier inputs (problem + full solution + per-step critique) are long:
16
+ # push seq_len toward the 4096 ceiling. Report truncation rate at build time.
17
+ max_length: 3072
18
+ per_device_train_batch_size: 1
19
+ gradient_accumulation_steps: 16
20
+
21
+ eval:
22
+ processbench_splits: ["gsm8k", "math", "olympiadbench", "omnimath"]
23
+ maj_k: 8 # Maj@k test-time voting for the headline number
24
+ gpt4o_reference_f1: 61.9 # standard proprietary baseline to beat
data/verifier/README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ task_categories: [text-generation]
4
+ tags: [math, process-verification, ProcessBench, PRM800K, mathcompose]
5
+ ---
6
+
7
+ # mathcompose — Model V (process verifier) SFT data
8
+
9
+ First-error-localization critiques for training a small generative math verifier.
10
+ Each row: a problem + a step-indexed candidate solution (`prompt`) and a
11
+ paragraph-by-paragraph critique ending in `\boxed{{k}}` (`completion`), where `k`
12
+ is the 0-based index of the first erroneous step (`-1` = all correct).
13
+
14
+ - **Labels** are ground truth from **PRM800K** (OpenAI, MIT).
15
+ - **Critiques** distilled from **claude-opus-4-8** (rationalization conditioned on
16
+ the known label; the boxed index is stamped authoritatively, not trusted to the teacher).
17
+ - **Deduped** against ProcessBench + MATH-500 (no eval contamination).
18
+ - `train.jsonl`/`val.jsonl` are class-balanced (~50/50 erroneous/all-correct);
19
+ `*_full.jsonl` keep the natural PRM800K distribution.
20
+
21
+ Built by the mathcompose harness. Eval target: ProcessBench first-error F1.
data/verifier/stats.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "n_total": 3214,
3
+ "n_train": 3054,
4
+ "n_val": 160,
5
+ "n_erroneous": 747,
6
+ "n_all_correct": 2467,
7
+ "teacher": "promptlens",
8
+ "deduped": true
9
+ }
data/verifier/train.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
data/verifier/val.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
notes/Train Your Own Small Learning Model.md ADDED
The diff for this file is too large to render. See raw diff
 
notes/brainlift.md ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Brainlift: Training and Optimizing Small Language Models to Reliably Perform Specific Behaviors
2
+
3
+ ## TL;DR
4
+ - **For instilling one narrow, falsifiable behavior into a 0.6B–4B model, the dataset is the deliverable:** LIMA (1,000 examples), s1 (1,000 traces), and QLoRA's own ablations converge on quality-and-diversity over quantity, and QLoRA makes single-GPU tuning routine. Capability is mostly *elicited* from a base model that already latently has it — not installed from scratch.
5
+ - **QLoRA is the right default precisely because it "forgets less"** (Biderman et al.), and reliability comes from an engineering stack — distilling a teacher's *process*, constrained decoding for output form, and an adversarial base-vs-tuned eval — not from a bigger model or a cleverer prompt.
6
+ - **Build the evaluation before you generate a single training row, and make it adversarial**, because small (and large) LMs fail in predictable, testable ways: compositional reasoning collapse (Faith and Fate), irrelevant-clause sensitivity (GSM-Symbolic, up to 65% drop), the reversal curse, lost-in-the-middle, and confident hallucination.
7
+
8
+ ## Purpose
9
+ A portable research knowledge base for instilling ONE specific, falsifiable behavior into a small open base model (0.6B–4B) via supervised fine-tuning (QLoRA), using distillation from a frontier teacher to build the dataset, and evaluating with LLM-as-judge plus a base-vs-tuned comparison.
10
+
11
+ **In scope:** data-centric generation/curation and distillation; PEFT (LoRA/QLoRA); SFT mechanics; preference optimization (DPO and variants); compression/quantization; small-model landscape; evaluation and LLM-as-judge; reliability and structured output; failure modes and limits of (small) LMs; practitioner playbooks.
12
+
13
+ **Out of scope:** pretraining from scratch; large-scale RLHF infrastructure; multimodal training; serving/infra beyond quantization formats; agent orchestration.
14
+
15
+ ## Key Findings
16
+ 1. **Small-data alignment works when the capability is latent.** Two independent 1,000-example results (LIMA for general alignment, s1 for reasoning) plus QLoRA's finding that data quality beats size establish that a narrow behavior can be reached cheaply — but LoRA-Learns-Less shows this breaks down for genuinely new hard skills.
17
+ 2. **Distill the process, not the style.** Orca and DeepSeek-R1 show reasoning traces transfer real capability into small models; Orca explicitly warns that style-only imitation inflates apparent quality on shallow evals.
18
+ 3. **PEFT trades peak capacity for forgetting-resistance**, which is a feature for narrow-behavior work. Full fine-tuning still wins on hard new skills.
19
+ 4. **LLM-as-judge is usable (>80% human agreement) but biased** (position, verbosity, self-preference); design evals around the biases.
20
+ 5. **Reliability = data + constrained decoding + calibration eval.** Neither fine-tuning nor grammar-constrained decoding is individually sufficient.
21
+ 6. **Small models fail in named, reproducible ways** — build adversarial evals that target exactly those failure modes.
22
+
23
+ ## DOK 4 — Spiky Points of View
24
+
25
+ **1. For a narrow behavior, ~1,000–2,000 excellent, diverse examples is enough; past that, more data is usually wasted effort.**
26
+
27
+ - _Why:_ pretraining already installed the capability, so SFT only has to pin a consistent output policy over what the model already knows (Superficial Alignment Hypothesis). Rows past the first ~1k mostly re-teach that and add overfitting risk.
28
+ - What still helps is coverage of the behavior's input variety, not raw count.
29
+ - Two independent replications on very different tasks (LIMA on alignment, s1 on competition math) landed on the same ~1,000 figure, so it isn't a single-domain fluke.
30
+ - _Evidence:_ LIMA (1,000 examples rival RLHF models trained on 52× more data), s1 (7 vs 394 GPU-hours for near-identical accuracy), QLoRA's quality>size ablation.
31
+ - _When wrong:_ if the base genuinely lacks the capability (novel reasoning, unfamiliar code), LoRA-Learns-Less shows you need more data AND higher rank or full FT.
32
+ - _Abandon when:_ the base-vs-tuned delta plateaus below target despite clean, diverse data; that plateau means the capability is absent, not under-sampled.
33
+
34
+ **2. Use QLoRA by default and treat "forgets less" as a feature, not a compromise.**
35
+
36
+ - _Why:_ full FT rewrites high-rank directions across all weights and injects "intruder dimensions" that overwrite unrelated pretrained abilities; LoRA's low-rank cap keeps the update from traveling that far.
37
+ - For one-behavior work you don't want to disturb anything else, so LoRA's capacity ceiling is the guardrail you want, not a weakness.
38
+ - The alternative fails concretely: Raschka saw a model "unlearn arithmetic" from narrow SFT; Luo et al. show forgetting worsens with scale under continual full tuning.
39
+ - QLoRA is single-GPU with no measured quality loss on tested tasks, so the default costs nothing.
40
+ - _Evidence:_ Biderman et al. (LoRA forgets less, retains out-of-domain capability), intruder-dimensions paper (the mechanism), QLoRA (single-GPU, no perf loss), Raschka.
41
+ - _Counter:_ full FT wins peak in-domain accuracy on genuinely hard new skills.
42
+ - _Wrong if:_ your ONLY metric is target-task accuracy and base-capability retention is irrelevant; then use full FT or high-rank (r≈256) LoRA.
43
+
44
+ **3. Build the eval before you generate a single training row, make it adversarial, and trust base-vs-tuned deltas over any single judge score.**
45
+
46
+ - Without a pre-committed eval, "we fine-tuned a model" is unfalsifiable; the eval is what turns a vibe into a measurement.
47
+ - Make it adversarial: small models fail in named ways (irrelevant clauses, reversed relations, mid-context facts) a happy-path test never triggers, so a fine-tune that clears only easy cases has learned the surface.
48
+ - Read the delta, not the absolute score: judge biases (position, verbosity, self-preference) distort raw numbers but largely cancel when one judge compares two outputs with randomized order.
49
+ - Prefer binary pass/fail over 1–5: it forces a crisp definition and is far more stable across judge runs.
50
+ - _Evidence:_ Husain (evals first, 60–80% of effort; binary > 1–5), Zheng et al. (quantified biases; >80% human agreement), GSM-Symbolic and Reversal Curse (fragilities to target).
51
+ - _Spiky corollary:_ a fine-tune shipped with no robustness eval should not be trusted, however good the demos.
52
+ - _Wrong if:_ the behavior is code-verifiable by exact-match/regex (skip the judge), or you've validated high judge-human agreement on your task.
53
+
54
+ **4. Reliability is an engineering stack (data + constrained decoding + a calibration/abstention eval), not a bigger model or a better prompt.**
55
+
56
+ - Three levers fix three failure sources: fine-tuning raises the odds of correct content but can't guarantee valid form; constrained decoding guarantees form but can push the model off-path and hurt content; a calibration eval surfaces the residual confident-guess errors that only rewarding "I don't know" suppresses.
57
+ - The deeper point: reliability is a tail property (the 1-in-50 malformed output breaks the pipeline), and scaling or prompting shifts the average while leaving the tail. Engineer the tail.
58
+ - _Evidence:_ constrained-decoding literature (Outlines/XGrammar guarantee form), Why-Models-Hallucinate (residual errors are calibration failures), plus fine-tuning raising correct-behavior probability.
59
+ - _Counter:_ constrained decoding adds ~2–5× overhead and can degrade content; over-constraining hides genuine uncertainty.
60
+ - _Wrong if:_ the base is already schema-reliable and latency can't absorb decoding overhead.
61
+
62
+ ## DOK 3 — Insights
63
+
64
+ **1. The dataset is the deliverable; capability is mostly elicited, not installed.**
65
+
66
+ - LIMA (1,000 examples), s1 (1,000 traces; 394 vs 7 GPU-hours), and QLoRA's quality>size finding converge: a small, diverse, high-quality set beats a large noisy one.
67
+ - _Why:_ pretraining did the heavy lifting, so SFT mainly selects a consistent output policy over existing knowledge; beyond a point you pay to re-teach what the model knows, and coverage matters more than count.
68
+ - _Tension:_ LoRA-Learns-Less shows that for genuinely NEW capabilities (code, math) low-rank small-data tuning underperforms full FT—elicitation works when the capability is latent, not when it's absent.
69
+
70
+ **2. Distill the process, not the style.**
71
+
72
+ - Orca and DeepSeek-R1 show that transferring reasoning traces/explanations (not just final answers) is what moves a real capability into a small model; Orca warns that style-only imitation inflates apparent quality.
73
+ - _Why:_ final-answer-only data teaches outputs without the intermediate computation, so it copies tone and collapses when the problem shifts; traces hand over the steps the student can internalize. It's also why style-rewarding evals overrate imitation-trained models.
74
+ - _Tension:_ DeepSeek-R1 distillation still leaves persistent gaps for ≤7B students; process distillation narrows but doesn't close the teacher-student gap.
75
+
76
+ **3. LoRA/QLoRA is the right default for behavior instillation precisely because it forgets less.**
77
+
78
+ - Biderman et al. and the intruder-dimension paper show LoRA sacrifices peak capacity but preserves base competence—ideal when you want ONE behavior without collateral regression.
79
+ - _Why:_ the low-rank cap limits how far the update moves the weights, so unrelated abilities survive; full FT has no such cap. For single-behavior work, preserving everything else is the goal, so the capacity limit is the property you want.
80
+ - Raschka's "unlearned arithmetic" and Luo et al.'s forgetting-scales-with-size results show full FT/narrow data risks broad capability loss.
81
+ - _Steelman for full FT:_ if the behavior is a hard new skill, full FT (or high-rank LoRA, α=2r, all modules) wins on the target metric.
82
+
83
+ **4. Evaluation must precede and drive training.**
84
+
85
+ - Hamel Husain (evals first, 60–80% of effort), LIMA (perplexity ≠ quality), and practitioner consensus all treat the eval harness as the real deliverable.
86
+ - _Why:_ loss and perplexity don't track the behavior you care about, so without a behavioral eval you optimize a proxy; the eval is also the only signal for whether the fix is more data, different data, or a different method.
87
+ - A base-vs-tuned LLM-judge comparison on a held-out set is the minimum viable measurement.
88
+
89
+ **5. LLM-as-judge is usable but biased; design around it.**
90
+
91
+ - Zheng et al. quantify position, verbosity, and self-preference biases (position can swing win-rate 10–15 points; length bias ~+17%).
92
+ - _Why it's still workable:_ the biases are systematic, not random, so they distort absolute scores but largely cancel in A/B comparisons under a fixed judge with randomized order.
93
+ - Mitigations: randomize order, control for length, don't judge with the same model family, prefer binary pass/fail (Husain).
94
+ - _Tension:_ judges agree with humans >80%, so the biases are manageable, not disqualifying.
95
+
96
+ **6. Reliability comes from data + decoding, not prompting.**
97
+
98
+ - Fine-tuning raises the probability of correct behavior; constrained decoding (Outlines/XGrammar) guarantees output FORM.
99
+ - _Why both:_ they govern different things (content odds vs form validity), and reliability is a tail property, so closing it takes both at once rather than a prompt that only shifts the average.
100
+ - Neither alone suffices: constrained decoding can force off-distribution tokens and hurt content; fine-tuning can't guarantee 100% valid structure.
101
+
102
+ **7. Small models fail in predictable, testable ways; build adversarial evals for exactly those.**
103
+
104
+ - Named fragilities: GSM-Symbolic (irrelevant-clause drops up to 65%), Faith-and-Fate (multiplication 59%→4%), Reversal Curse (near-0% on reversed facts), Lost-in-the-Middle (U-shaped context use), and the hallucination-calibration work.
105
+ - _Why it helps:_ because the failures are named and reproducible, the papers' own perturbations become your robustness suite, turning known weaknesses into a pre-deployment checklist.
106
+ - A robustness eval should perturb numbers, add distractor clauses, reverse relations, and move key info to the middle.
107
+
108
+ **8. "Emergence" is partly a measurement artifact.**
109
+
110
+ - Schaeffer et al. show apparent emergence tracks metric choice.
111
+ - _Why it matters here:_ a behavior that looks absent at small scale under exact-match may just be the metric masking steady progress; continuous metrics (edit distance, token-level, Brier) expose the gradient and prevent premature abandonment.
112
+
113
+ ## DOK 2 — Knowledge Tree
114
+
115
+ ### A. Data-centric AI & distillation
116
+
117
+ **LIMA: Less Is More for Alignment** — Zhou et al., Meta AI, NeurIPS 2023. https://arxiv.org/abs/2305.11206. *Peer-reviewed / researcher.*
118
+ - Fine-tuned LLaMa-65B on only 1,000 curated prompt-response pairs with standard supervised loss, no RLHF.
119
+ - In a controlled human study, LIMA responses were equivalent or preferred to GPT-4 in 43% of cases; it beat DaVinci003 (RLHF-trained) and Alpaca-65B (trained on 52× more data). Even GPT-4 preferred LIMA's output over its own 19% of the time.
120
+ - Hyperparameters: 15 epochs, LR 1e-5→1e-6, batch 32; perplexity did NOT correlate with generation quality, so checkpoints were selected manually on a 50-example dev set.
121
+ - **DOK 2 Summary:** Proposes the "Superficial Alignment Hypothesis" — nearly all knowledge is learned in pretraining and alignment mainly teaches format/style, so a tiny high-quality dataset can suffice. Intellectual foundation for behavior-focused small-data fine-tuning.
122
+
123
+ **Self-Instruct / Stanford Alpaca** — Wang et al. 2022 (https://arxiv.org/abs/2212.10560); Taori et al. 2023. *Peer-reviewed + technical / practitioner.*
124
+ - Self-Instruct bootstraps 52K instructions from 175 seed tasks using a base LLM; improved the SUPER-NATURALINSTRUCTIONS baseline by 33%.
125
+ - Alpaca reused the pipeline with text-davinci-003, generated 52K samples, and fine-tuned LLaMA-7B cheaply; AlpaGasus later showed filtering Alpaca's low-quality rows improves the model.
126
+ - **DOK 2 Summary:** Established the template for distilling a teacher's behavior into a smaller student via synthetic instruction data — the direct ancestor of the "generate data from a frontier teacher" workflow.
127
+
128
+ **Evol-Instruct / WizardLM** — Xu et al. 2023. *Peer-reviewed / researcher.*
129
+ - Iteratively rewrites simple instructions into more complex/diverse ones (in-depth and in-breadth evolution).
130
+ - **DOK 2 Summary:** Complexity and diversity of instructions, not just count, drive instruction-following quality — a concrete lever for dataset design.
131
+
132
+ **Orca: Progressive Learning from Complex Explanation Traces of GPT-4** — Mukherjee et al., Microsoft 2023. https://arxiv.org/abs/2306.02707. *Peer-reviewed / researcher.*
133
+ - 13B model trained to imitate GPT-4 reasoning via explanation traces and step-by-step thought, with ChatGPT as intermediate teacher; ~5M samples.
134
+ - Beat Vicuna-13B by >100% on Big-Bench Hard and 42% on AGIEval.
135
+ - Explicitly warns: naive imitation makes students copy the STYLE but not the REASONING of teachers, and shallow evaluation overestimates capability.
136
+ - **DOK 2 Summary:** To transfer a behavior (not just surface style), distill the process — reasoning traces and explanations �� and evaluate rigorously, because style mimicry masks capability gaps.
137
+
138
+ **Textbooks Are All You Need (phi-1) / phi-1.5** — Gunasekar et al. 2023 (https://arxiv.org/abs/2306.11644); Li et al. 2023 (https://arxiv.org/abs/2309.05463). *Peer-reviewed / researcher.*
139
+ - phi-1: 1.3B params, trained on 6B tokens of "textbook-quality" web data + 1B tokens of GPT-3.5 synthetic textbooks/exercises; 50.6% pass@1 on HumanEval, 55.5% on MBPP. phi-1-small (350M) still hit 45% on HumanEval.
140
+ - phi-1.5: 1.3B, matches models 5× larger on reasoning; challenges the notion that capability is determined solely by scale.
141
+ - **DOK 2 Summary:** Data quality can substitute for scale — carefully curated/synthetic "textbook" data lets tiny models punch far above their weight, reinforcing the data-as-deliverable thesis.
142
+
143
+ **FineWeb-Edu / Cosmopedia / SmolLM-Corpus** — Hugging Face (Penedo et al.). *Technical / practitioner.*
144
+ - Cosmopedia: 25B tokens, 30M synthetic samples (Mixtral-generated textbooks/blogs/stories) — largest open synthetic dataset at release. FineWeb-Edu is an educational-quality filtered subset of FineWeb; FineMath ~50B tokens.
145
+ - **DOK 2 Summary:** Open replications confirm that quality-filtering and synthetic generation at scale produce better small models than raw web crawl.
146
+
147
+ *Collective note:* The synthetic-data surveys ("Best Practices and Lessons Learned on Synthetic Data," https://arxiv.org/abs/2404.07503; "A Survey on Post-training of LLMs," https://arxiv.org/abs/2503.06072) document that the recurring quality levers are complexity, diversity, and scale, and that quality filtering beats raw quantity. Tooling: **distilabel/Argilla** for programmatic synthetic-data and preference-pair pipelines.
148
+
149
+ ### B. PEFT / LoRA
150
+
151
+ **LoRA: Low-Rank Adaptation** — Hu et al., Microsoft, ICLR 2022. https://arxiv.org/abs/2106.09685. *Peer-reviewed / researcher.*
152
+ - Freezes base weights, learns low-rank update matrices A,B; far fewer trainable params and no inference latency once merged.
153
+ - **DOK 2 Summary:** The core PEFT method — adapt behavior by learning a small rank-r perturbation instead of all weights.
154
+
155
+ **QLoRA: Efficient Finetuning of Quantized LLMs** — Dettmers et al., UW, NeurIPS 2023. https://arxiv.org/abs/2305.14314. *Peer-reviewed / researcher.*
156
+ - Finetunes a 65B model on a single 48GB GPU. Innovations: 4-bit NormalFloat (NF4), double quantization (~0.37 bits/param saved, ~3GB on a 65B model), paged optimizers. Storage in NF4, compute de-quantized to bf16.
157
+ - Guanaco reached 99.3% of ChatGPT on the Vicuna benchmark in 24h on one GPU; the authors trained >1,000 models.
158
+ - Found data quality > dataset size for instruction following, and that MMLU does not predict chatbot quality.
159
+ - **DOK 2 Summary:** Makes single-GPU fine-tuning of small models routine, with no measured performance loss vs 16-bit for the tested tasks.
160
+
161
+ **LoRA Learns Less and Forgets Less** — Biderman et al., Columbia/Databricks Mosaic, TMLR 2024. https://arxiv.org/abs/2405.09673. *Peer-reviewed / researcher.*
162
+ - On code and math, standard low-rank LoRA substantially underperforms full fine-tuning; full FT learns perturbations 10–100× higher rank than typical LoRA configs.
163
+ - But LoRA forgets less (better retains out-of-domain capability) and maintains more diverse generations; mitigates forgetting more than weight decay/dropout. Example: code IFT full-FT scored 0.414 vs LoRA r=64's 0.509 on retention.
164
+ - Best practices: target all modules (attention + MLP), use α=2r, rank ~16 as a starting point, ≥4 epochs; LoRA is highly LR-sensitive.
165
+ - **DOK 2 Summary:** LoRA trades peak in-domain capacity for regularization/forgetting-resistance — a favorable trade when instilling a narrow behavior without wrecking base competence.
166
+
167
+ **LoRA vs Full Fine-tuning: An Illusion of Equivalence** — Shuttleworth et al. 2024. https://arxiv.org/abs/2410.21228. *Peer-reviewed / researcher.*
168
+ - LoRA introduces "intruder dimensions" — high-ranking singular vectors dissimilar to the pretrained weights — that full FT does not; causal intervention on them shows they drive forgetting.
169
+ - **DOK 2 Summary:** LoRA and full FT reach structurally different solutions; that difference explains both LoRA's forgetting-resistance and its capacity limits.
170
+
171
+ **Sebastian Raschka — Practical Tips for Finetuning LLMs Using LoRA** (Ahead of AI, 2023). https://magazine.sebastianraschka.com/p/practical-tips-for-finetuning-llms. *Technical blog / practitioner.*
172
+ - QLoRA: ~33% memory savings for ~33% runtime increase. Optimizer choice barely matters. In his sweep r=256/α=512 gave the best performance.
173
+ - Apply LoRA to ALL layers, not just K/V. Multiple epochs on small instruction sets can overfit and hurt.
174
+ - Observed a model "unlearned arithmetic" because Alpaca lacked arithmetic examples — a concrete instance of narrow capability regression.
175
+ - **DOK 2 Summary:** Hard-won operational defaults for LoRA; the "unlearned arithmetic" anecdote grounds the forgetting risk from narrow data.
176
+
177
+ ### C. SFT mechanics
178
+ - **Loss masking / train-on-completions-only**: standard TRL SFTTrainer practice, but "Instruction Tuning With Loss Over Instructions" (Shi et al. 2024) questions always masking the instruction.
179
+ - **Chat templates**: model-specific special tokens (LIMA introduced an explicit EOT token distinct from EOS); mismatched templates silently degrade behavior.
180
+ - **Hyperparameters**: small datasets overfit fast; LIMA found perplexity uncorrelated with quality, so gate on behavioral eval, not loss. Use packing for throughput; watch effective batch size and LR.
181
+ - **DOK 2 Summary (collective):** For narrow-behavior SFT the choices that matter most are correct chat-template/token handling, masking the prompt so loss applies only to completions, and early stopping by behavioral eval rather than loss.
182
+
183
+ ### D. Preference optimization
184
+
185
+ **DPO: Direct Preference Optimization** — Rafailov et al., Stanford, NeurIPS 2023. https://arxiv.org/abs/2305.18290. *Peer-reviewed / researcher.*
186
+ - Reparameterizes the RLHF reward so the optimal policy has a closed form; trains directly on preference pairs with a simple classification loss — no separate reward model, no sampling during training.
187
+ - Matches or beats PPO-based RLHF on sentiment control, summarization, and single-turn dialogue while being far simpler and more stable.
188
+ - **DOK 2 Summary:** Makes preference tuning accessible on small hardware; the go-to "stretch" step after SFT when you have chosen/rejected pairs.
189
+
190
+ **ORPO, KTO, SimPO, IPO** — Hong et al. 2024; Ethayarajh et al. 2024; Meng et al. 2024 (https://arxiv.org/abs/2405.14734); Azar et al. 2024. *Peer-reviewed / researcher.*
191
+ - ORPO: reference-model-free, folds preference into SFT via an odds-ratio term (one model, one dataset), more efficient but hyperparameter-sensitive.
192
+ - KTO: learns from unpaired binary (good/bad) signals — no preference pairs required.
193
+ - SimPO: reference-free, length-normalized reward + target margin; reported to outperform DPO and ORPO in its own experiments while tolerating noisier labels.
194
+ - IPO: addresses DPO overfitting with a theoretically grounded objective.
195
+ - **DOK 2 Summary:** A menu trading off data format (paired vs unpaired), compute (reference model or not), and stability; choose by what feedback you can cheaply generate.
196
+
197
+ *Collective note:* Argilla's RLHF overview (https://argilla.io/blog/mantisnlp-rlhf-part-9/) and post-training-stack write-ups stress that DPO alone optimizes generic preference; controllable/domain behavior often needs SFT first, then a preference pass, and that all these methods are β- and length-term–sensitive.
198
+
199
+ ### E. Compression / quantization & reasoning distillation
200
+
201
+ **Distilling the Knowledge in a Neural Network** — Hinton, Vinyals, Dean, 2015. https://arxiv.org/abs/1503.02531. *Peer-reviewed / researcher.* Origin of soft-target knowledge distillation.
202
+
203
+ **DeepSeek-R1 (distilled models)** — DeepSeek-AI, 2025. https://arxiv.org/abs/2501.12948. *Technical / researcher-practitioner.*
204
+ - Fine-tuned six dense students (1.5B–70B, Qwen2.5 & Llama) on 800K reasoning traces from R1; SFT only, no RL stage.
205
+ - Per the R1 technical report, DeepSeek-R1-Distill-Qwen-32B scores 72.6% on AIME 2024, 94.3% on MATH-500, and 57.2% on LiveCodeBench — results that significantly outperform previous open-source models and are comparable to o1-mini.
206
+ - Key claim: distilling from a strong teacher beats running large-scale RL directly on a small model.
207
+ - **DOK 2 Summary:** Landmark evidence that a hard capability (long-CoT reasoning) transfers into small models purely via SFT on teacher traces — though persistent gaps remain for ≤7B students.
208
+
209
+ **s1: Simple Test-Time Scaling** — Muennighoff et al., Stanford, EMNLP 2025. https://arxiv.org/abs/2501.19393. *Peer-reviewed / researcher.*
210
+ - Per the paper: "After supervised finetuning the Qwen2.5-32B-Instruct language model on s1K and equipping it with budget forcing, our model s1-32B exceeds o1-preview on competition math questions by up to 27% (MATH and AIME24)"; budget forcing (appending "Wait") scaled AIME24 from 50% to 57%.
211
+ - s1K = 1,000 traces selected for difficulty, diversity, quality. Training on the full 59K vs the 1K cost 394 vs 7 H100-hours for marginal gains — data selection dominates.
212
+ - Explicitly invokes LIMA's Superficial Alignment Hypothesis.
213
+ - **DOK 2 Summary:** A second independent confirmation that ~1,000 well-chosen examples can activate a latent capability; the behavior is elicited, not taught from scratch.
214
+
215
+ **Quantization formats** — GPTQ, AWQ, bitsandbytes (NF4), GGUF/llama.cpp. *Docs / practitioner.*
216
+ - **DOK 2 Summary (collective):** Post-training quantization (GGUF for llama.cpp/CPU, AWQ/GPTQ for GPU) lets a tuned small model deploy on edge; 4-bit is the practical sweet spot.
217
+
218
+ ### F. Small-model landscape
219
+
220
+ **SmolLM3-3B** — Hugging Face, released July 8, 2025. https://huggingface.co/HuggingFaceTB/SmolLM3-3B. *Technical / practitioner.*
221
+ - 3B, trained on 11.2T tokens; dual reasoning (think/no_think); 64k context (128k via YaRN); fully open (data mixture, configs, 100+ intermediate checkpoints). Trained on 384 H100s for 24 days.
222
+ - Outperforms Llama-3.2-3B and Qwen2.5-3B; competitive with Qwen3-4B and Gemma3-4B. Instruct variant optimized for reasoning and tool use.
223
+ - **DOK 2 Summary:** Best current fully-open small base for tuning, with a documented recipe and a strong instruct variant.
224
+
225
+ *Collective note:* **Qwen3 (0.6/1.7/4B), Llama 3.2 (1B/3B), Gemma 3 small, Phi family, SmolLM2 (135M/360M/1.7B)** are the practical base pool. Instruct variants ship chat templates and instruction-following priors; base variants give a cleaner slate but need format teaching. Tokenizer/number-tokenization differences matter (Goat attributed arithmetic gains to LLaMA's consistent digit tokenization).
226
+
227
+ ### G. Evaluation & LLM-as-judge
228
+
229
+ **Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena** — Zheng et al. 2023. https://arxiv.org/abs/2306.05685. *Peer-reviewed / researcher.*
230
+ - Per the paper: "strong LLM judges like GPT-4 can match both controlled and crowdsourced human preferences well, achieving over 80% agreement, the same level of agreement between humans" (validated on ~3K controlled expert votes + ~3K crowdsourced votes).
231
+ - Documents position bias (most judges favor the first answer; only GPT-4 stays consistent in >60% of swapped cases), verbosity bias, and self-enhancement bias (judges favor their own outputs). Independent studies measure length bias around +17% for LLM judges vs ~+13% for humans.
232
+ - **DOK 2 Summary:** LLM-as-judge is scalable and roughly human-aligned but carries measurable biases — always randomize position, control length, and avoid judging with the same model family.
233
+
234
+ **Hamel Husain — Your AI Product Needs Evals / LLM-as-a-Judge / Evals FAQ** (hamel.dev). https://hamel.dev/blog/posts/evals/. *Technical blog / practitioner.*
235
+ - Error analysis first: read ~100 traces; stop when ~20 traces reveal no new failure mode ("theoretical saturation").
236
+ - Use a single domain-expert "benevolent dictator"; build a custom, friction-free data-viewing tool. Prefer binary pass/fail over 1–5 scales ("Critique Shadowing"); pass rate is a product decision, not necessarily 100%.
237
+ - Per the Evals FAQ: "In the projects we've worked on, we've spent 60-80% of our development time on error analysis and evaluation"; he notes a ~70% pass rate can indicate an eval that is actually stress-testing the app.
238
+ - **DOK 2 Summary:** Build evals before/alongside the model; the eval harness IS the improvement flywheel and the debugging infrastructure.
239
+
240
+ *Collective note:* **G-Eval** (Liu et al. 2023) uses CoT + form-filling for judge scoring; **JSONSchemaBench** (https://arxiv.org/abs/2501.10868) shows models still struggle with real-world schemas. Watch for contamination, overfitting to the judge, and benchmark gaming.
241
+
242
+ ### H. Reliability & structured output
243
+
244
+ **Constrained decoding — Outlines, XGrammar, Guidance, llama.cpp GBNF; JSONSchemaBench** — Willard & Louf 2023; Dong et al. 2024 (XGrammar); https://arxiv.org/abs/2501.10868. *Docs + peer-reviewed / practitioner.*
245
+ - FSM/token-masking sets invalid-token logits to −∞, guaranteeing schema-valid output; Outlines compiles JSON schemas for ~O(1) valid-token lookup per step.
246
+ - Overhead ranges from minimal to ~2–5× for naive implementations; XGrammar is current SOTA for low overhead. Output quality can suffer when constraints force the model off its preferred tokens, but constrained decoding sometimes IMPROVES task performance.
247
+ - **DOK 2 Summary:** For structured-output behaviors, constrained decoding guarantees form while fine-tuning improves the model's tendency to produce correct content within that form — complementary, not substitutes.
248
+
249
+ ### I. Failure modes & limits (dedicated strand)
250
+
251
+ **GSM-Symbolic** — Mirzadeh et al., Apple, 2024 (ICLR 2025). https://arxiv.org/abs/2410.05229. *Peer-reviewed / researcher.*
252
+ - All tested models drop accuracy when only numbers change in a template; performance degrades further as clause count rises.
253
+ - Per the paper: "adding seemingly relevant but ultimately irrelevant information to problems, we demonstrate substantial performance drops (up to 65%) across all state-of-the-art models" — Phi-3-mini experienced over a 65% drop on the GSM-NoOp variant.
254
+ - Interpretation: reasoning resembles sophisticated pattern matching, not robust logic. **Contested:** Ivanova and others critique the statistical rigor; treat as evidence of fragility, not proof of "no reasoning."
255
+
256
+ **Faith and Fate: Limits of Transformers on Compositionality** — Dziri et al., NeurIPS 2023. https://arxiv.org/abs/2305.18654. *Peer-reviewed / researcher.*
257
+ - GPT-4 zero-shot: 59% on 3×3-digit multiplication, dropping to 4% on 4×4; a few-shot scratchpad lifts 3×3 to 92% but stays near 0 on the hardest cases.
258
+ - Transformers reduce compositional reasoning to "linearized subgraph matching"; under stated assumptions the probability of error converges toward 1 as problem size grows. Exhaustive fine-tuning (~1.8M multiplication pairs) generalized in-distribution but "utterly failed" out-of-distribution.
259
+
260
+ **Why Language Models Hallucinate** — Kalai, Nachum, Vempala, Zhang (OpenAI/Georgia Tech), 2025. https://arxiv.org/abs/2509.04664. *Researcher.*
261
+ - Frames hallucination as statistical error: generative error rate is lower-bounded by the "Is-It-Valid" binary misclassification rate — even with clean data, generating valid outputs is statistically harder than classifying validity.
262
+ - Post-training persists/worsens hallucination because benchmarks reward confident guessing over "I don't know"; the proposed fix is reworking eval metrics to reward calibrated uncertainty/abstention.
263
+
264
+ **Lost in the Middle: How Language Models Use Long Contexts** — Liu et al., TACL 2024. https://arxiv.org/abs/2307.03172. *Peer-reviewed / researcher.*
265
+ - U-shaped accuracy: models use info at the start/end of context well and degrade sharply in the middle, even in explicitly long-context models.
266
+
267
+ **Are Emergent Abilities of Large Language Models a Mirage?** — Schaeffer, Miranda, Koyejo, Stanford, NeurIPS 2023. https://arxiv.org/abs/2304.15004. *Peer-reviewed / researcher.*
268
+ - Core claim: apparent "emergence" appears "due to the researcher's choice of metric rather than due to fundamental changes in model behavior with scale." Nonlinear/discontinuous metrics (exact match) manufacture sharp jumps; linear/continuous metrics (token edit distance, Brier score) show smooth, predictable scaling. They even *induce* apparent emergence in vision models by choosing metrics.
269
+
270
+ **The Reversal Curse: LLMs trained on "A is B" fail to learn "B is A"** — Berglund et al., ICLR 2024. https://arxiv.org/abs/2309.12288. *Peer-reviewed / researcher.*
271
+ - Models trained on "A is B" do not generalize to "B is A": "near 0% accuracy on reversals" across GPT-3-350M, Llama-7B, and GPT-3-175B; the log-probability of the correct reversed name is no higher than a random name. Data augmentation with paraphrases did not fix it. (In-context, models CAN reverse; the curse is about stored/trained knowledge.)
272
+
273
+ **An Empirical Study of Catastrophic Forgetting in LLMs During Continual Fine-tuning** — Luo et al., 2023 (EMNLP 2025 proceedings). https://arxiv.org/abs/2308.08747. *Peer-reviewed / researcher.*
274
+ - Catastrophic forgetting observed across 1B–7B models, and severity INTENSIFIES with scale during continual instruction tuning. Decoder-only BLOOMZ forgets less than encoder-decoder mT0. General instruction tuning (Alpaca vs LLaMA) mitigates forgetting; continual tuning can also reduce some biases (e.g., gender bias).
275
+
276
+ **Alignment tax** — Ouyang et al. (InstructGPT), NeurIPS 2022. https://arxiv.org/abs/2203.02155; confirmed by Lin et al., EMNLP 2024, https://arxiv.org/abs/2309.06256.
277
+ - InstructGPT coined the term: RLHF "comes at the cost of lower performance on certain tasks," with regressions on SQuAD, DROP, HellaSwag, and WMT'15 Fr→En. Mitigation: PPO-ptx (mixing pretraining-distribution gradients into PPO) largely closes the gap without hurting labeler scores. Lin et al. independently confirm the tax on OpenLLaMA-3B/Mistral-7B and note DPO induces less tax than other RLHF algorithms.
278
+
279
+ **DOK 2 Summary (collective for I):** Small (and large) LMs fail systematically at compositional/multi-step reasoning, robustness to irrelevant info, symmetric fact retrieval, middle-of-context use, and calibrated uncertainty. Fine-tuning a narrow behavior can cause narrow forgetting and an alignment tax. Design evals to probe these exact fragilities.
280
+
281
+ ### J. Practitioner playbooks
282
+ - **Hamel Husain** (evals), **Sebastian Raschka** (LoRA experiments; "Build a Reasoning Model From Scratch"), **Unsloth / Axolotl** docs (fast QLoRA recipes; HF alignment-handbook), **Argilla / distilabel** (data + preference pipelines).
283
+ - **DOK 2 Summary:** The consensus workflow — define behavior + eval → generate/curate teacher data → QLoRA SFT → base-vs-tuned judge eval → optional DPO → quantize/deploy.
284
+
285
+ ### K. Forums / community
286
+ - r/LocalLLaMA, Hugging Face forums, Unsloth/Axolotl Discords, Hacker News threads on QLoRA/DeepSeek-R1. Useful for hardware-specific gotchas, chat-template bugs, and reproductions; treat single-poster claims as anecdotes to verify.
287
+
288
+ ## Recommendations
289
+
290
+ **Stage 0 — Define and instrument (before any training).** Write the ONE behavior as a falsifiable spec. Build the eval harness first: a held-out behavioral set plus an adversarial set that perturbs numbers, injects irrelevant clauses (GSM-Symbolic style), reverses relations (reversal curse), and shifts key info to the middle (lost-in-the-middle). Decide the judge protocol now — binary pass/fail, randomized answer order, a different model family as judge. *Benchmark that gates progress:* baseline the untuned model on this harness; the whole project is measured as the base-vs-tuned delta.
291
+
292
+ **Stage 1 — Data.** Generate ~1,000–2,000 examples from a frontier teacher, distilling the *process* (reasoning traces/explanations), then filter for quality and diversity (AlpaGasus-style). Use distilabel/Argilla for pipelines. *Threshold to add more data:* only if the base-vs-tuned delta plateaus below target with clean data — that signals a missing latent capability, not a data-quantity problem.
293
+
294
+ **Stage 2 — Train.** QLoRA (NF4, double quant) on a fully-open small base (SmolLM3-3B or Qwen3-1.7B/4B). Defaults: target all modules, α=2r, r≈16 (raise to 64–256 only if the target metric is capacity-bound), ≥3–4 epochs, sweep LR (LoRA is LR-sensitic). Mask the prompt (loss on completions only); verify the exact chat template. Early-stop on the behavioral eval, not loss. *Escalate to full FT or high-rank LoRA if:* the behavior is a genuinely new hard skill and low-rank LoRA underperforms full FT on the target metric.
295
+
296
+ **Stage 3 — Preference tuning (stretch).** Only after SFT clears most of the target. Build chosen/rejected pairs (or unpaired good/bad for KTO) and run DPO (or ORPO to fold it into one stage). Re-run the full eval to check for an alignment tax — regression on base capabilities. *Threshold to keep it:* preference tuning must improve the target behavior without dropping retained-capability checks.
297
+
298
+ **Stage 4 — Reliability & deploy.** Add constrained decoding (Outlines/XGrammar) for any structured output; measure the quality cost vs the validity gain. Quantize to GGUF (llama.cpp) or AWQ/GPTQ for deployment. *Kill-switch:* if constrained decoding measurably degrades content quality and the base is already schema-reliable, drop it.
299
+
300
+ ## Caveats
301
+ - **Contested reasoning claims.** GSM-Symbolic's "not genuine reasoning" framing is disputed (Ivanova and others critique statistical rigor); treat it as strong evidence of *fragility*, not proof of no reasoning.
302
+ - **Vendor vs independent.** DeepSeek-R1, SmolLM3, and QLoRA headline numbers are self-reported by the authors/vendors; the s1, LIMA, MT-Bench, GSM-Symbolic, Faith-and-Fate, and LoRA-Learns-Less findings are peer-reviewed but some rely on GPT-4-as-judge, which carries the biases documented in Section G.
303
+ - **Alignment-tax magnitude is method-dependent.** InstructGPT quantifies regressions qualitatively; the headline drop is largely mitigated by PPO-ptx and DPO, so the "tax" is real but not fixed — measure it on your own benchmarks.
304
+ - **Scale gap in the evidence.** Several marquee results (LIMA-65B, s1-32B, Guanaco-65B, Faith-and-Fate on GPT-4) are on models much larger than the 0.6B–4B target range; the *mechanisms* (superficial alignment, forgetting, compositional limits) transfer, but exact numbers will not. Small models show these failure modes at least as strongly.
305
+ - **Fast-moving field.** Model releases (SmolLM3, Qwen3, DeepSeek-R1) and preference methods (SimPO/ORPO/KTO) are recent; recheck for newer bases and recipes before committing.
306
+ - **Two searches were cut short by budget** (a direct pull of the emergent-abilities and reversal-curse primary PDFs); those facts were verified via a dedicated sub-search against the primary arXiv abstracts and are cited accordingly.
notes/research-small-vs-large-math.md ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Can a Fine-Tuned Small LM Beat a Large LM at Mathematics?
2
+
3
+ **Research report — 2026-07-09.** Scope: can a fine-tuned **small** model (target 0.6B–4B,
4
+ QLoRA/SFT on an open base) reach **benchmark parity with or beat** a large/frontier model at a
5
+ math skill, across four behaviors — **proof generation, grading/evaluation, teaching/tutoring,
6
+ autoformalization** — comparing **informal (NL)** vs **formal (verifiable)** styles. Purpose: pick
7
+ (or reject) a behavior for the one-week QLoRA build described in
8
+ `Train Your Own Small Learning Model.md`. This report *extends* `brainlift.md` (which covers the
9
+ general SFT/QLoRA/eval stack) with math-specific evidence.
10
+
11
+ ---
12
+
13
+ ## Executive answer
14
+
15
+ **"Beat a frontier model on a public math benchmark" is achievable — but only in the
16
+ narrow, specialist-beats-generalist sense, only where the task is machine-checkable, and
17
+ mostly at 7B–8B rather than sub-4B.** The pattern is consistent across all four behaviors:
18
+
19
+ | Behavior | Does a small tuned model beat a frontier model? | Best real evidence | Genuine ≤4B? |
20
+ |---|---|---|---|
21
+ | **Formal proof gen** (Lean) | **Yes, decisively** vs generalists | Goedel-Prover-V2-**8B** > DeepSeek-Prover-V2-**671B** on miniF2F pass@32 (84.6 vs ~82.4) | Kimina-Distill-**1.7B** = 72.95% miniF2F (distilled) |
22
+ | **Grading / verifying** | **Yes**, on first-error localization | Qwen2.5-Math-PRM-**7B** (73.5 F1) > GPT-4o (61.9) on ProcessBench | **GenPRM-1.5B**+Maj@8 (63.4) > GPT-4o ✅ |
23
+ | **Teaching / tutoring** | **Yes**, on "withhold the answer" | DPO Llama-3.1-**8B** > GPT-4o in human pedagogy eval | MathDial Flan-T5-**780M** reveals 4% vs ChatGPT 32% |
24
+ | **Autoformalization** (statement) | **Yes** vs frontier | StepFun-Formalizer-**7B** > R1-671B, o3-pro, Claude-4, Gemini-2.5 on BEq@1 | *(none shown sub-4B; all ~7B)* |
25
+ | **Informal PROSE proofs** (real analysis) | **No — small models don't even compete** | No sub-4B prose-proof generator exists in the literature | ✗ |
26
+
27
+ **The single most important distinction:** every small-model "win" above is on a task with a
28
+ **cheap, objective correctness signal** — a Lean compiler, an integer final answer, a step-label,
29
+ or a "did it reveal the answer" flag. The one behavior with **no** such signal — writing
30
+ rigorous real-analysis **prose proofs** — is exactly the one where small models are absent and
31
+ even frontier models are weak. This is the generator–verifier asymmetry in action, and it should
32
+ drive the project choice.
33
+
34
+ ---
35
+
36
+ ## Cross-cutting frame
37
+
38
+ ### 1. The generator–verifier asymmetry is confirmed and is the key lever
39
+ Verifying/grading a solution is generically easier than producing one, so a small specialist can
40
+ win at *checking* while losing at *generating*.
41
+ - **Measured gap:** on GPQA-Diamond, oracle Pass@100 = 82.8% but majority-vote selection = 45.5%
42
+ — the right answer is usually generated but not *selected*, so a better (even small) verifier
43
+ captures huge headroom. (Weaver, Stanford Hazy Research, arXiv:2506.18203; GV-gap formalized in
44
+ arXiv:2509.17995.) *Caveat: asymmetry is not universal — some tasks are "easy to solve, hard to
45
+ verify."*
46
+ - **Process supervision beats outcome supervision** at all data scales, and catches
47
+ right-answer/wrong-reasoning cases (OpenAI "Let's Verify Step by Step", arXiv:2305.20050;
48
+ released PRM800K = 800k human step labels).
49
+ - **Implication for the four behaviors:** grading (2) and formal proving/autoformalization (4)
50
+ sit on the easy side of the asymmetry; informal prose-proof generation (1) sits on the hard
51
+ side. Tutoring (3) is a third category — a *policy* problem (withhold the answer), not a
52
+ capability problem.
53
+
54
+ ### 2. Scale calibration — most "small beats frontier" headlines are 7B–32B, NOT sub-4B
55
+ Be explicit about the size a result was achieved at. The genuinely **sub-4B** wins are narrow:
56
+ - **GenPRM-1.5B** (+Maj@8) beats GPT-4o on ProcessBench error localization — *the strongest
57
+ clean sub-4B "beats frontier" result found* (arXiv:2504.00891, independent lab). Note the win
58
+ needs test-time voting over 8 samples; greedy Pass@1 (57.3) trails GPT-4o.
59
+ - **Kimina-Prover-Distill-1.7B** = 72.95% miniF2F pass@32 — a real sub-2B formal prover, but
60
+ **distilled from a 72B teacher** (capability inherited, not independently trained) and reliant
61
+ on heavy proof search.
62
+ - **DeepSeek-R1-Distill-Qwen-1.5B** = 83.9% MATH-500 / 28.9% AIME'24 — beats non-reasoning GPT-4o
63
+ but this is **final-answer** accuracy (see §Skill 1), and it loses to o1-mini/o1-preview.
64
+ - Everything else headline-worthy (Goedel-V2-8B, StepFun-7B, DPO-tutor-8B, Qwen-PRM-7B) is
65
+ **7B–8B+**. At 0.6–4B expect a meaningful step down from these numbers.
66
+
67
+ ### 3. Contamination / self-report caveats (apply to every number below)
68
+ - **GSM8K/MATH are contaminated.** GSM1k (1,000 fresh analog problems) shows drops up to 13%,
69
+ correlated with memorization; smaller/benchmark-tuned models drop most (Scale AI,
70
+ arXiv:2405.00332). GSM-Symbolic shows all models degrade on templated variants, with Phi-3/3.5
71
+ among the larger droppers (Apple, arXiv:2410.05229) — though a reanalysis argues the
72
+ contamination-vs-distribution-shift evidence is weaker than headlined.
73
+ - **Many prover/PRM headline numbers are vendor self-reported**, sometimes on a
74
+ **same-vendor benchmark** (ProcessBench and Qwen2.5-Math-PRM are both Qwen). Independent
75
+ cross-checks used here: PutnamBench public leaderboard (provers), GenPRM (independent lab,
76
+ corroborates ProcessBench), PRMBench (independent, *tempers* it).
77
+ - **Compute regime dominates prover comparisons.** pass@32 vs pass@8192 vs step-level
78
+ multi-agent tree search vs self-correction loops are different compute classes — read every
79
+ prover % with its sampling budget.
80
+ - A few 2026-dated sources the agents surfaced cite unreleased models (GPT-5.5, Gemini-3.1);
81
+ their specific numbers are treated as **low-confidence** and are not load-bearing here.
82
+
83
+ ---
84
+
85
+ ## Skill 1 — Proof generation
86
+
87
+ ### Formal (Lean/Isabelle): small specialists beat frontier generalists — real and robust
88
+ - **Goedel-Prover-V2-8B** = 84.6% miniF2F pass@32, explicitly **outperforming DeepSeek-Prover-V2-671B
89
+ (~82.4% matched budget) at ~80–100× fewer params**; the **32B** version solves 86/PutnamBench
90
+ (pass@184) vs the 671B's 47, and beats Kimina-72B (arXiv:2508.03613, self-reported;
91
+ PutnamBench leaderboard corroborates).
92
+ - **Kimina-Prover-Distill-1.7B** = 72.95% miniF2F pass@32 (RL variant 76.63%) — smallest
93
+ competitive prover found; exceeds every pre-2025 7B prover and vastly exceeds **GPT-4-direct
94
+ (~20–31%)**. Distilled from Kimina-72B (arXiv:2504.11354; HF AI-MO).
95
+ - **BFS-Prover-V2-32B** = 95.08% miniF2F (step-level multi-agent tree search) — SOTA-class but
96
+ very high inference compute (arXiv:2509.06493).
97
+ - **Why small wins here:** the Lean compiler is a perfect verifier, so a specialist can be trained
98
+ with expert iteration and search-verify loops; frontier generalists are "under-trained on Lean 4"
99
+ (TheoremLlama, arXiv:2407.03203). *But note: these are heavily-engineered systems (distillation
100
+ from huge teachers + tree search + self-correction), not a plain one-week QLoRA.*
101
+
102
+ ### Informal (real-analysis prose proofs): small models do NOT compete
103
+ - **No sub-4B (indeed no small) prose-proof generator exists in the literature.** The Open Proof
104
+ Corpus (arXiv:2506.21621) evaluates only frontier/≥235B generators (o3, Gemini-2.5-Pro,
105
+ Qwen3-235B, R1); small models appear at most as **8B graders**.
106
+ - **Right answer ≠ right proof:** o3's score dropped ~30% when a *valid proof* was required (only
107
+ 59.5% of its correct answers had a valid proof). This is the crux — the MATH-500/AIME
108
+ small-model "wins" below are all on the answer-accuracy axis, which overstates proof ability.
109
+ - **Even frontier models are weak at analysis proofs.** On analysis-style proof benchmarks the
110
+ best models score in the low tens of percent (and open models ~0% on the hardest tiers).
111
+ FrontierMath: even frontier models are low (o3 ~25% self-reported vs Epoch-independent
112
+ o3-mini 11%; research-tier ≈ 0%), amid a real contamination controversy (OpenAI funded it and
113
+ saw nearly all problems). Prose-proof grading itself is a *recent, still-open* research area
114
+ needing large LLM judges + reference solutions (ProofBench/ProofGrader,
115
+ proofgrader.github.io / arXiv:2510.13888).
116
+
117
+ ### Final-answer competition math (the "wins" that aren't proofs)
118
+ - **DeepSeek-R1-Distill-Qwen-1.5B/7B** = 83.9%/92.8% MATH-500, 28.9%/55.5% AIME'24 — 1.5B beats
119
+ non-reasoning GPT-4o (74.6/9.3) but **not** o1-mini (90.0/63.6) (arXiv:2501.12948, self-reported).
120
+ - **rStar-Math**: Qwen2.5-Math-7B 58.8→90.0% MATH, 0→53.3% AIME via MCTS + a 7B process reward
121
+ model; 1.5B → 88.6% MATH; "surpasses o1-preview" **on answer accuracy** (arXiv:2501.04519).
122
+ - **Phi-4-mini-reasoning (3.8B)** = 94.6% MATH-500 / 57.5% AIME'24, distilled from R1 traces
123
+ (arXiv:2504.21233). All of these are **auto-verified integer/expression answers**, not graded
124
+ proofs.
125
+
126
+ **Verdict (Skill 1):** formal proving is a genuine small-beats-large domain but demands
127
+ serious infra; informal real-analysis proof *generation* is the **worst** possible one-week
128
+ target — no small-model precedent, frontier models themselves are weak, and grading is unsolved.
129
+
130
+ ---
131
+
132
+ ## Skill 2 — Grading / evaluating proofs (verification)
133
+
134
+ **This is where the cleanest sub-4B "beats frontier" evidence lives.**
135
+ - **Qwen2.5-Math-PRM-7B (73.5 avg F1) beats GPT-4o-0806 (61.9)** at first-error localization on
136
+ **ProcessBench** (GSM8K/MATH/Olympiad/Omni-MATH) (arXiv:2501.07301). The 72B PRM = 78.3.
137
+ - **GenPRM-1.5B (+Maj@8) = 63.4 F1 > GPT-4o (61.9)** — genuine ≤4B win, from an **independent**
138
+ lab, trained on just 23K MATH examples; **GenPRM-7B (80.5) beats the 10× larger 72B PRM**
139
+ (arXiv:2504.00891). *Caveat: the sub-GPT-4o win needs Maj@8 test-time compute + code execution;
140
+ greedy Pass@1 (57.3) trails GPT-4o.*
141
+ - **Load-bearing caveats:**
142
+ 1. All specialists still **trail the reasoning frontier model o1-mini (87.9)** on ProcessBench.
143
+ 2. On the **harder, adversarial PRMBench**, the same 7B PRM (65.5) drops *below* GPT-4o (66.8)
144
+ and o1-mini (68.8); all models are far under human (83.8) (arXiv:2501.03124, independent).
145
+ 3. The advantage comes from **supervision quality** (human/consensus step labels), not size —
146
+ untuned open PRMs lose badly (Math-Shepherd-7B 31.5, Skywork-7B 42.1 vs GPT-4o 61.9).
147
+ 4. Headline ProcessBench is **Qwen model on Qwen benchmark** (GenPRM independently corroborates).
148
+ - **LLM-as-judge for math grading** reaches ~86–93% agreement with humans (κ≈0.73–0.81); **binary
149
+ pass/fail beats partial-credit scales** by ~20 F1 points; judges are stricter than humans
150
+ (~10% false-negative bias). Consistent with `brainlift.md`'s judge findings.
151
+
152
+ **Verdict (Skill 2):** the generator–verifier asymmetry is real and *documented at ≤4B* on a
153
+ ready-made public benchmark. Best-supported "small beats frontier on a benchmark" story of the
154
+ four — with the honest caveat that "frontier" here means GPT-4o, not o1-mini.
155
+
156
+ ---
157
+
158
+ ## Skill 3 — Teaching / tutoring
159
+
160
+ **Best fit for the project's actual framing ("reliably do ONE narrow behavior"), because
161
+ pedagogy failure is a *policy* problem, not a capability problem.**
162
+ - **Frontier models are bad tutors by default — they reveal the answer.** MRBench: GPT-4 fails
163
+ the "doesn't reveal the answer" dimension ~47% of the time — **worst of all tested LLMs**;
164
+ prompted Mistral-7B (86.5) and Llama-3.1-8B (74.0) already withhold better (NAACL 2025,
165
+ arXiv:2412.09416).
166
+ - **A fine-tuned small model beats GPT-4o on human pedagogy eval.** DPO-tuned **Llama-3.1-8B**
167
+ beats GPT-4o on student-correctness (0.65 vs 0.49) and wins the **human** rubric eval
168
+ (8.55 vs 8.07, p<0.05) (UMass, arXiv:2503.06424). *Caveat: simulated students, and possible
169
+ GPT-4o self-bias as the automated judge.*
170
+ - **The narrow-behavior "reliability" case, quantified at 780M:** MathDial's fine-tuned
171
+ **Flan-T5-780M** reveals the answer **4%** of the time vs prompted **ChatGPT's 32%**, at
172
+ comparable early solve-success (EMNLP-F 2023, arXiv:2305.14536).
173
+ - **BEA-2025 shared task:** small fine-tuned/open models **won the pedagogy tracks** (Guidance =
174
+ Mathstral-7B, Actionability = GLM-4-9B; a 0.5–1.5B entry was competitive); frontier models won
175
+ only "Mistake Location" (arXiv:2507.10579).
176
+ - **Big caveats:** (1) **no objective benchmark** — pedagogy is scored by human rubric, learned
177
+ reward model, or LLM-judge, and generic LLM-judges (Prometheus2, Llama-8B) were found
178
+ *unreliable* for pedagogy (MRBench). (2) Fine-tuning is **not automatically sufficient**:
179
+ MathTutorBench shows two poorly-specialized 7B tutors *lost* to GPT-4o on scaffolding, and
180
+ documents a solving-vs-teaching **trade-off** (arXiv:2502.18940). (3) Essentially **no
181
+ real-student learning-gain evidence** (Khanmigo studies null/pending).
182
+
183
+ **Verdict (Skill 3):** strongest match to "reliability of a narrow behavior" and to the project's
184
+ own litmus test (a prompted frontier model *can't* reliably withhold the answer). Weakest match to
185
+ "beat on a **public benchmark**" because the objective benchmark doesn't exist.
186
+
187
+ ---
188
+
189
+ ## Skill 4 — Autoformalization (Lean)
190
+
191
+ **The most "beatable" *capability* target, thanks to the free type-check signal — but faithfulness
192
+ is the catch.**
193
+ - **StepFun-Formalizer-7B beats every frontier model tested** — DeepSeek-R1-671B, o3-pro,
194
+ Claude-4-thinking, Gemini-2.5-thinking — on **BEq@1** (compiles AND is bidirectionally
195
+ equivalent to a human ground-truth Lean statement): 38.3 vs 18.4/22.6/20.8/17.8 on
196
+ FormalMATH-Lite (arXiv:2508.04440). 7B ≈ 32B (data-limited).
197
+ - **Herald-7B** = 93.2% miniF2F-test statement formalization (Pass@128, compile + NLI
198
+ back-translation), crushing InternLM2-Math-7B (74.0) and TheoremLlama (50.1) (ICLR 2025,
199
+ arXiv:2410.10878). *Caveat: Pass@128 is a loose "any-of-128" metric.*
200
+ - **The free Lean signal powers self-improvement loops** with little/no labeled data: FormaRL
201
+ (GRPO with compiler + consistency reward, arXiv:2508.18914), DeepSeek-Prover expert iteration,
202
+ Lean Workbook active learning (57K problems @ 93.5% audited).
203
+ - **The catch — compile ≠ faithful.** Every serious pipeline bolts an LLM/NLI/critic judge on top
204
+ of the compiler because a Lean statement can type-check yet mean the wrong thing. Faithfulness
205
+ eval is a **documented open problem**: 31.8% of published Lean-4 ProofNet ports were themselves
206
+ *wrong*, motivating BEq+/ProofNetVerif and critic models like CriticLean (arXiv:2406.07222,
207
+ arXiv:2507.06181). Statement autoformalization is much easier than **full-proof**
208
+ autoformalization.
209
+ - **Scale note:** the winners (StepFun, Herald, Kimina) are all **7B**; no sub-4B autoformalizer
210
+ win was found.
211
+
212
+ **Verdict (Skill 4):** strong small-beats-frontier evidence and a built-in verification signal
213
+ ideal for an expert-iteration loop — but the real deliverable becomes the **faithfulness eval**,
214
+ which is unsolved, and the demonstrated wins are at 7B, not sub-4B.
215
+
216
+ ---
217
+
218
+ ## Recommendation for the one-week QLoRA build (0.6–4B)
219
+
220
+ **Is "beating a frontier model on a public benchmark" realistic at 0.6–4B in one week?**
221
+ Partially. It is realistic for a *machine-checkable* task against a *non-reasoning* frontier model
222
+ (GPT-4o), on the *easy side* of the generator–verifier asymmetry. It is **not** realistic to beat a
223
+ reasoning frontier model (o1-mini/o3) or to win at prose-proof *generation*.
224
+
225
+ **Ranked picks:**
226
+
227
+ 1. **PRIMARY — Grading / verification (a fine-tuned first-error-localizer / process verifier).**
228
+ - *Only one of the four with a clean sub-4B "beats GPT-4o on a public benchmark" precedent*
229
+ (GenPRM-1.5B on ProcessBench).
230
+ - Fits QLoRA-in-a-week: distill step-level correct/incorrect labels from a frontier teacher
231
+ (PRM800K-style), train a small verifier, evaluate on the ready-made **ProcessBench**
232
+ (objective F1 — no judge-bias problem).
233
+ - Sits on the winning side of the asymmetry; the base model likely *lacks* reliable
234
+ error-localization (passes the project's "a prompt can't already do it" gate).
235
+ - *Honest framing to adopt:* target "beats GPT-4o-class grading," not o1-mini; report the
236
+ PRMBench regression as a robustness caveat.
237
+
238
+ 2. **CO-PRIMARY — Tutoring "withhold-the-answer / Socratic hint" behavior.**
239
+ - Best fit to the project's *actual* thesis (reliability of a narrow behavior), with the
240
+ cleanest "prompted frontier model fails, small tune succeeds" evidence (MRBench + DPO-tutor).
241
+ - Cheap data (MathDial/Bridge exist; distill a teacher for Socratic traces) and a cheap
242
+ **binary eval** ("did it reveal the answer?").
243
+ - *Trade-off vs pick 1:* weaker on "public benchmark" (no objective leaderboard; pedagogy
244
+ judged by rubric), so it's the better pick if you weight the project's reliability framing
245
+ over a headline benchmark number.
246
+
247
+ 3. **STRETCH — Statement autoformalization (informal math → Lean statement).**
248
+ - Free type-check signal enables a self-improvement loop; strong 7B precedent. But winners are
249
+ 7B not sub-4B, and the real work becomes the **faithfulness eval** (an open problem) — likely
250
+ too much for one week at ≤4B unless scoped tightly (one narrow domain, human-audited sample).
251
+
252
+ 4. **AVOID — Informal real-analysis prose-proof generation.**
253
+ - No small-model precedent, frontier models themselves are weak, and grading is unsolved. This
254
+ is the hard side of the asymmetry and violates the project's spirit (you'd be chasing a
255
+ capability even frontier models lack). Formal proof *generation* is impressive but needs
256
+ distillation-from-72B + tree-search infra that doesn't fit a one-week QLoRA.
257
+
258
+ **One-line answer:** *A fine-tuned 0.6–4B model can credibly beat a (non-reasoning) frontier model
259
+ at math tasks that have a cheap objective checker — grade/verify steps, withhold answers,
260
+ autoformalize statements — but not at writing rigorous real-analysis proofs. Pick the verifier
261
+ (for a benchmark win) or the Socratic tutor (for the project's reliability thesis); avoid prose
262
+ proof generation.*
263
+
264
+ ---
265
+
266
+ ## Caveats summary
267
+ - Sub-4B evidence is thin; most wins are 7B–8B. Budget for a step down at 0.6–4B.
268
+ - "Beats frontier" almost always means **beats GPT-4o**, not o1-mini/o3.
269
+ - Many prover/PRM numbers are vendor self-reported, sometimes same-vendor benchmark; compute
270
+ budget (pass@k, test-time voting, search) is often doing the heavy lifting.
271
+ - GSM8K/MATH contamination is real; prefer contamination-controlled or held-out evals.
272
+ - For grading/formal/autoformalization the checker is objective; for tutoring it is not — its
273
+ "benchmark" is a rubric/judge, so an adversarial binary behavioral eval (per `brainlift.md`)
274
+ matters most there.
275
+
276
+ ## Key sources
277
+ Formal proving: Goedel-Prover-V2 (arXiv:2508.03613), Kimina-Prover (2504.11354), DeepSeek-Prover-V2
278
+ (2504.21801), BFS-Prover-V2 (2509.06493), PutnamBench (2407.11214 + trishullab.github.io/PutnamBench),
279
+ miniF2F (2109.00110), miniF2F-Revisited (2511.03108). Verification: ProcessBench (2412.06559),
280
+ "Lessons of Developing PRMs" (2501.07301), GenPRM (2504.00891), PRMBench (2501.03124), Math-Shepherd
281
+ (2312.08935), Let's Verify Step by Step (2305.20050), Weaver (2506.18203). Tutoring: MathDial
282
+ (2305.14536), DPO tutor (2503.06424), MRBench (2412.09416), MathTutorBench (2502.18940), BEA-2025
283
+ (2507.10579), Bridge (NAACL 2024). Autoformalization: StepFun-Formalizer (2508.04440), Herald
284
+ (2410.10878), Kimina-Autoformalizer (2504.11354), FormaRL (2508.18914), ProofNet (2302.12433),
285
+ type-check/BEq+ (2406.07222), CriticLean (2507.06181), MMA (2311.03755). Proof-eval / ceilings:
286
+ Open Proof Corpus (2506.21621), ProofGrader (2510.13888), FrontierMath (2411.04872). Contamination:
287
+ GSM1k (2405.00332), GSM-Symbolic (2410.05229).
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mathcompose"
7
+ version = "0.1.0"
8
+ description = "Two <=4B math models (generative verifier + solution generator) that demonstrate the generator-verifier gap."
9
+ requires-python = ">=3.10"
10
+ dynamic = ["dependencies"]
11
+
12
+ [tool.setuptools.packages.find]
13
+ where = ["src"]
14
+
15
+ [tool.setuptools.dynamic]
16
+ dependencies = { file = ["requirements.txt"] }
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
20
+ addopts = "-q"
21
+ markers = [
22
+ "network: test hits the network (HF Hub); skip with -m 'not network'",
23
+ "slow: slow test (loads a model)",
24
+ ]
requirements-gpu.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Extra deps for the GPU training/inference path (Colab / Modal / RunPod).
2
+ # Install on top of requirements.txt on a CUDA box:
3
+ # pip install -r requirements.txt -r requirements-gpu.txt
4
+ #
5
+ # NOTE: install a CUDA build of torch first (Colab ships one). The CPU box uses
6
+ # torch==*+cpu, which cannot run 4-bit QLoRA.
7
+ bitsandbytes>=0.43 # 4-bit NF4 kernels for QLoRA (requires CUDA)
8
+ # Optional speedup (UNCONFIRMED against transformers 5.13 — pin transformers down if you use it):
9
+ # pip install unsloth
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CPU / harness dependencies (already present in the dev box).
2
+ # Pinned loosely to the versions this project was built and verified against.
3
+ transformers>=5.13,<6
4
+ trl>=1.7,<2
5
+ peft>=0.19,<0.20
6
+ accelerate>=1.14
7
+ datasets>=5.0
8
+ huggingface_hub>=1.22
9
+ tokenizers>=0.22
10
+ safetensors>=0.8
11
+ sympy>=1.12
12
+ pyyaml>=6
13
+ numpy>=2.0
14
+ pandas>=2.2
15
+ tqdm>=4.66
16
+ # Teacher clients are optional and imported lazily; install the one you use:
17
+ # pip install anthropic # for the Anthropic teacher
18
+ # pip install openai # for the OpenAI teacher
19
+ # Neither is required for CPU smoke tests (DummyTeacher has no external deps).
results/teacher_bakeoff.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Teacher bake-off — Model G (solution generator)
2
+
3
+ Data-driven teacher selection. 40 NuminaMath-CoT problems, one solution each,
4
+ graded by boxed-answer equivalence (`scripts/teacher_bakeoff.py`).
5
+
6
+ scripts/teacher_bakeoff.py --models gpt-5.6-sol claude-opus-4-8 claude-sonnet-5 --n 40
7
+
8
+ | model | keep-rate | median tok | p90 tok | % over 2048 | sec/ex |
9
+ |---|---|---|---|---|---|
10
+ | **claude-opus-4-8** | **57%** | 155 | 703 | 0% | 6.7 |
11
+ | gpt-5.6-sol | 32% | 103 | 324 | 0% | 10.4 |
12
+ | claude-sonnet-5 | 12% | 224 | 691 | 0% | 9.9 |
13
+
14
+ **Winner: `claude-opus-4-8`** — highest keep-rate (most usable data per call),
15
+ fastest, and traces fit the 4096-token student ceiling with room to spare.
16
+
17
+ `claude-fable-5` was requested but **excluded**: 403 on both routes (AWS Bedrock
18
+ Marketplace access not granted on this gateway account).
19
+
20
+ **Caveats:** n=40 is noisy; absolute keep-rates are depressed by NuminaMath
21
+ difficulty + strict grading + format adherence, not just model capability — the
22
+ *relative* ranking is the signal. For the real G build, rejection sampling with
23
+ `--max-attempts > 1` lifts the effective keep-rate.
24
+
25
+ ## Teacher assignments
26
+ - **Model G** (solution generator): `claude-opus-4-8`.
27
+ - **Model V** (critique writer; label already known from PRM800K → easier job):
28
+ `claude-haiku-4-5` (cheaper) is sufficient; use `claude-opus-4-8` if budget allows.
scripts/check_teacher.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify the teacher gateway is reachable and returns text.
2
+
3
+ python scripts/check_teacher.py # default: promptlens
4
+ python scripts/check_teacher.py --teacher openai
5
+
6
+ Loads .env, sends a 1-line prompt, prints the reply. Exits non-zero on failure.
7
+ """
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13
+
14
+ from mathcompose.teachers import get_teacher # noqa: E402
15
+
16
+
17
+ def main() -> int:
18
+ ap = argparse.ArgumentParser()
19
+ ap.add_argument("--teacher", default="promptlens")
20
+ ap.add_argument("--model", default=None)
21
+ ap.add_argument("--prompt", default="Reply with exactly: pong")
22
+ args = ap.parse_args()
23
+
24
+ try:
25
+ teacher = get_teacher(args.teacher, **({"model": args.model} if args.model else {}))
26
+ except Exception as e:
27
+ print(f"[FAIL] could not build teacher '{args.teacher}': {e}")
28
+ return 1
29
+
30
+ who = f"{args.teacher} (model={getattr(teacher, 'model', '?')}, base_url={getattr(teacher, 'base_url', None)})"
31
+ print(f"calling {who} ...")
32
+ try:
33
+ reply = teacher.complete(
34
+ [{"role": "user", "content": args.prompt}], temperature=0.0, max_tokens=32
35
+ )
36
+ except Exception as e:
37
+ print(f"[FAIL] teacher call errored: {type(e).__name__}: {e}")
38
+ return 1
39
+
40
+ print(f"[OK] reply: {reply!r}")
41
+ return 0
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
scripts/download_prm800k.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download PRM800K step-label files (OpenAI, MIT) into data/raw/prm800k/.
2
+
3
+ python scripts/download_prm800k.py # phase2_train (what we use)
4
+ python scripts/download_prm800k.py --all # all four phase files
5
+
6
+ The GitHub raw URLs serve the real JSONL (schema: question/label/steps/...),
7
+ which is exactly what mathcompose.datagen.prm800k_loader expects.
8
+ """
9
+ import argparse
10
+ import sys
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ BASE = "https://github.com/openai/prm800k/raw/main/prm800k/data"
15
+ FILES = ["phase2_train.jsonl", "phase1_train.jsonl", "phase2_test.jsonl", "phase1_test.jsonl"]
16
+
17
+
18
+ def download(name: str, out_dir: Path) -> None:
19
+ url = f"{BASE}/{name}"
20
+ dest = out_dir / name
21
+ dest.parent.mkdir(parents=True, exist_ok=True)
22
+ print(f"downloading {name} -> {dest}")
23
+
24
+ def _hook(block, bsize, total):
25
+ if total > 0:
26
+ pct = min(100, block * bsize * 100 // total)
27
+ sys.stdout.write(f"\r {pct:3d}% ({block * bsize / 1e6:.0f} MB)")
28
+ sys.stdout.flush()
29
+
30
+ urllib.request.urlretrieve(url, dest, reporthook=_hook)
31
+ print(f"\r done: {dest.stat().st_size / 1e6:.1f} MB{' ' * 10}")
32
+
33
+
34
+ def main() -> int:
35
+ ap = argparse.ArgumentParser()
36
+ ap.add_argument("--out-dir", default="data/raw/prm800k")
37
+ ap.add_argument("--all", action="store_true", help="download all four phase files")
38
+ args = ap.parse_args()
39
+ names = FILES if args.all else ["phase2_train.jsonl"]
40
+ for n in names:
41
+ download(n, Path(args.out_dir))
42
+ print("\nNext: python -m mathcompose.data.build_verifier_dataset "
43
+ f"--prm800k {args.out_dir}/phase2_train.jsonl --teacher promptlens --limit 15000")
44
+ return 0
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())
scripts/publish_hf.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Publish to HF Hub: the V dataset (deliverable) + a self-contained code+data
2
+ snapshot the Colab notebook can pull with just an HF token (no GitHub needed).
3
+
4
+ # after setting HF_TOKEN in .env:
5
+ python scripts/publish_hf.py --dataset --code
6
+
7
+ Creates:
8
+ <user>/mathcompose-verifier (dataset repo) — data/verifier/*.jsonl + card
9
+ <user>/mathcompose (model repo) — full code + data/verifier
10
+ (excludes data/raw, runs, caches)
11
+ The Colab notebook snapshot_downloads the model repo, `pip install -e .`, trains.
12
+ """
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
18
+ from mathcompose.common.env import load_dotenv, first_env # noqa: E402
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+
22
+ DATASET_CARD = """---
23
+ license: mit
24
+ task_categories: [text-generation]
25
+ tags: [math, process-verification, ProcessBench, PRM800K, mathcompose]
26
+ ---
27
+
28
+ # mathcompose — Model V (process verifier) SFT data
29
+
30
+ First-error-localization critiques for training a small generative math verifier.
31
+ Each row: a problem + a step-indexed candidate solution (`prompt`) and a
32
+ paragraph-by-paragraph critique ending in `\\boxed{{k}}` (`completion`), where `k`
33
+ is the 0-based index of the first erroneous step (`-1` = all correct).
34
+
35
+ - **Labels** are ground truth from **PRM800K** (OpenAI, MIT).
36
+ - **Critiques** distilled from **claude-opus-4-8** (rationalization conditioned on
37
+ the known label; the boxed index is stamped authoritatively, not trusted to the teacher).
38
+ - **Deduped** against ProcessBench + MATH-500 (no eval contamination).
39
+ - `train.jsonl`/`val.jsonl` are class-balanced (~50/50 erroneous/all-correct);
40
+ `*_full.jsonl` keep the natural PRM800K distribution.
41
+
42
+ Built by the mathcompose harness. Eval target: ProcessBench first-error F1.
43
+ """
44
+
45
+
46
+ def main() -> int:
47
+ ap = argparse.ArgumentParser()
48
+ ap.add_argument("--dataset", action="store_true", help="publish the dataset repo")
49
+ ap.add_argument("--code", action="store_true", help="publish the code+data snapshot repo")
50
+ ap.add_argument("--user", default=None, help="HF username/org (default: from token)")
51
+ ap.add_argument("--dataset-repo", default=None)
52
+ ap.add_argument("--code-repo", default=None)
53
+ ap.add_argument("--private", action="store_true")
54
+ args = ap.parse_args()
55
+ if not (args.dataset or args.code):
56
+ args.dataset = args.code = True
57
+
58
+ load_dotenv()
59
+ token = first_env(["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"])
60
+ if not token:
61
+ print("No HF token — set HF_TOKEN in .env."); return 1
62
+
63
+ from huggingface_hub import HfApi
64
+ api = HfApi(token=token)
65
+ user = args.user or api.whoami()["name"]
66
+ ds_repo = args.dataset_repo or f"{user}/mathcompose-verifier"
67
+ code_repo = args.code_repo or f"{user}/mathcompose"
68
+
69
+ if args.dataset:
70
+ print(f"publishing dataset -> {ds_repo}")
71
+ api.create_repo(ds_repo, repo_type="dataset", exist_ok=True, private=args.private)
72
+ (ROOT / "data/verifier/README.md").write_text(DATASET_CARD)
73
+ api.upload_folder(
74
+ folder_path=str(ROOT / "data/verifier"),
75
+ repo_id=ds_repo, repo_type="dataset",
76
+ allow_patterns=["*.jsonl", "README.md"],
77
+ )
78
+ print(f" https://huggingface.co/datasets/{ds_repo}")
79
+
80
+ if args.code:
81
+ print(f"publishing code+data snapshot -> {code_repo}")
82
+ api.create_repo(code_repo, repo_type="model", exist_ok=True, private=args.private)
83
+ api.upload_folder(
84
+ folder_path=str(ROOT),
85
+ repo_id=code_repo, repo_type="model",
86
+ ignore_patterns=[
87
+ "data/raw/*", "**/__pycache__/*", "*.egg-info/*", "**/*.pyc",
88
+ "runs/*", ".git/*", ".pytest_cache/*", "data/verifier/*_full.jsonl",
89
+ ".env", ".env.*",
90
+ ],
91
+ )
92
+ print(f" https://huggingface.co/{code_repo}")
93
+
94
+ print("\nColab: snapshot_download the model repo, `pip install -e .`, then train.")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
scripts/rebalance_verifier.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rebalance the Model-V dataset by downsampling all-correct (-1) examples.
2
+
3
+ ProcessBench F1 = harmonic_mean(acc_error, acc_correct), so a training set skewed
4
+ toward -1 biases V to over-accept, crushing acc_error and thus F1. This makes a
5
+ ~balanced default (correct:erroneous = --ratio) and preserves the original.
6
+
7
+ python scripts/rebalance_verifier.py # train.jsonl + val.jsonl, ratio 1.0
8
+ python scripts/rebalance_verifier.py --ratio 1.5
9
+
10
+ Original files are moved to *_full.jsonl; the balanced set takes the canonical name.
11
+ """
12
+ import argparse
13
+ import json
14
+ import random
15
+ from pathlib import Path
16
+
17
+
18
+ def rebalance(path: Path, ratio: float, seed: int) -> tuple[int, int, int]:
19
+ rows = [json.loads(l) for l in path.read_text().splitlines() if l.strip()]
20
+ err = [r for r in rows if r.get("first_error_index", -1) != -1]
21
+ cor = [r for r in rows if r.get("first_error_index", -1) == -1]
22
+ rng = random.Random(seed)
23
+ rng.shuffle(cor)
24
+ keep_cor = cor[: int(round(len(err) * ratio))]
25
+ balanced = err + keep_cor
26
+ rng.shuffle(balanced)
27
+
28
+ full = path.with_name(path.stem + "_full.jsonl")
29
+ if not full.exists():
30
+ path.rename(full) # preserve original once
31
+ with open(path, "w") as f:
32
+ for r in balanced:
33
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
34
+ return len(err), len(keep_cor), len(balanced)
35
+
36
+
37
+ def main() -> int:
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--dir", default="data/verifier")
40
+ ap.add_argument("--ratio", type=float, default=1.0, help="correct:erroneous ratio to keep")
41
+ ap.add_argument("--seed", type=int, default=0)
42
+ args = ap.parse_args()
43
+ for name in ("train.jsonl", "val.jsonl"):
44
+ p = Path(args.dir) / name
45
+ if not p.exists():
46
+ continue
47
+ e, c, t = rebalance(p, args.ratio, args.seed)
48
+ print(f"{name}: {t} rows (erroneous {e}, all-correct {c}); original -> {p.stem}_full.jsonl")
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
scripts/smoke_all.sh ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # CPU smoke suite — proves the harness works before spending GPU.
3
+ # Fast logic-only tests by default; add --full to include the tiny-model
4
+ # train/eval and the HF dataset-schema probes (needs network).
5
+ set -euo pipefail
6
+ cd "$(dirname "$0")/.."
7
+
8
+ export HF_HUB_DISABLE_PROGRESS_BARS=1
9
+ export TOKENIZERS_PARALLELISM=false
10
+
11
+ if [[ "${1:-}" == "--full" ]]; then
12
+ echo ">> full smoke suite (logic + tiny-model train/eval + dataset probes)"
13
+ python -m pytest -q -p no:cacheprovider
14
+ else
15
+ echo ">> fast smoke suite (pure logic; no model/network)"
16
+ python -m pytest -q -m "not slow and not network" -p no:cacheprovider
17
+ echo
18
+ echo " (run './scripts/smoke_all.sh --full' to also exercise training + datasets)"
19
+ fi
scripts/teacher_bakeoff.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pick a teacher empirically: keep-rate + trace-length-vs-ceiling per model.
2
+
3
+ # after setting TFY_API_KEY in .env:
4
+ python scripts/teacher_bakeoff.py --models claude-fable-5 gpt-5.6-sol --n 40
5
+
6
+ For each model it distills N solutions (same problems), grades the boxed answer,
7
+ and reports: keep-rate (higher = better/cheaper data), median/p90 trace length in
8
+ STUDENT tokens, and the fraction that would overflow the generator's max_length
9
+ (dropped). Highest keep-rate that mostly fits the ceiling wins.
10
+
11
+ The V-critique job is easier (label is known from PRM800K), so a lighter/cheaper
12
+ model usually suffices there regardless of who wins for G.
13
+ """
14
+ import argparse
15
+ import statistics as stats
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
21
+
22
+ from mathcompose.common.chat import load_tokenizer # noqa: E402
23
+ from mathcompose.common.math_grade import extract_last_boxed, grade_answer # noqa: E402
24
+ from mathcompose.datagen.gen_generator_data import Problem, synthesize_solution # noqa: E402
25
+ from mathcompose.teachers import get_teacher # noqa: E402
26
+
27
+
28
+ def load_problems(source, split, n, problem_field, solution_field):
29
+ """Stream the source (no full download) and collect the first n problems that
30
+ have a parseable boxed gold answer."""
31
+ from datasets import load_dataset
32
+
33
+ ds = load_dataset(source, split=split, streaming=True)
34
+ out = []
35
+ for row in ds:
36
+ prob = row.get(problem_field)
37
+ ans = extract_last_boxed(row.get(solution_field) or "")
38
+ if prob and ans:
39
+ out.append(Problem(problem=prob, answer=ans))
40
+ if len(out) >= n:
41
+ break
42
+ return out
43
+
44
+
45
+ def main() -> int:
46
+ ap = argparse.ArgumentParser()
47
+ ap.add_argument("--models", nargs="+", required=True, help="gateway model slugs to compare")
48
+ ap.add_argument("--teacher", default="promptlens")
49
+ ap.add_argument("--n", type=int, default=40)
50
+ ap.add_argument("--source", default="AI-MO/NuminaMath-CoT")
51
+ ap.add_argument("--split", default="train")
52
+ ap.add_argument("--problem-field", default="problem")
53
+ ap.add_argument("--solution-field", default="solution")
54
+ ap.add_argument("--max-tokens", type=int, default=2048, help="teacher output cap")
55
+ ap.add_argument("--ceiling", type=int, default=2048, help="generator max_length; traces over this are dropped")
56
+ ap.add_argument("--base-id", default="Qwen/Qwen2.5-Math-1.5B-Instruct")
57
+ args = ap.parse_args()
58
+
59
+ problems = load_problems(args.source, args.split, args.n, args.problem_field, args.solution_field)
60
+ print(f"loaded {len(problems)} problems from {args.source}\n")
61
+ tok = load_tokenizer(args.base_id)
62
+
63
+ rows = []
64
+ for slug in args.models:
65
+ teacher = get_teacher(args.teacher, model=slug)
66
+ kept, lens, t0 = 0, [], time.time()
67
+ for p in problems:
68
+ try:
69
+ sol = synthesize_solution(teacher, p.problem, temperature=0.7, max_tokens=args.max_tokens)
70
+ except Exception as e:
71
+ print(f" [{slug}] call failed: {type(e).__name__}: {e}")
72
+ continue
73
+ n_tok = len(tok(sol)["input_ids"])
74
+ lens.append(n_tok)
75
+ if grade_answer(extract_last_boxed(sol), p.answer):
76
+ kept += 1
77
+ dt = time.time() - t0
78
+ n = len(lens) or 1
79
+ rows.append({
80
+ "model": slug,
81
+ "keep_rate": kept / len(problems),
82
+ "median_tok": int(stats.median(lens)) if lens else 0,
83
+ "p90_tok": int(sorted(lens)[int(0.9 * (len(lens) - 1))]) if lens else 0,
84
+ "over_ceiling": sum(1 for x in lens if x > args.ceiling) / n,
85
+ "sec_per_ex": dt / len(problems),
86
+ })
87
+
88
+ print(f"\n{'model':<24}{'keep':>8}{'med_tok':>9}{'p90_tok':>9}{'>ceil':>8}{'s/ex':>8}")
89
+ print("-" * 66)
90
+ for r in sorted(rows, key=lambda r: -r["keep_rate"]):
91
+ print(f"{r['model']:<24}{r['keep_rate']:>7.0%}{r['median_tok']:>9}{r['p90_tok']:>9}"
92
+ f"{r['over_ceiling']:>7.0%}{r['sec_per_ex']:>7.1f}")
93
+ print("\nPick the highest keep-rate whose p90_tok comfortably fits the ceiling "
94
+ f"({args.ceiling}). Set it via TFY_MODEL or --model at build time.")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
src/mathcompose/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """mathcompose — two <=4B math models (generative verifier + solution generator)
2
+ that together demonstrate the generator-verifier gap.
3
+
4
+ Subpackages:
5
+ common — chat templating, boxed-answer grading, config, seeding (no heavy deps)
6
+ teachers — pluggable frontier teacher clients (+ offline DummyTeacher)
7
+ datagen — build V/G training data (PRM800K parsing, teacher distillation, dedup)
8
+ data — V output schema + dataset builders
9
+ eval — ProcessBench / PRMBench / MATH / composition metrics (objective, no judge)
10
+ train — shared QLoRA SFT entrypoint
11
+ infer — inference demo
12
+ """
13
+
14
+ __version__ = "0.1.0"
src/mathcompose/common/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .math_grade import (
2
+ extract_boxed,
3
+ extract_last_boxed,
4
+ extract_boxed_int,
5
+ grade_answer,
6
+ normalize_final_answer,
7
+ normalize_problem_text,
8
+ )
9
+ from .seeding import set_seed
10
+ from .config import load_config
11
+
12
+ __all__ = [
13
+ "extract_boxed",
14
+ "extract_last_boxed",
15
+ "extract_boxed_int",
16
+ "grade_answer",
17
+ "normalize_final_answer",
18
+ "normalize_problem_text",
19
+ "set_seed",
20
+ "load_config",
21
+ ]
src/mathcompose/common/chat.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Prompt construction + tokenizer/chat-template helpers.
2
+
3
+ Prompt builders are pure-Python (no heavy deps) so datagen and tests can build
4
+ prompts without importing transformers. ``load_tokenizer`` is the only function
5
+ that touches transformers, and it applies the Qwen2.5-Math pad-token fix.
6
+
7
+ Model V speaks the *official ProcessBench critic prompt*: the solution is split
8
+ into paragraphs, tagged and indexed from 0; V critiques each and returns the
9
+ 0-based index of the first erroneous paragraph in ``\boxed{}`` (``-1`` = all
10
+ correct). Using ProcessBench's own format means the same model scores
11
+ ProcessBench *and* reranks Model G with zero prompt divergence.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ # Qwen2.5-Math default CoT system prompt (do NOT use the TIR variant here).
18
+ GENERATOR_SYSTEM = (
19
+ "Please reason step by step, and put your final answer within \\boxed{}."
20
+ )
21
+
22
+ VERIFIER_SYSTEM = (
23
+ "You are a meticulous math grader. You are given a problem and a candidate "
24
+ "solution split into indexed paragraphs. Critically review the solution one "
25
+ "paragraph at a time and find the FIRST paragraph that contains an error."
26
+ )
27
+
28
+ # The official ProcessBench critic instruction (verbatim intent).
29
+ _VERIFIER_INSTRUCTION = (
30
+ "The following is a math problem and a solution (split into paragraphs, "
31
+ "enclosed with tags and indexed from 0):\n\n"
32
+ "[Math Problem]\n{problem}\n\n"
33
+ "[Solution]\n{tagged_steps}\n\n"
34
+ "Your task is to review and critique the solution paragraph by paragraph. "
35
+ "Once you identify an error in a paragraph, return the index of the paragraph "
36
+ "where the earliest error occurs. Otherwise, return the index of -1 (which "
37
+ "typically denotes \"all correct\").\n\n"
38
+ "Please put your final answer (i.e., the index) in \\boxed{{}}."
39
+ )
40
+
41
+
42
+ def tag_steps(steps: list[str]) -> str:
43
+ """Render a list of solution steps as indexed <paragraph_i> blocks."""
44
+ return "\n\n".join(
45
+ f"<paragraph_{i}>\n{s.strip()}\n</paragraph_{i}>" for i, s in enumerate(steps)
46
+ )
47
+
48
+
49
+ def build_verifier_prompt(problem: str, steps: list[str]) -> str:
50
+ """User-turn content for Model V (ProcessBench critic format)."""
51
+ return _VERIFIER_INSTRUCTION.format(problem=problem.strip(), tagged_steps=tag_steps(steps))
52
+
53
+
54
+ def verifier_messages(problem: str, steps: list[str]) -> list[dict]:
55
+ return [
56
+ {"role": "system", "content": VERIFIER_SYSTEM},
57
+ {"role": "user", "content": build_verifier_prompt(problem, steps)},
58
+ ]
59
+
60
+
61
+ def build_generator_prompt(problem: str) -> str:
62
+ return problem.strip()
63
+
64
+
65
+ def generator_messages(problem: str) -> list[dict]:
66
+ return [
67
+ {"role": "system", "content": GENERATOR_SYSTEM},
68
+ {"role": "user", "content": build_generator_prompt(problem)},
69
+ ]
70
+
71
+
72
+ def split_into_steps(solution: str) -> list[str]:
73
+ """Split a free-form solution into paragraph 'steps' the way ProcessBench does
74
+ (double-newline separated, non-empty). Used when reranking G's own outputs."""
75
+ parts = [p.strip() for p in solution.replace("\r\n", "\n").split("\n\n")]
76
+ return [p for p in parts if p]
77
+
78
+
79
+ # ----------------------------------------------------------------------------
80
+ # Tokenizer (transformers) — only import when actually loading a model.
81
+ # ----------------------------------------------------------------------------
82
+
83
+ def load_tokenizer(base_id: str, pad_token: Optional[str] = "<|fim_pad|>"):
84
+ """Load the tokenizer and apply the Qwen pad-token fix.
85
+
86
+ Using <|endoftext|> as the pad token triggers infinite generations after
87
+ finetuning (documented Qwen2.5 gotcha); we pin a distinct pad token.
88
+ """
89
+ from transformers import AutoTokenizer
90
+
91
+ tok = AutoTokenizer.from_pretrained(base_id)
92
+ if pad_token and pad_token in tok.get_vocab():
93
+ tok.pad_token = pad_token
94
+ elif tok.pad_token is None:
95
+ tok.pad_token = tok.eos_token
96
+ # Base (non-chat) models and some tiny test tokenizers ship no chat template;
97
+ # fall back to a minimal ChatML so apply_chat_template always works.
98
+ if getattr(tok, "chat_template", None) is None:
99
+ tok.chat_template = _MINIMAL_CHATML
100
+ return tok
101
+
102
+
103
+ _MINIMAL_CHATML = (
104
+ "{% for m in messages %}"
105
+ "{{ '<|im_start|>' + m['role'] + '\n' + m['content'] + '<|im_end|>\n' }}"
106
+ "{% endfor %}"
107
+ "{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}"
108
+ )
src/mathcompose/common/config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny YAML config loader with single-level `extends` inheritance and deep-merge.
2
+
3
+ cfg = load_config("configs/verifier_v.yaml")
4
+ cfg["model"]["base_id"] # inherited from base.yaml, overridable per-file
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+
15
+ def _deep_merge(base: dict, override: dict) -> dict:
16
+ out = copy.deepcopy(base)
17
+ for k, v in override.items():
18
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
19
+ out[k] = _deep_merge(out[k], v)
20
+ else:
21
+ out[k] = copy.deepcopy(v)
22
+ return out
23
+
24
+
25
+ def load_config(path: str | Path) -> dict[str, Any]:
26
+ path = Path(path)
27
+ with open(path) as f:
28
+ cfg = yaml.safe_load(f) or {}
29
+ parent = cfg.pop("extends", None)
30
+ if parent:
31
+ parent_cfg = load_config(path.parent / parent)
32
+ cfg = _deep_merge(parent_cfg, cfg)
33
+ return cfg
src/mathcompose/common/env.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal .env loader (no python-dotenv dependency).
2
+
3
+ Parses simple KEY=VALUE lines (optionally `export KEY=VALUE`, quoted values,
4
+ `#` comments) from a .env file and puts them in os.environ. Existing env vars
5
+ win unless override=True.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+
13
+ def load_dotenv(path: str | Path = ".env", override: bool = False) -> bool:
14
+ p = Path(path)
15
+ if not p.exists():
16
+ return False
17
+ for raw in p.read_text().splitlines():
18
+ line = raw.strip()
19
+ if not line or line.startswith("#"):
20
+ continue
21
+ if line.startswith("export "):
22
+ line = line[len("export "):]
23
+ if "=" not in line:
24
+ continue
25
+ key, val = line.split("=", 1)
26
+ key = key.strip()
27
+ val = val.strip().strip('"').strip("'")
28
+ if override or key not in os.environ:
29
+ os.environ[key] = val
30
+ return True
31
+
32
+
33
+ def first_env(names: list[str]) -> str | None:
34
+ """Return the first set-and-non-empty env var among names."""
35
+ for n in names:
36
+ v = os.environ.get(n)
37
+ if v:
38
+ return v
39
+ return None
src/mathcompose/common/io.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """jsonl read/write helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Iterable, Iterator
7
+
8
+
9
+ def write_jsonl(rows: Iterable[dict], path: str | Path) -> int:
10
+ path = Path(path)
11
+ path.parent.mkdir(parents=True, exist_ok=True)
12
+ n = 0
13
+ with open(path, "w") as f:
14
+ for r in rows:
15
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
16
+ n += 1
17
+ return n
18
+
19
+
20
+ def read_jsonl(path: str | Path) -> Iterator[dict]:
21
+ with open(path) as f:
22
+ for line in f:
23
+ line = line.strip()
24
+ if line:
25
+ yield json.loads(line)
src/mathcompose/common/math_grade.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Boxed-answer extraction and math-equivalence grading.
2
+
3
+ Two independent jobs:
4
+
5
+ 1. ``extract_boxed_int`` — pull the integer inside the LAST ``\boxed{...}``.
6
+ Used by Model V, whose canonical output ends in ``\boxed{k}`` (first-error
7
+ index, ``-1`` = all steps correct). This is the ProcessBench primitive.
8
+
9
+ 2. ``grade_answer`` — decide whether a predicted final answer is mathematically
10
+ equivalent to the gold answer. Used by Model G's eval and by the data filter.
11
+ Best-effort: string normalization -> sympy simplify -> numeric fallback,
12
+ fully guarded so it degrades to a normalized string compare. Swap in
13
+ ``math_verify`` / the PRM800K sympy grader later if you want stricter grading.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Optional
19
+
20
+ # ----------------------------------------------------------------------------
21
+ # Boxed extraction (balanced-brace aware)
22
+ # ----------------------------------------------------------------------------
23
+
24
+ _BOXED_PREFIXES = (r"\boxed", r"\fbox")
25
+
26
+
27
+ def _find_boxed_spans(text: str) -> list[str]:
28
+ """Return the *contents* of every ``\boxed{...}`` / ``\fbox{...}`` with
29
+ balanced-brace matching (so ``\boxed{\frac{1}{2}}`` returns ``\frac{1}{2}``)."""
30
+ results: list[str] = []
31
+ for prefix in _BOXED_PREFIXES:
32
+ start = 0
33
+ while True:
34
+ idx = text.find(prefix, start)
35
+ if idx == -1:
36
+ break
37
+ i = idx + len(prefix)
38
+ # skip whitespace, expect an opening brace
39
+ while i < len(text) and text[i] in " \t":
40
+ i += 1
41
+ if i >= len(text) or text[i] != "{":
42
+ # \boxed without braces, e.g. "\boxed -1" — grab the token
43
+ m = re.match(r"\s*(-?\d+)", text[idx + len(prefix):])
44
+ if m:
45
+ results.append(m.group(1))
46
+ start = idx + len(prefix)
47
+ continue
48
+ depth = 0
49
+ j = i
50
+ while j < len(text):
51
+ if text[j] == "{":
52
+ depth += 1
53
+ elif text[j] == "}":
54
+ depth -= 1
55
+ if depth == 0:
56
+ break
57
+ j += 1
58
+ results.append(text[i + 1 : j])
59
+ start = j + 1
60
+ return results
61
+
62
+
63
+ def extract_boxed(text: str) -> Optional[str]:
64
+ """Contents of the FIRST boxed expression, or None."""
65
+ spans = _find_boxed_spans(text)
66
+ return spans[0] if spans else None
67
+
68
+
69
+ def extract_last_boxed(text: str) -> Optional[str]:
70
+ """Contents of the LAST boxed expression, or None. This is the final answer."""
71
+ spans = _find_boxed_spans(text)
72
+ return spans[-1] if spans else None
73
+
74
+
75
+ def extract_boxed_int(text: str) -> Optional[int]:
76
+ """Integer inside the LAST boxed expression (Model V's first-error index).
77
+
78
+ Tolerant of ``\boxed{-1}``, ``\boxed{ 3 }``, ``\boxed{\text{-1}}``, and a
79
+ bare trailing ``-1`` if no box is present at all.
80
+ """
81
+ span = extract_last_boxed(text)
82
+ if span is not None:
83
+ m = re.search(r"-?\d+", span)
84
+ if m:
85
+ return int(m.group())
86
+ # Last-ditch: a trailing integer on the final non-empty line.
87
+ for line in reversed([l for l in text.strip().splitlines() if l.strip()]):
88
+ m = re.search(r"(-?\d+)\s*$", line.strip())
89
+ if m:
90
+ return int(m.group(1))
91
+ return None
92
+
93
+
94
+ # ----------------------------------------------------------------------------
95
+ # Answer normalization + equivalence
96
+ # ----------------------------------------------------------------------------
97
+
98
+ _SUBST = [
99
+ (r"\\left", ""),
100
+ (r"\\right", ""),
101
+ (r"\\!", ""),
102
+ (r"\\,", ""),
103
+ (r"\\;", ""),
104
+ (r"\\ ", " "),
105
+ (r"\\%", ""),
106
+ (r"\\\$", ""),
107
+ (r"\\cdot", "*"),
108
+ (r"\\times", "*"),
109
+ (r"\\div", "/"),
110
+ (r"\\pi", "pi"),
111
+ (r"\\pm", ""),
112
+ ]
113
+
114
+
115
+ def _strip_text_wrappers(s: str) -> str:
116
+ # \text{...}, \mathrm{...}, \mbox{...} -> inner
117
+ for macro in ("text", "mathrm", "mbox", "textbf", "mathbf"):
118
+ s = re.sub(r"\\" + macro + r"\{([^{}]*)\}", r"\1", s)
119
+ return s
120
+
121
+
122
+ def _normalize_answer(s: str) -> str:
123
+ if s is None:
124
+ return ""
125
+ s = str(s).strip()
126
+ # strip surrounding math delimiters and whitespace
127
+ s = s.replace("$", "").replace("\\(", "").replace("\\)", "")
128
+ s = s.replace("\\[", "").replace("\\]", "")
129
+ s = _strip_text_wrappers(s)
130
+ for pat, rep in _SUBST:
131
+ s = re.sub(pat, rep, s)
132
+ s = s.replace("°", "") # degree sign
133
+ s = s.replace("^{\\circ}", "").replace("^\\circ", "").replace("\\circ", "")
134
+ s = s.replace(" ", "")
135
+ s = s.rstrip(".")
136
+ # thousands separators inside numbers: 1,000 -> 1000
137
+ s = re.sub(r"(?<=\d),(?=\d{3}\b)", "", s)
138
+ # trailing units words like "dollars", "cm" — conservative: only strip a
139
+ # trailing alpha run if the rest is numeric-ish.
140
+ return s
141
+
142
+
143
+ def _latex_to_sympy(s: str) -> str:
144
+ """Very small latex->sympy-parseable rewrite (no latex2sympy dependency)."""
145
+ # \frac{a}{b} -> ((a)/(b)) (repeat for nesting)
146
+ for _ in range(6):
147
+ new = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"((\1)/(\2))", s)
148
+ new = re.sub(r"\\sqrt\{([^{}]*)\}", r"sqrt(\1)", new)
149
+ new = re.sub(r"\\sqrt\[(\d+)\]\{([^{}]*)\}", r"(\2)**(1/\1)", new)
150
+ if new == s:
151
+ break
152
+ s = new
153
+ s = s.replace("{", "(").replace("}", ")")
154
+ s = re.sub(r"\^", "**", s)
155
+ s = s.replace("\\", "")
156
+ return s
157
+
158
+
159
+ def _try_float(s: str) -> Optional[float]:
160
+ try:
161
+ return float(s)
162
+ except (ValueError, TypeError):
163
+ return None
164
+
165
+
166
+ def normalize_final_answer(s: str) -> str:
167
+ """Public normalizer for bucketing answers in majority voting."""
168
+ return _normalize_answer(s)
169
+
170
+
171
+ def grade_answer(pred: Optional[str], gold: Optional[str]) -> bool:
172
+ """True iff `pred` is mathematically equivalent to `gold`.
173
+
174
+ Order: exact normalized string -> numeric -> sympy simplify. Fully guarded.
175
+ """
176
+ if pred is None or gold is None:
177
+ return False
178
+ p, g = _normalize_answer(pred), _normalize_answer(gold)
179
+ if p == "" and g == "":
180
+ return False
181
+ if p == g:
182
+ return True
183
+
184
+ # numeric comparison
185
+ pf, gf = _try_float(p), _try_float(g)
186
+ if pf is not None and gf is not None:
187
+ return abs(pf - gf) <= 1e-6 * max(1.0, abs(gf))
188
+
189
+ # symbolic comparison
190
+ try:
191
+ import sympy as sp
192
+ from sympy.parsing.sympy_parser import (
193
+ parse_expr,
194
+ standard_transformations,
195
+ implicit_multiplication_application,
196
+ )
197
+
198
+ transforms = standard_transformations + (
199
+ implicit_multiplication_application,
200
+ )
201
+ ep = parse_expr(_latex_to_sympy(p), transformations=transforms, evaluate=True)
202
+ eg = parse_expr(_latex_to_sympy(g), transformations=transforms, evaluate=True)
203
+ diff = sp.simplify(ep - eg)
204
+ if diff == 0:
205
+ return True
206
+ # fall back to numeric equality of the two expressions
207
+ try:
208
+ return bool(abs(float(ep) - float(eg)) <= 1e-6)
209
+ except (TypeError, ValueError):
210
+ return False
211
+ except Exception:
212
+ return False
213
+
214
+
215
+ # ----------------------------------------------------------------------------
216
+ # Problem-text normalization for contamination dedup
217
+ # ----------------------------------------------------------------------------
218
+
219
+ def normalize_problem_text(s: str) -> str:
220
+ """Aggressive normalization so the same problem from different sources
221
+ (PRM800K vs ProcessBench vs MATH-500) collides for dedup."""
222
+ if s is None:
223
+ return ""
224
+ s = s.lower()
225
+ s = _strip_text_wrappers(s)
226
+ s = re.sub(r"[^a-z0-9]+", "", s) # keep only alnum; drop all latex/space/punct
227
+ return s
src/mathcompose/common/parallel.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ordered, fault-tolerant thread-pool map for I/O-bound teacher calls.
2
+
3
+ API calls are I/O-bound, so threads give near-linear speedup. Results preserve
4
+ input order; any item whose fn raises becomes None (so callers filter(None)).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from typing import Callable, Iterable, Optional
10
+
11
+
12
+ def thread_map(
13
+ fn: Callable,
14
+ items: Iterable,
15
+ workers: int = 8,
16
+ desc: Optional[str] = None,
17
+ ) -> list:
18
+ items = list(items)
19
+ results: list = [None] * len(items)
20
+
21
+ try:
22
+ from tqdm.auto import tqdm
23
+ except Exception: # pragma: no cover
24
+ def tqdm(x, **k):
25
+ return x
26
+
27
+ if workers <= 1:
28
+ seq = enumerate(items)
29
+ for i, x in tqdm(seq, total=len(items), desc=desc):
30
+ try:
31
+ results[i] = fn(x)
32
+ except Exception:
33
+ results[i] = None
34
+ return results
35
+
36
+ with ThreadPoolExecutor(max_workers=workers) as ex:
37
+ futs = {ex.submit(fn, x): i for i, x in enumerate(items)}
38
+ for fut in tqdm(as_completed(futs), total=len(items), desc=desc):
39
+ i = futs[fut]
40
+ try:
41
+ results[i] = fut.result()
42
+ except Exception:
43
+ results[i] = None
44
+ return results
src/mathcompose/common/seeding.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic seeding across random / numpy / torch (torch optional)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import random
6
+
7
+
8
+ def set_seed(seed: int = 0) -> None:
9
+ os.environ["PYTHONHASHSEED"] = str(seed)
10
+ random.seed(seed)
11
+ try:
12
+ import numpy as np
13
+
14
+ np.random.seed(seed)
15
+ except Exception:
16
+ pass
17
+ try:
18
+ import torch
19
+
20
+ torch.manual_seed(seed)
21
+ if torch.cuda.is_available():
22
+ torch.cuda.manual_seed_all(seed)
23
+ except Exception:
24
+ pass
src/mathcompose/data/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .schema import (
2
+ StepVerdict,
3
+ VerifierOutput,
4
+ serialize_verifier_output,
5
+ parse_verifier_output,
6
+ )
7
+
8
+ __all__ = [
9
+ "StepVerdict",
10
+ "VerifierOutput",
11
+ "serialize_verifier_output",
12
+ "parse_verifier_output",
13
+ ]
src/mathcompose/data/build_generator_dataset.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Build Model G's SFT dataset by distilling CoT solutions from the teacher and
2
+ keeping only answer-correct traces.
3
+
4
+ python -m mathcompose.data.build_generator_dataset \
5
+ --source AI-MO/NuminaMath-CoT --split train \
6
+ --teacher anthropic --limit 8000 --out-dir data/generator
7
+
8
+ Problem/answer are read from the HF dataset; if no explicit answer field exists,
9
+ the gold answer is taken from the LAST \boxed{} in the dataset's own solution.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import random
16
+ from pathlib import Path
17
+
18
+ from ..common.io import write_jsonl
19
+ from ..common.math_grade import extract_last_boxed, grade_answer
20
+ from ..common.parallel import thread_map
21
+ from ..teachers import get_teacher
22
+ from ..datagen.dedup import build_contamination_set, is_contaminated
23
+ from ..datagen.gen_generator_data import Problem, synthesize_solution, make_generator_row
24
+
25
+
26
+ def _load_problems(source: str, split: str, problem_field: str, answer_field: str | None,
27
+ solution_field: str, limit: int | None) -> list[Problem]:
28
+ from datasets import load_dataset
29
+
30
+ ds = load_dataset(source, split=split)
31
+ if limit:
32
+ ds = ds.select(range(min(limit, len(ds))))
33
+ problems: list[Problem] = []
34
+ for row in ds:
35
+ prob = row.get(problem_field)
36
+ if not prob:
37
+ continue
38
+ if answer_field and row.get(answer_field) not in (None, ""):
39
+ ans = str(row[answer_field])
40
+ else:
41
+ sol = row.get(solution_field) or ""
42
+ ans = extract_last_boxed(sol)
43
+ if not ans:
44
+ continue
45
+ problems.append(Problem(problem=prob, answer=ans))
46
+ return problems
47
+
48
+
49
+ def main() -> None:
50
+ ap = argparse.ArgumentParser(description=__doc__)
51
+ ap.add_argument("--source", default="AI-MO/NuminaMath-CoT")
52
+ ap.add_argument("--split", default="train")
53
+ ap.add_argument("--problem-field", default="problem")
54
+ ap.add_argument("--answer-field", default=None, help="explicit answer column, if any")
55
+ ap.add_argument("--solution-field", default="solution", help="fallback: parse \\boxed{} from here")
56
+ ap.add_argument("--out-dir", default="data/generator")
57
+ ap.add_argument("--teacher", default="promptlens",
58
+ choices=["promptlens", "tfy", "anthropic", "openai", "dummy"])
59
+ ap.add_argument("--model", default=None)
60
+ ap.add_argument("--limit", type=int, default=None)
61
+ ap.add_argument("--val-frac", type=float, default=0.05)
62
+ ap.add_argument("--temperature", type=float, default=0.7)
63
+ ap.add_argument("--max-attempts", type=int, default=2)
64
+ ap.add_argument("--workers", type=int, default=8, help="concurrent teacher calls")
65
+ ap.add_argument("--keep-all", action="store_true", help="skip the answer-correct filter")
66
+ ap.add_argument("--no-dedup", action="store_true")
67
+ ap.add_argument("--seed", type=int, default=0)
68
+ args = ap.parse_args()
69
+
70
+ random.seed(args.seed)
71
+ teacher = get_teacher(args.teacher, **({"model": args.model} if args.model else {}))
72
+
73
+ banned = set()
74
+ if not args.no_dedup:
75
+ print("Building contamination set from ProcessBench + MATH-500 ...")
76
+ banned = build_contamination_set()
77
+ print(f" {len(banned)} eval problems banned")
78
+
79
+ problems = [
80
+ p for p in _load_problems(args.source, args.split, args.problem_field,
81
+ args.answer_field, args.solution_field, args.limit)
82
+ if not (banned and is_contaminated(p.problem, banned))
83
+ ]
84
+ print(f"Loaded {len(problems)} problems; distilling "
85
+ f"({args.workers} workers, teacher={args.teacher}) ...")
86
+
87
+ def _one(p):
88
+ for _ in range(args.max_attempts):
89
+ sol = synthesize_solution(teacher, p.problem, temperature=args.temperature)
90
+ if args.keep_all or grade_answer(extract_last_boxed(sol), p.answer):
91
+ return make_generator_row(p.problem, sol)
92
+ return None
93
+
94
+ rows = [r for r in thread_map(_one, problems, workers=args.workers, desc="generator") if r]
95
+ random.shuffle(rows)
96
+
97
+ n_val = max(1, int(len(rows) * args.val_frac)) if rows else 0
98
+ val, train = rows[:n_val], rows[n_val:]
99
+
100
+ out = Path(args.out_dir)
101
+ n_train = write_jsonl(train, out / "train.jsonl")
102
+ n_val = write_jsonl(val, out / "val.jsonl")
103
+ stats = {
104
+ "n_problems": len(problems),
105
+ "n_kept": len(rows),
106
+ "n_train": n_train,
107
+ "n_val": n_val,
108
+ "keep_rate": (len(rows) / len(problems)) if problems else 0.0,
109
+ "teacher": args.teacher,
110
+ "filtered_by_answer": not args.keep_all,
111
+ }
112
+ (out / "stats.json").write_text(json.dumps(stats, indent=2))
113
+ print(json.dumps(stats, indent=2))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
src/mathcompose/data/build_verifier_dataset.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Build Model V's SFT dataset from PRM800K.
2
+
3
+ python -m mathcompose.data.build_verifier_dataset \
4
+ --prm800k data/raw/prm800k/phase2_train.jsonl \
5
+ --teacher anthropic --limit 15000 --out-dir data/verifier
6
+
7
+ Writes data/verifier/{train,val}.jsonl (+ stats.json). Contamination against
8
+ ProcessBench/MATH-500 is enforced unless --no-dedup.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import random
15
+ from pathlib import Path
16
+
17
+ from ..common.io import write_jsonl
18
+ from ..common.parallel import thread_map
19
+ from ..teachers import get_teacher
20
+ from ..datagen.prm800k_loader import iter_prm800k
21
+ from ..datagen.dedup import build_contamination_set, is_contaminated
22
+ from ..datagen.gen_verifier_data import synthesize_verifier_completion, make_verifier_row
23
+
24
+
25
+ def main() -> None:
26
+ ap = argparse.ArgumentParser(description=__doc__)
27
+ ap.add_argument("--prm800k", required=True, help="path to PRM800K .jsonl")
28
+ ap.add_argument("--out-dir", default="data/verifier")
29
+ ap.add_argument("--teacher", default="promptlens",
30
+ choices=["promptlens", "tfy", "anthropic", "openai", "dummy"])
31
+ ap.add_argument("--model", default=None, help="teacher model id override")
32
+ ap.add_argument("--limit", type=int, default=None)
33
+ ap.add_argument("--val-frac", type=float, default=0.05)
34
+ ap.add_argument("--temperature", type=float, default=0.4)
35
+ ap.add_argument("--workers", type=int, default=8, help="concurrent teacher calls")
36
+ ap.add_argument("--no-dedup", action="store_true")
37
+ ap.add_argument("--seed", type=int, default=0)
38
+ args = ap.parse_args()
39
+
40
+ random.seed(args.seed)
41
+ teacher = get_teacher(args.teacher, **({"model": args.model} if args.model else {}))
42
+
43
+ banned = set()
44
+ if not args.no_dedup:
45
+ print("Building contamination set from ProcessBench + MATH-500 ...")
46
+ banned = build_contamination_set()
47
+ print(f" {len(banned)} eval problems banned")
48
+
49
+ records = [
50
+ r for r in iter_prm800k(args.prm800k, limit=args.limit)
51
+ if not (banned and is_contaminated(r.problem, banned))
52
+ ]
53
+ print(f"synthesizing critiques for {len(records)} records "
54
+ f"({args.workers} workers, teacher={args.teacher}) ...")
55
+
56
+ def _one(rec):
57
+ completion = synthesize_verifier_completion(teacher, rec, temperature=args.temperature)
58
+ return make_verifier_row(rec, completion)
59
+
60
+ rows = [r for r in thread_map(_one, records, workers=args.workers, desc="verifier") if r]
61
+ random.shuffle(rows)
62
+
63
+ n_val = max(1, int(len(rows) * args.val_frac)) if rows else 0
64
+ val, train = rows[:n_val], rows[n_val:]
65
+
66
+ out = Path(args.out_dir)
67
+ n_train = write_jsonl(train, out / "train.jsonl")
68
+ n_val = write_jsonl(val, out / "val.jsonl")
69
+
70
+ err = sum(1 for r in rows if r.get("first_error_index", -1) != -1)
71
+ stats = {
72
+ "n_total": len(rows),
73
+ "n_train": n_train,
74
+ "n_val": n_val,
75
+ "n_erroneous": err,
76
+ "n_all_correct": len(rows) - err,
77
+ "teacher": args.teacher,
78
+ "deduped": not args.no_dedup,
79
+ }
80
+ (out / "stats.json").write_text(json.dumps(stats, indent=2))
81
+ print(json.dumps(stats, indent=2))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
src/mathcompose/data/schema.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Model V's canonical output schema — the single primitive that lets one model
2
+ both score ProcessBench and rerank Model G.
3
+
4
+ Serialized completion (what V is trained to produce and what we parse back):
5
+
6
+ Paragraph 0: <one-line critique>. Verdict: correct
7
+ Paragraph 1: <one-line critique>. Verdict: incorrect
8
+
9
+ The earliest error is in paragraph 1.
10
+
11
+ \boxed{1}
12
+
13
+ The trailing ``\boxed{k}`` is authoritative: ``k`` = 0-based index of the first
14
+ incorrect paragraph, ``-1`` = all correct. Per-paragraph ``Verdict:`` lines are
15
+ a secondary, best-effort signal (useful for analysis and soft rerank scores).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from typing import Optional
22
+
23
+
24
+ @dataclass
25
+ class StepVerdict:
26
+ index: int
27
+ verdict: str # "correct" | "incorrect"
28
+ critique: str = ""
29
+
30
+
31
+ @dataclass
32
+ class VerifierOutput:
33
+ first_error_index: int # -1 == all correct
34
+ steps: list[StepVerdict] = field(default_factory=list)
35
+ raw: str = ""
36
+
37
+ @property
38
+ def all_correct(self) -> bool:
39
+ return self.first_error_index == -1
40
+
41
+
42
+ def serialize_verifier_output(
43
+ step_verdicts: list[StepVerdict], first_error_index: int
44
+ ) -> str:
45
+ """Render a training-target completion from structured verdicts."""
46
+ lines = []
47
+ for sv in step_verdicts:
48
+ crit = sv.critique.strip()
49
+ prefix = f"Paragraph {sv.index}: "
50
+ body = (crit + " " if crit else "")
51
+ lines.append(f"{prefix}{body}Verdict: {sv.verdict}")
52
+ body = "\n".join(lines)
53
+ if first_error_index == -1:
54
+ tail = "\n\nAll paragraphs are correct."
55
+ else:
56
+ tail = f"\n\nThe earliest error is in paragraph {first_error_index}."
57
+ return f"{body}{tail}\n\n\\boxed{{{first_error_index}}}"
58
+
59
+
60
+ _VERDICT_RE = re.compile(
61
+ r"[Pp]aragraph\s*(\d+)\s*:?.*?[Vv]erdict\s*:?\s*(correct|incorrect)",
62
+ re.DOTALL,
63
+ )
64
+
65
+
66
+ def parse_verifier_output(text: str) -> VerifierOutput:
67
+ """Parse raw model text back into a VerifierOutput.
68
+
69
+ The boxed integer is authoritative for ``first_error_index``. If no box is
70
+ present we fall back to the earliest paragraph explicitly marked incorrect.
71
+ """
72
+ from ..common.math_grade import extract_boxed_int
73
+
74
+ step_verdicts: list[StepVerdict] = []
75
+ for m in _VERDICT_RE.finditer(text):
76
+ step_verdicts.append(StepVerdict(index=int(m.group(1)), verdict=m.group(2)))
77
+
78
+ idx = extract_boxed_int(text)
79
+ if idx is None:
80
+ # fall back: earliest "incorrect" paragraph, else -1 if any verdicts seen
81
+ incorrect = [sv.index for sv in step_verdicts if sv.verdict == "incorrect"]
82
+ if incorrect:
83
+ idx = min(incorrect)
84
+ elif step_verdicts:
85
+ idx = -1
86
+ else:
87
+ idx = None # genuinely unparseable
88
+
89
+ return VerifierOutput(
90
+ first_error_index=idx if idx is not None else -2, # -2 == parse failure sentinel
91
+ steps=step_verdicts,
92
+ raw=text,
93
+ )
src/mathcompose/datagen/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data-generation pipeline for Model V (verifier) and Model G (generator)."""
2
+ from .prm800k_loader import PRM800KRecord, parse_prm800k_row, iter_prm800k
3
+ from .dedup import build_contamination_set, is_contaminated
4
+
5
+ __all__ = [
6
+ "PRM800KRecord",
7
+ "parse_prm800k_row",
8
+ "iter_prm800k",
9
+ "build_contamination_set",
10
+ "is_contaminated",
11
+ ]
src/mathcompose/datagen/dedup.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Contamination guard.
2
+
3
+ PRM800K is built on MATH; ProcessBench's ``math``/``olympiadbench`` subsets and
4
+ MATH-500 also derive from MATH. Training V on a problem that appears in the eval
5
+ would inflate the score. We drop any training problem whose normalized text
6
+ collides with an eval problem.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Iterable, Optional
11
+
12
+ from ..common.math_grade import normalize_problem_text
13
+
14
+
15
+ def build_contamination_set(
16
+ processbench_splits: Iterable[str] = ("math", "olympiadbench", "omnimath", "gsm8k"),
17
+ include_math500: bool = True,
18
+ extra_problems: Optional[Iterable[str]] = None,
19
+ processbench_id: str = "Qwen/ProcessBench",
20
+ math500_id: str = "HuggingFaceH4/MATH-500",
21
+ ) -> set[str]:
22
+ """Normalized problem texts appearing in the eval sets. Requires network
23
+ (HF Hub) unless you pass only ``extra_problems``."""
24
+ banned: set[str] = set()
25
+ from datasets import load_dataset
26
+
27
+ for split in processbench_splits:
28
+ try:
29
+ ds = load_dataset(processbench_id, split=split)
30
+ for p in ds["problem"]:
31
+ banned.add(normalize_problem_text(p))
32
+ except Exception:
33
+ continue
34
+ if include_math500:
35
+ try:
36
+ ds = load_dataset(math500_id, split="test")
37
+ for p in ds["problem"]:
38
+ banned.add(normalize_problem_text(p))
39
+ except Exception:
40
+ pass
41
+ if extra_problems:
42
+ for p in extra_problems:
43
+ banned.add(normalize_problem_text(p))
44
+ return banned
45
+
46
+
47
+ def is_contaminated(problem: str, banned: set[str]) -> bool:
48
+ return normalize_problem_text(problem) in banned
src/mathcompose/datagen/gen_generator_data.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Synthesize Model G training rows: distill CoT solutions from the teacher and
2
+ keep only those whose ``\boxed{}`` answer is correct (rejection sampling on a
3
+ free objective signal).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Iterable, Iterator, Optional
9
+
10
+ from ..common.chat import generator_messages, GENERATOR_SYSTEM
11
+ from ..common.math_grade import extract_last_boxed, grade_answer
12
+ from .dedup import is_contaminated
13
+
14
+
15
+ @dataclass
16
+ class Problem:
17
+ problem: str
18
+ answer: str
19
+
20
+
21
+ def synthesize_solution(teacher, problem: str, *, temperature: float = 0.7, max_tokens: int = 2048) -> str:
22
+ return teacher.complete(generator_messages(problem), temperature=temperature, max_tokens=max_tokens)
23
+
24
+
25
+ def make_generator_row(problem: str, solution: str) -> dict:
26
+ return {
27
+ "prompt": [
28
+ {"role": "system", "content": GENERATOR_SYSTEM},
29
+ {"role": "user", "content": problem.strip()},
30
+ ],
31
+ "completion": [{"role": "assistant", "content": solution.strip()}],
32
+ }
33
+
34
+
35
+ def generate_generator_dataset(
36
+ problems: Iterable[Problem],
37
+ teacher,
38
+ banned: Optional[set[str]] = None,
39
+ *,
40
+ temperature: float = 0.7,
41
+ keep_only_correct: bool = True,
42
+ max_attempts: int = 1,
43
+ skip_on_error: bool = True,
44
+ ) -> Iterator[dict]:
45
+ """Yield filtered G training rows.
46
+
47
+ For each problem, sample up to ``max_attempts`` solutions and keep the first
48
+ whose boxed answer matches the gold (when ``keep_only_correct``). This is the
49
+ rejection-sampling filter that makes the dataset the deliverable.
50
+ """
51
+ for p in problems:
52
+ if banned and is_contaminated(p.problem, banned):
53
+ continue
54
+ kept = None
55
+ for _ in range(max_attempts):
56
+ try:
57
+ sol = synthesize_solution(teacher, p.problem, temperature=temperature)
58
+ except Exception:
59
+ if skip_on_error:
60
+ break
61
+ raise
62
+ if not keep_only_correct:
63
+ kept = sol
64
+ break
65
+ pred = extract_last_boxed(sol)
66
+ if pred is not None and grade_answer(pred, p.answer):
67
+ kept = sol
68
+ break
69
+ if kept is not None:
70
+ yield make_generator_row(p.problem, kept)
src/mathcompose/datagen/gen_verifier_data.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Synthesize Model V training rows from PRM800K records.
2
+
3
+ Key idea (GenPRM-style rationale synthesis): the *label* (first-error index) is
4
+ ground truth from PRM800K. The teacher only writes the paragraph-by-paragraph
5
+ *critique prose*, conditioned on that known index ("rationalization with hint").
6
+ We then STAMP the authoritative ``\boxed{gold}`` onto the completion, so a
7
+ teacher that boxes the wrong index can never corrupt a label.
8
+
9
+ Two distinct prompts:
10
+ * synthesis prompt (to the teacher) — INCLUDES the gold index.
11
+ * stored training prompt (what V sees) — the plain ProcessBench critic prompt,
12
+ with NO gold leak; V must learn to find the error itself.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Iterable, Iterator, Optional
18
+
19
+ from ..common.chat import build_verifier_prompt, verifier_messages, VERIFIER_SYSTEM
20
+ from .prm800k_loader import PRM800KRecord
21
+ from .dedup import is_contaminated
22
+
23
+ _SYNTH_INSTRUCTION = (
24
+ "You are writing a high-quality critique for a grader-training dataset.\n\n"
25
+ "{prompt}\n\n"
26
+ "GROUND TRUTH (for your eyes only — never reveal that you were told): "
27
+ "{gold_desc}\n\n"
28
+ "Write a concise paragraph-by-paragraph critique. For each paragraph i, output "
29
+ "exactly one line:\n"
30
+ " Paragraph i: <one-sentence analysis>. Verdict: correct\n"
31
+ "or\n"
32
+ " Paragraph i: <one-sentence analysis>. Verdict: incorrect\n"
33
+ "Paragraphs before the first error are correct; the first error is the paragraph "
34
+ "named above; do not analyze paragraphs after the first error. Finish with a short "
35
+ "sentence naming the earliest error paragraph (or stating all paragraphs are correct). "
36
+ "Do NOT write a \\boxed{{}} — it is appended automatically."
37
+ )
38
+
39
+
40
+ def _gold_desc(idx: int) -> str:
41
+ if idx == -1:
42
+ return "the solution is fully correct (no erroneous paragraph)."
43
+ return f"the FIRST error occurs in paragraph {idx}."
44
+
45
+
46
+ def _strip_trailing_box(text: str) -> str:
47
+ return re.sub(r"\\boxed\{[^{}]*\}\s*$", "", text.strip()).strip()
48
+
49
+
50
+ def synthesize_verifier_completion(
51
+ teacher, rec: PRM800KRecord, *, temperature: float = 0.4, max_tokens: int = 1536
52
+ ) -> str:
53
+ """Teacher critique + authoritative boxed label."""
54
+ synth_prompt = _SYNTH_INSTRUCTION.format(
55
+ prompt=build_verifier_prompt(rec.problem, rec.steps),
56
+ gold_desc=_gold_desc(rec.first_error_index),
57
+ )
58
+ messages = [
59
+ {"role": "system", "content": VERIFIER_SYSTEM},
60
+ {"role": "user", "content": synth_prompt},
61
+ ]
62
+ critique = teacher.complete(messages, temperature=temperature, max_tokens=max_tokens)
63
+ critique = _strip_trailing_box(critique)
64
+ return f"{critique}\n\n\\boxed{{{rec.first_error_index}}}"
65
+
66
+
67
+ def make_verifier_row(rec: PRM800KRecord, completion: str) -> dict:
68
+ """Conversational prompt-completion row (triggers TRL completion_only_loss)."""
69
+ prompt_msgs = verifier_messages(rec.problem, rec.steps) # system + user, no gold
70
+ return {
71
+ "prompt": prompt_msgs,
72
+ "completion": [{"role": "assistant", "content": completion}],
73
+ "first_error_index": rec.first_error_index, # kept for inspection/stats
74
+ }
75
+
76
+
77
+ def generate_verifier_dataset(
78
+ records: Iterable[PRM800KRecord],
79
+ teacher,
80
+ banned: Optional[set[str]] = None,
81
+ *,
82
+ temperature: float = 0.4,
83
+ skip_on_error: bool = True,
84
+ ) -> Iterator[dict]:
85
+ """Yield training rows; drops contaminated problems and (optionally) teacher
86
+ failures."""
87
+ for rec in records:
88
+ if banned and is_contaminated(rec.problem, banned):
89
+ continue
90
+ try:
91
+ completion = synthesize_verifier_completion(teacher, rec, temperature=temperature)
92
+ except Exception:
93
+ if skip_on_error:
94
+ continue
95
+ raise
96
+ yield make_verifier_row(rec, completion)
src/mathcompose/datagen/prm800k_loader.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Parse PRM800K (OpenAI "Let's Verify Step by Step", MIT license) into
2
+ first-error-localization records.
3
+
4
+ PRM800K rows (newline-delimited JSON) look like::
5
+
6
+ {
7
+ "question": {"problem": "...", "ground_truth_answer": "..."},
8
+ "label": {
9
+ "steps": [
10
+ {"completions": [{"text": "...", "rating": 1}], "chosen_completion": 0,
11
+ "human_completion": null},
12
+ ...
13
+ ],
14
+ "finish_reason": "solution" | "found_error" | "give_up"
15
+ }
16
+ }
17
+
18
+ We reconstruct the solution as the sequence of *chosen* step texts and set
19
+ ``first_error_index`` = index of the first step whose chosen completion is rated
20
+ ``-1`` (or ``-1`` if the whole solution is correct). PRM800K phase-2 stops
21
+ labeling after the first mistake, so the erroneous step is the last one we keep.
22
+
23
+ The parser accepts either a path to a ``.jsonl`` file or an iterable of dict
24
+ rows (handy for tests / fixtures).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Iterable, Iterator, Optional, Union
32
+
33
+
34
+ @dataclass
35
+ class PRM800KRecord:
36
+ problem: str
37
+ steps: list[str]
38
+ first_error_index: int # -1 == all correct
39
+ ground_truth_answer: Optional[str] = None
40
+
41
+
42
+ def _chosen_text_and_rating(step: dict) -> tuple[Optional[str], Optional[int]]:
43
+ """Return (text, rating) for the human-selected completion of a step."""
44
+ human = step.get("human_completion")
45
+ completions = step.get("completions") or []
46
+ ci = step.get("chosen_completion")
47
+
48
+ if ci is not None and 0 <= ci < len(completions):
49
+ c = completions[ci]
50
+ return c.get("text"), c.get("rating")
51
+ if human: # human wrote a correct step
52
+ text = human if isinstance(human, str) else human.get("text")
53
+ return text, 1
54
+ if completions: # fall back to first completion
55
+ c = completions[0]
56
+ return c.get("text"), c.get("rating")
57
+ return None, None
58
+
59
+
60
+ def parse_prm800k_row(row: dict) -> Optional[PRM800KRecord]:
61
+ """Convert one PRM800K row into a record, or None if it can't be used."""
62
+ q = row.get("question") or {}
63
+ problem = q.get("problem")
64
+ label = row.get("label") or {}
65
+ raw_steps = label.get("steps") or []
66
+ if not problem or not raw_steps:
67
+ return None
68
+
69
+ steps: list[str] = []
70
+ first_error = -1
71
+ for i, step in enumerate(raw_steps):
72
+ text, rating = _chosen_text_and_rating(step)
73
+ if text is None:
74
+ break
75
+ steps.append(text.strip())
76
+ if rating is not None and rating < 0:
77
+ first_error = i
78
+ break # phase-2: labeling stops at the first mistake
79
+
80
+ if not steps:
81
+ return None
82
+ return PRM800KRecord(
83
+ problem=problem.strip(),
84
+ steps=steps,
85
+ first_error_index=first_error,
86
+ ground_truth_answer=q.get("ground_truth_answer"),
87
+ )
88
+
89
+
90
+ def iter_prm800k(
91
+ source: Union[str, Path, Iterable[dict]],
92
+ limit: Optional[int] = None,
93
+ ) -> Iterator[PRM800KRecord]:
94
+ """Yield PRM800KRecords from a .jsonl path or an iterable of dict rows."""
95
+ def _rows() -> Iterator[dict]:
96
+ if isinstance(source, (str, Path)):
97
+ with open(source) as f:
98
+ for line in f:
99
+ line = line.strip()
100
+ if line:
101
+ yield json.loads(line)
102
+ else:
103
+ yield from source
104
+
105
+ n = 0
106
+ for row in _rows():
107
+ rec = parse_prm800k_row(row)
108
+ if rec is None:
109
+ continue
110
+ yield rec
111
+ n += 1
112
+ if limit is not None and n >= limit:
113
+ return
src/mathcompose/eval/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .parse import (
2
+ first_error_index,
3
+ majority_index,
4
+ p_correct_from_samples,
5
+ )
6
+
7
+ __all__ = [
8
+ "first_error_index",
9
+ "majority_index",
10
+ "p_correct_from_samples",
11
+ ]
src/mathcompose/eval/compose.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Composition eval: does Model V recover Model G's headroom?
2
+
3
+ For each problem we sample n solutions from G and compare selection strategies:
4
+ pass@1 greedy/first sample (floor)
5
+ maj@n plain majority vote over final answers (baseline)
6
+ oracle pass@n any sample correct (ceiling)
7
+ weighted_maj majority weighted by V's p_correct (V result)
8
+ best_of_n single highest-V-scored sample (V result)
9
+
10
+ generator-verifier gap = oracle - maj@n ; V's value = how much of that gap the
11
+ weighted vote recovers. This reproduces the Weaver headroom result at <=4B.
12
+
13
+ Pure w.r.t. two callbacks (solve_fn, v_score_fn) so it is unit-testable with
14
+ fakes; the real callbacks come from eval/runners.py.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from collections import defaultdict
19
+ from typing import Callable, Optional, Sequence
20
+
21
+ from ..common.chat import split_into_steps
22
+ from ..common.math_grade import extract_last_boxed, grade_answer, normalize_final_answer
23
+ from .parse import p_correct_from_samples
24
+
25
+ SolveFn = Callable[[str], list[str]] # problem -> n solution strings
26
+ VScoreFn = Callable[[str, list[str]], float] # (problem, solution_steps) -> p_correct in [0,1]
27
+
28
+ _NONE_KEY = "__no_answer__"
29
+
30
+
31
+ def _weighted_vote(answers: Sequence[Optional[str]], weights: Sequence[float],
32
+ correct: Sequence[bool]) -> bool:
33
+ """Return whether the max-weight answer bucket is correct."""
34
+ buckets: dict[str, dict] = defaultdict(lambda: {"w": 0.0, "correct": False})
35
+ for ans, w, ok in zip(answers, weights, correct):
36
+ key = normalize_final_answer(ans) if ans else _NONE_KEY
37
+ b = buckets[key]
38
+ b["w"] += w
39
+ b["correct"] = b["correct"] or ok
40
+ if not buckets:
41
+ return False
42
+ winner = max(buckets.values(), key=lambda b: b["w"])
43
+ return bool(winner["correct"])
44
+
45
+
46
+ def evaluate_composition(
47
+ problems: Sequence[tuple[str, str]], # (problem, gold_answer)
48
+ solve_fn: SolveFn,
49
+ v_score_fn: VScoreFn,
50
+ progress: bool = True,
51
+ ) -> dict:
52
+ try:
53
+ from tqdm.auto import tqdm
54
+ except Exception: # pragma: no cover
55
+ def tqdm(x, **k):
56
+ return x
57
+
58
+ acc = {k: 0 for k in ["pass@1", "maj@n", "oracle", "weighted_maj", "best_of_n"]}
59
+ n_total = 0
60
+
61
+ it = tqdm(problems, desc="compose") if progress else problems
62
+ for problem, gold in it:
63
+ samples = solve_fn(problem)
64
+ if not samples:
65
+ n_total += 1
66
+ continue
67
+ answers = [extract_last_boxed(s) for s in samples]
68
+ correct = [grade_answer(a, gold) for a in answers]
69
+ v_scores = [v_score_fn(problem, split_into_steps(s)) for s in samples]
70
+
71
+ n_total += 1
72
+ acc["pass@1"] += 1 if correct[0] else 0
73
+ acc["oracle"] += 1 if any(correct) else 0
74
+ acc["maj@n"] += 1 if _weighted_vote(answers, [1.0] * len(samples), correct) else 0
75
+ acc["weighted_maj"] += 1 if _weighted_vote(answers, v_scores, correct) else 0
76
+ # best-of-n: single highest-V sample
77
+ best_i = max(range(len(samples)), key=lambda i: v_scores[i])
78
+ acc["best_of_n"] += 1 if correct[best_i] else 0
79
+
80
+ out = {k: (v / n_total if n_total else 0.0) for k, v in acc.items()}
81
+ out["n"] = n_total
82
+ gap = out["oracle"] - out["maj@n"]
83
+ out["generator_verifier_gap"] = gap
84
+ out["gap_recovered_by_V"] = (
85
+ (out["weighted_maj"] - out["maj@n"]) / gap if gap > 1e-9 else None
86
+ )
87
+ return out
88
+
89
+
90
+ def make_v_scorer(runner, k: int = 4, temperature: float = 0.6,
91
+ max_new_tokens: int = 1024) -> VScoreFn:
92
+ """Build a V-score callback from an HFRunner: sample k critiques per solution
93
+ and return the fraction that judged it fully correct."""
94
+ from ..common.chat import verifier_messages
95
+
96
+ def v_score_fn(problem: str, solution_steps: list[str]) -> float:
97
+ samples = runner.chat(verifier_messages(problem, solution_steps), n=k,
98
+ temperature=temperature, max_new_tokens=max_new_tokens)
99
+ return p_correct_from_samples(samples)
100
+
101
+ return v_score_fn
src/mathcompose/eval/math500.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Model G answer-accuracy eval (objective: boxed answer vs gold, no judge).
2
+
3
+ Works for MATH-500 (fields problem/answer) and AIME_2024 (fields Problem/Answer)
4
+ via configurable field names. Reports pass@1 (first sample) and pass@k
5
+ (any-of-k correct).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Callable, Optional, Sequence
10
+
11
+ from ..common.math_grade import extract_last_boxed, grade_answer
12
+
13
+ # solve_fn(problem) -> list of k solution strings
14
+ SolveFn = Callable[[str], list[str]]
15
+
16
+
17
+ def _any_correct(samples: Sequence[str], gold: str) -> list[bool]:
18
+ return [grade_answer(extract_last_boxed(s), gold) for s in samples]
19
+
20
+
21
+ def evaluate_generator(
22
+ solve_fn: SolveFn,
23
+ dataset_id: str = "HuggingFaceH4/MATH-500",
24
+ split: str = "test",
25
+ problem_field: str = "problem",
26
+ answer_field: str = "answer",
27
+ limit: Optional[int] = None,
28
+ progress: bool = True,
29
+ ) -> dict:
30
+ from datasets import load_dataset
31
+
32
+ try:
33
+ from tqdm.auto import tqdm
34
+ except Exception: # pragma: no cover
35
+ def tqdm(x, **k):
36
+ return x
37
+
38
+ ds = load_dataset(dataset_id, split=split)
39
+ if limit is not None:
40
+ ds = ds.select(range(min(limit, len(ds))))
41
+
42
+ n_total = 0
43
+ n_pass1 = 0
44
+ n_passk = 0
45
+ it = tqdm(ds, desc=f"MATH/{dataset_id.split('/')[-1]}") if progress else ds
46
+ for row in it:
47
+ gold = str(row[answer_field])
48
+ samples = solve_fn(row[problem_field])
49
+ if not samples:
50
+ n_total += 1
51
+ continue
52
+ flags = _any_correct(samples, gold)
53
+ n_total += 1
54
+ n_pass1 += 1 if flags[0] else 0
55
+ n_passk += 1 if any(flags) else 0
56
+
57
+ return {
58
+ "dataset": dataset_id,
59
+ "n": n_total,
60
+ "pass@1": n_pass1 / n_total if n_total else 0.0,
61
+ "pass@k": n_passk / n_total if n_total else 0.0,
62
+ }
src/mathcompose/eval/parse.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Eval-facing helpers over Model V's output — used by ProcessBench scoring
2
+ (single output or Maj@k) and by the composition reranker.
3
+
4
+ A parse failure maps to the sentinel ``-2`` so it never accidentally equals a
5
+ gold label (which is >= -1); i.e. an unparseable V output is scored as wrong.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+
11
+ from ..data.schema import parse_verifier_output
12
+
13
+ PARSE_FAIL = -2
14
+
15
+
16
+ def first_error_index(text: str) -> int:
17
+ """0-based first-error index (or -1 all-correct; PARSE_FAIL if unparseable)."""
18
+ return parse_verifier_output(text).first_error_index
19
+
20
+
21
+ def majority_index(samples: list[str]) -> int:
22
+ """Maj@k over the first-error index across k sampled V outputs.
23
+
24
+ Parse failures are dropped before voting; if every sample failed, returns
25
+ PARSE_FAIL. Ties broken by the smallest (earliest) index — the conservative
26
+ choice for *first*-error localization.
27
+ """
28
+ idxs = [first_error_index(s) for s in samples]
29
+ idxs = [i for i in idxs if i != PARSE_FAIL]
30
+ if not idxs:
31
+ return PARSE_FAIL
32
+ counts = Counter(idxs)
33
+ top = max(counts.values())
34
+ winners = [i for i, c in counts.items() if c == top]
35
+ return min(winners)
36
+
37
+
38
+ def p_correct_from_samples(samples: list[str]) -> float:
39
+ """Solution-level correctness probability for reranking Model G:
40
+ fraction of k verifier samples that judged the whole solution correct
41
+ (first_error_index == -1). Parse failures count as 'not correct'.
42
+ """
43
+ if not samples:
44
+ return 0.0
45
+ good = sum(1 for s in samples if first_error_index(s) == -1)
46
+ return good / len(samples)
src/mathcompose/eval/prmbench.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""PRMBench robustness eval (hitsmy/PRMBench_Preview, apache-2.0).
2
+
3
+ Each row pairs a correct ``original_process`` with a ``modified_process`` that has
4
+ injected errors at ``error_steps`` (1-based), tagged with a ``classification``
5
+ category (confidence, redundancy, ...). This stresses whether V gets fooled into
6
+ accepting subtly-wrong reasoning.
7
+
8
+ We report a simplified but faithful robustness picture (the official ``mr_eval``
9
+ toolkit computes finer per-category scores):
10
+ detection — fraction of MODIFIED processes where V flags any error (index != -1)
11
+ localization— fraction where V's flagged index hits a gold error step
12
+ false_pos — fraction of ORIGINAL (correct) processes V wrongly flags (index != -1)
13
+ per_category detection breakdown.
14
+
15
+ Uses the same ``verify_fn`` as ProcessBench, so V is evaluated identically.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+ from typing import Callable, Optional
21
+
22
+ from .parse import majority_index
23
+
24
+ VerifyFn = Callable[[str, list[str]], list[str]]
25
+
26
+
27
+ def evaluate_prmbench(
28
+ verify_fn: VerifyFn,
29
+ dataset_id: str = "hitsmy/PRMBench_Preview",
30
+ split: str = "train",
31
+ limit: Optional[int] = None,
32
+ error_steps_base: int = 1, # PRMBench error_steps are 1-based
33
+ include_original: bool = True, # also run the false-positive pass
34
+ progress: bool = True,
35
+ ) -> dict:
36
+ from datasets import load_dataset
37
+
38
+ try:
39
+ from tqdm.auto import tqdm
40
+ except Exception: # pragma: no cover
41
+ def tqdm(x, **k):
42
+ return x
43
+
44
+ ds = load_dataset(dataset_id, split=split)
45
+ if limit is not None:
46
+ ds = ds.select(range(min(limit, len(ds))))
47
+
48
+ n_mod = detected = localized = 0
49
+ n_orig = false_pos = 0
50
+ per_cat: dict[str, dict] = defaultdict(lambda: {"n": 0, "detected": 0})
51
+
52
+ it = tqdm(ds, desc="PRMBench") if progress else ds
53
+ for row in it:
54
+ q_mod = row.get("modified_question") or row.get("question") or ""
55
+ steps_mod = list(row.get("modified_process") or [])
56
+ gold0 = {int(e) - error_steps_base for e in (row.get("error_steps") or [])}
57
+ cat = row.get("classification") or "unknown"
58
+ if steps_mod and gold0:
59
+ pred = majority_index(verify_fn(q_mod, steps_mod))
60
+ n_mod += 1
61
+ is_detected = pred != -1
62
+ detected += 1 if is_detected else 0
63
+ localized += 1 if pred in gold0 else 0
64
+ per_cat[cat]["n"] += 1
65
+ per_cat[cat]["detected"] += 1 if is_detected else 0
66
+
67
+ if include_original:
68
+ q_orig = row.get("original_question") or row.get("question") or ""
69
+ steps_orig = list(row.get("original_process") or [])
70
+ if steps_orig:
71
+ pred_o = majority_index(verify_fn(q_orig, steps_orig))
72
+ n_orig += 1
73
+ false_pos += 1 if pred_o != -1 else 0
74
+
75
+ return {
76
+ "dataset": dataset_id,
77
+ "n_modified": n_mod,
78
+ "detection": detected / n_mod if n_mod else 0.0,
79
+ "localization": localized / n_mod if n_mod else 0.0,
80
+ "false_positive_rate": (false_pos / n_orig) if n_orig else None,
81
+ "per_category_detection": {
82
+ c: {"n": d["n"], "detection": d["detected"] / d["n"] if d["n"] else 0.0}
83
+ for c, d in sorted(per_cat.items())
84
+ },
85
+ }
src/mathcompose/eval/processbench.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""ProcessBench evaluation — the core objective metric for Model V.
2
+
3
+ Dataset: ``Qwen/ProcessBench`` (apache-2.0), 4 splits (gsm8k 400, math 1000,
4
+ olympiadbench 1000, omnimath 1000). Each row: ``problem`` (str),
5
+ ``steps`` (list[str]), ``label`` (int; 0-based index of first erroneous step,
6
+ ``-1`` == all correct), ``final_answer_correct`` (bool).
7
+
8
+ Official metric (per subset, then averaged across the 4 subsets):
9
+ acc_error = accuracy on erroneous samples (pred index == gold index)
10
+ acc_correct = accuracy on correct samples (pred index == -1)
11
+ F1 = harmonic_mean(acc_error, acc_correct)
12
+
13
+ The scoring functions here are pure (no model, no network) so they can be unit
14
+ tested. ``evaluate`` takes a ``verify_fn`` callback that returns k raw V outputs
15
+ for one (problem, steps); Maj@k reduction happens via ``majority_index``.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, asdict
20
+ from typing import Callable, Optional, Sequence
21
+
22
+ from .parse import majority_index
23
+
24
+ DEFAULT_SPLITS = ("gsm8k", "math", "olympiadbench", "omnimath")
25
+
26
+ # verify_fn(problem, steps) -> list of k raw V-output strings
27
+ VerifyFn = Callable[[str, list[str]], list[str]]
28
+
29
+
30
+ def harmonic_mean(a: float, b: float) -> float:
31
+ if a + b == 0:
32
+ return 0.0
33
+ return 2 * a * b / (a + b)
34
+
35
+
36
+ @dataclass
37
+ class SubsetScore:
38
+ subset: str
39
+ n_error: int
40
+ n_correct: int
41
+ acc_error: float
42
+ acc_correct: float
43
+ f1: float
44
+
45
+ def as_dict(self) -> dict:
46
+ return asdict(self)
47
+
48
+
49
+ def score_subset(
50
+ subset: str,
51
+ gold_labels: Sequence[int],
52
+ pred_indices: Sequence[int],
53
+ ) -> SubsetScore:
54
+ """Compute acc_error / acc_correct / harmonic-mean F1 for one subset."""
55
+ assert len(gold_labels) == len(pred_indices)
56
+ n_error = n_correct = 0
57
+ hit_error = hit_correct = 0
58
+ for gold, pred in zip(gold_labels, pred_indices):
59
+ if gold == -1:
60
+ n_correct += 1
61
+ if pred == -1:
62
+ hit_correct += 1
63
+ else:
64
+ n_error += 1
65
+ if pred == gold:
66
+ hit_error += 1
67
+ acc_error = hit_error / n_error if n_error else 0.0
68
+ acc_correct = hit_correct / n_correct if n_correct else 0.0
69
+ return SubsetScore(
70
+ subset=subset,
71
+ n_error=n_error,
72
+ n_correct=n_correct,
73
+ acc_error=acc_error,
74
+ acc_correct=acc_correct,
75
+ f1=harmonic_mean(acc_error, acc_correct),
76
+ )
77
+
78
+
79
+ def evaluate(
80
+ verify_fn: VerifyFn,
81
+ splits: Sequence[str] = DEFAULT_SPLITS,
82
+ limit: Optional[int] = None,
83
+ dataset_id: str = "Qwen/ProcessBench",
84
+ progress: bool = True,
85
+ ) -> dict:
86
+ """Run V over ProcessBench and return per-subset scores + the averaged F1.
87
+
88
+ ``limit`` caps examples per split (use a small value for CPU smoke tests).
89
+ """
90
+ from datasets import load_dataset
91
+
92
+ try:
93
+ from tqdm.auto import tqdm
94
+ except Exception: # pragma: no cover
95
+ def tqdm(x, **k):
96
+ return x
97
+
98
+ per_subset: list[SubsetScore] = []
99
+ for split in splits:
100
+ ds = load_dataset(dataset_id, split=split)
101
+ if limit is not None:
102
+ ds = ds.select(range(min(limit, len(ds))))
103
+ golds, preds = [], []
104
+ it = tqdm(ds, desc=f"ProcessBench/{split}") if progress else ds
105
+ for row in it:
106
+ samples = verify_fn(row["problem"], list(row["steps"]))
107
+ preds.append(majority_index(samples))
108
+ golds.append(int(row["label"]))
109
+ per_subset.append(score_subset(split, golds, preds))
110
+
111
+ avg_f1 = sum(s.f1 for s in per_subset) / len(per_subset) if per_subset else 0.0
112
+ return {
113
+ "subsets": {s.subset: s.as_dict() for s in per_subset},
114
+ "average_f1": avg_f1,
115
+ "gpt4o_reference_f1": 61.9,
116
+ }
src/mathcompose/eval/run.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""One CLI to run every eval against a base or tuned model and write results.
2
+
3
+ # Verifier on ProcessBench (base then tuned -> comparison table):
4
+ python -m mathcompose.eval.run processbench --adapter runs/verifier_v --maj-k 8
5
+ python -m mathcompose.eval.run processbench --tag base # no adapter
6
+
7
+ python -m mathcompose.eval.run prmbench --adapter runs/verifier_v --limit 300
8
+ python -m mathcompose.eval.run math --adapter runs/generator_g --dataset HuggingFaceH4/MATH-500
9
+ python -m mathcompose.eval.run compose --gen-adapter runs/generator_g --ver-adapter runs/verifier_v
10
+
11
+ Each subcommand writes results/<name>_<tag>.json. `report` collates them into a
12
+ markdown table (results/RESULTS.md).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ from pathlib import Path
19
+
20
+ RESULTS_DIR = Path("results")
21
+
22
+
23
+ def _save(name: str, tag: str, payload: dict) -> None:
24
+ RESULTS_DIR.mkdir(parents=True, exist_ok=True)
25
+ path = RESULTS_DIR / f"{name}_{tag}.json"
26
+ path.write_text(json.dumps(payload, indent=2))
27
+ print(f"\nwrote {path}")
28
+ print(json.dumps(payload, indent=2))
29
+
30
+
31
+ def _runner(base_id, adapter):
32
+ from .runners import HFRunner
33
+ return HFRunner(base_id, adapter_path=adapter)
34
+
35
+
36
+ def cmd_processbench(a):
37
+ from .processbench import evaluate
38
+ from .runners import make_verify_fn
39
+ r = _runner(a.base_id, a.adapter)
40
+ verify_fn = make_verify_fn(r, n=a.maj_k, temperature=(0.6 if a.maj_k > 1 else 0.0))
41
+ res = evaluate(verify_fn, splits=tuple(a.splits), limit=a.limit)
42
+ _save("processbench", a.tag, res)
43
+
44
+
45
+ def cmd_prmbench(a):
46
+ from .prmbench import evaluate_prmbench
47
+ from .runners import make_verify_fn
48
+ r = _runner(a.base_id, a.adapter)
49
+ verify_fn = make_verify_fn(r, n=a.maj_k, temperature=(0.6 if a.maj_k > 1 else 0.0))
50
+ res = evaluate_prmbench(verify_fn, limit=a.limit)
51
+ _save("prmbench", a.tag, res)
52
+
53
+
54
+ def cmd_math(a):
55
+ from .math500 import evaluate_generator
56
+ from .runners import make_solve_fn
57
+ r = _runner(a.base_id, a.adapter)
58
+ solve_fn = make_solve_fn(r, n=a.k, temperature=(0.8 if a.k > 1 else 0.0))
59
+ res = evaluate_generator(solve_fn, dataset_id=a.dataset, split=a.split,
60
+ problem_field=a.problem_field, answer_field=a.answer_field,
61
+ limit=a.limit)
62
+ _save("math", a.tag, res)
63
+
64
+
65
+ def cmd_compose(a):
66
+ from datasets import load_dataset
67
+ from .compose import evaluate_composition, make_v_scorer
68
+ from .runners import HFRunner, make_solve_fn
69
+
70
+ gen = HFRunner(a.base_id, adapter_path=a.gen_adapter)
71
+ ver = HFRunner(a.base_id, adapter_path=a.ver_adapter)
72
+ solve_fn = make_solve_fn(gen, n=a.n, temperature=a.temperature)
73
+ v_score_fn = make_v_scorer(ver, k=a.ver_k)
74
+
75
+ ds = load_dataset(a.dataset, split=a.split)
76
+ if a.limit:
77
+ ds = ds.select(range(min(a.limit, len(ds))))
78
+ problems = [(row[a.problem_field], str(row[a.answer_field])) for row in ds]
79
+ res = evaluate_composition(problems, solve_fn, v_score_fn)
80
+ _save("compose", a.tag, res)
81
+
82
+
83
+ def cmd_report(a):
84
+ rows = []
85
+ for p in sorted(RESULTS_DIR.glob("*.json")):
86
+ rows.append((p.stem, json.loads(p.read_text())))
87
+ lines = ["# Results\n"]
88
+ for name, data in rows:
89
+ lines.append(f"## {name}\n")
90
+ lines.append("```json")
91
+ lines.append(json.dumps(data, indent=2))
92
+ lines.append("```\n")
93
+ out = RESULTS_DIR / "RESULTS.md"
94
+ out.write_text("\n".join(lines))
95
+ print(f"wrote {out}")
96
+
97
+
98
+ def main() -> None:
99
+ ap = argparse.ArgumentParser(description=__doc__)
100
+ ap.add_argument("--base-id", default="Qwen/Qwen2.5-Math-1.5B-Instruct")
101
+ sub = ap.add_subparsers(dest="cmd", required=True)
102
+
103
+ p = sub.add_parser("processbench")
104
+ p.add_argument("--adapter", default=None)
105
+ p.add_argument("--tag", default="tuned")
106
+ p.add_argument("--maj-k", type=int, default=1)
107
+ p.add_argument("--limit", type=int, default=None)
108
+ p.add_argument("--splits", nargs="+", default=["gsm8k", "math", "olympiadbench", "omnimath"])
109
+ p.set_defaults(func=cmd_processbench)
110
+
111
+ p = sub.add_parser("prmbench")
112
+ p.add_argument("--adapter", default=None)
113
+ p.add_argument("--tag", default="tuned")
114
+ p.add_argument("--maj-k", type=int, default=1)
115
+ p.add_argument("--limit", type=int, default=None)
116
+ p.set_defaults(func=cmd_prmbench)
117
+
118
+ p = sub.add_parser("math")
119
+ p.add_argument("--adapter", default=None)
120
+ p.add_argument("--tag", default="tuned")
121
+ p.add_argument("--dataset", default="HuggingFaceH4/MATH-500")
122
+ p.add_argument("--split", default="test")
123
+ p.add_argument("--problem-field", default="problem")
124
+ p.add_argument("--answer-field", default="answer")
125
+ p.add_argument("--k", type=int, default=1)
126
+ p.add_argument("--limit", type=int, default=None)
127
+ p.set_defaults(func=cmd_math)
128
+
129
+ p = sub.add_parser("compose")
130
+ p.add_argument("--gen-adapter", default=None)
131
+ p.add_argument("--ver-adapter", default=None)
132
+ p.add_argument("--tag", default="compose")
133
+ p.add_argument("--dataset", default="HuggingFaceH4/MATH-500")
134
+ p.add_argument("--split", default="test")
135
+ p.add_argument("--problem-field", default="problem")
136
+ p.add_argument("--answer-field", default="answer")
137
+ p.add_argument("--n", type=int, default=8)
138
+ p.add_argument("--ver-k", type=int, default=4)
139
+ p.add_argument("--temperature", type=float, default=0.8)
140
+ p.add_argument("--limit", type=int, default=None)
141
+ p.set_defaults(func=cmd_compose)
142
+
143
+ p = sub.add_parser("report")
144
+ p.set_defaults(func=cmd_report)
145
+
146
+ a = ap.parse_args()
147
+ a.func(a)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
src/mathcompose/eval/runners.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""HF generation wrapper + factory closures used by every eval.
2
+
3
+ ``HFRunner`` loads a base model (optionally with a LoRA adapter merged in),
4
+ applies the chat template, and samples n completions. It imports torch /
5
+ transformers lazily so the rest of the package stays importable on machines
6
+ without them.
7
+
8
+ Factories:
9
+ make_verify_fn(runner, n) -> verify_fn(problem, steps) -> list[str] (Model V)
10
+ make_solve_fn(runner, n) -> solve_fn(problem) -> list[str] (Model G)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Callable, Optional
15
+
16
+ from ..common.chat import verifier_messages, generator_messages
17
+
18
+
19
+ class HFRunner:
20
+ def __init__(
21
+ self,
22
+ base_id: str,
23
+ adapter_path: Optional[str] = None,
24
+ device: Optional[str] = None,
25
+ dtype: str = "auto",
26
+ max_context: int = 4096,
27
+ pad_token: str = "<|fim_pad|>",
28
+ ):
29
+ import torch
30
+ from transformers import AutoModelForCausalLM
31
+
32
+ from ..common.chat import load_tokenizer
33
+
34
+ self.tokenizer = load_tokenizer(base_id, pad_token=pad_token)
35
+ self.max_context = max_context
36
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
37
+
38
+ torch_dtype = {"auto": "auto", "bf16": torch.bfloat16, "fp16": torch.float16,
39
+ "fp32": torch.float32}.get(dtype, "auto")
40
+ # transformers 5.x: `dtype=` (torch_dtype is deprecated)
41
+ model = AutoModelForCausalLM.from_pretrained(base_id, dtype=torch_dtype)
42
+ if adapter_path:
43
+ from peft import PeftModel
44
+
45
+ model = PeftModel.from_pretrained(model, adapter_path)
46
+ self.model = model.to(self.device).eval()
47
+
48
+ def chat(
49
+ self,
50
+ messages: list[dict],
51
+ n: int = 1,
52
+ temperature: float = 0.0,
53
+ max_new_tokens: int = 1024,
54
+ repetition_penalty: float = 1.1,
55
+ ) -> list[str]:
56
+ """Return n decoded completions (generated text only)."""
57
+ import torch
58
+
59
+ # transformers 5.x returns a BatchEncoding (dict) here, not a bare tensor.
60
+ enc = self.tokenizer.apply_chat_template(
61
+ messages, add_generation_prompt=True, return_tensors="pt", return_dict=True,
62
+ )
63
+ input_ids = enc["input_ids"]
64
+ attn = enc.get("attention_mask")
65
+
66
+ # keep room for generation under the hard context ceiling
67
+ max_prompt = max(1, self.max_context - max_new_tokens)
68
+ if input_ids.shape[-1] > max_prompt:
69
+ input_ids = input_ids[:, -max_prompt:]
70
+ if attn is not None:
71
+ attn = attn[:, -max_prompt:]
72
+ input_ids = input_ids.to(self.device)
73
+ attn = attn.to(self.device) if attn is not None else None
74
+
75
+ do_sample = bool(temperature and temperature > 0)
76
+ gen_kwargs = dict(
77
+ max_new_tokens=max_new_tokens,
78
+ do_sample=do_sample,
79
+ num_return_sequences=n,
80
+ pad_token_id=self.tokenizer.pad_token_id,
81
+ repetition_penalty=repetition_penalty,
82
+ )
83
+ if do_sample:
84
+ gen_kwargs.update(temperature=temperature, top_p=0.95)
85
+
86
+ with torch.no_grad():
87
+ out = self.model.generate(input_ids, attention_mask=attn, **gen_kwargs)
88
+ gen = out[:, input_ids.shape[-1]:]
89
+ return self.tokenizer.batch_decode(gen, skip_special_tokens=True)
90
+
91
+
92
+ def make_verify_fn(runner: HFRunner, n: int = 1, temperature: float = 0.0,
93
+ max_new_tokens: int = 1024) -> Callable[[str, list[str]], list[str]]:
94
+ def verify_fn(problem: str, steps: list[str]) -> list[str]:
95
+ return runner.chat(verifier_messages(problem, steps), n=n,
96
+ temperature=temperature, max_new_tokens=max_new_tokens)
97
+ return verify_fn
98
+
99
+
100
+ def make_solve_fn(runner: HFRunner, n: int = 1, temperature: float = 0.0,
101
+ max_new_tokens: int = 1024) -> Callable[[str], list[str]]:
102
+ def solve_fn(problem: str) -> list[str]:
103
+ return runner.chat(generator_messages(problem), n=n,
104
+ temperature=temperature, max_new_tokens=max_new_tokens)
105
+ return solve_fn
src/mathcompose/infer/__init__.py ADDED
File without changes
src/mathcompose/infer/demo.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Inference demo for Model V and Model G, with a base-vs-tuned side-by-side
2
+ mode for the demo video.
3
+
4
+ # Verify a solution with the tuned verifier:
5
+ python -m mathcompose.infer.demo --task v --adapter runs/verifier_v \
6
+ --problem "Compute 1+1." --steps "We add." "1+1=3."
7
+
8
+ # Solve a problem:
9
+ python -m mathcompose.infer.demo --task g --adapter runs/generator_g \
10
+ --problem "What is 12*11?"
11
+
12
+ # Base vs tuned side by side (the money shot for the video):
13
+ python -m mathcompose.infer.demo --task v --adapter runs/verifier_v --compare \
14
+ --problem "..." --steps "..." "..."
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+
20
+ from ..common.chat import verifier_messages, generator_messages
21
+ from ..data.schema import parse_verifier_output
22
+
23
+
24
+ def run_verifier(runner, problem: str, steps: list[str], temperature: float = 0.0) -> str:
25
+ out = runner.chat(verifier_messages(problem, steps), n=1, temperature=temperature,
26
+ max_new_tokens=1024)[0]
27
+ parsed = parse_verifier_output(out)
28
+ verdict = ("all steps correct" if parsed.first_error_index == -1
29
+ else f"first error at step {parsed.first_error_index}")
30
+ return f"[V verdict: {verdict}]\n{out}"
31
+
32
+
33
+ def run_generator(runner, problem: str, temperature: float = 0.0) -> str:
34
+ return runner.chat(generator_messages(problem), n=1, temperature=temperature,
35
+ max_new_tokens=1024)[0]
36
+
37
+
38
+ def main() -> None:
39
+ ap = argparse.ArgumentParser(description=__doc__)
40
+ ap.add_argument("--task", choices=["v", "g"], required=True)
41
+ ap.add_argument("--base-id", default="Qwen/Qwen2.5-Math-1.5B-Instruct")
42
+ ap.add_argument("--adapter", default=None, help="path/hub id of the tuned adapter")
43
+ ap.add_argument("--problem", required=True)
44
+ ap.add_argument("--steps", nargs="*", default=None, help="solution steps (task v)")
45
+ ap.add_argument("--temperature", type=float, default=0.0)
46
+ ap.add_argument("--compare", action="store_true", help="show base vs tuned")
47
+ args = ap.parse_args()
48
+
49
+ from ..eval.runners import HFRunner
50
+
51
+ def do(runner, tag):
52
+ print(f"\n===== {tag} =====")
53
+ if args.task == "v":
54
+ print(run_verifier(runner, args.problem, args.steps or [], args.temperature))
55
+ else:
56
+ print(run_generator(runner, args.problem, args.temperature))
57
+
58
+ if args.compare:
59
+ do(HFRunner(args.base_id, adapter_path=None), "BASE (untuned)")
60
+ if args.adapter:
61
+ do(HFRunner(args.base_id, adapter_path=args.adapter), "TUNED")
62
+ else:
63
+ do(HFRunner(args.base_id, adapter_path=args.adapter), "TUNED" if args.adapter else "BASE")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()