Word problems: exact core, step solver, world scripts, world reader, SVAMP / GSM8K
Crates: math (exact core, step solver mathsolve, world reader qread) and world (world scripts: format, checker, optional LLM writer). Both belong to the root Cargo workspace; pick one with -p or --manifest-path. math has three binaries (math, mathsolve, vmmtests) and no default-run, so --bin is required:
cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- <cmd> ...
cargo run --release --manifest-path math/Cargo.toml --bin math -- <cmd> ...
cargo run --release --manifest-path world/Cargo.toml -- <cmd> ...
0. Setup (read once)
Gist
Three things to know before running anything beyond calc and controls.
Steps
- Parser model.
mathsolve(steps,qread,qread-text) loads theenUD model shipped inen/models/en.bin(the path is fixed at build time). To use your own model, train it withen train(see retrain-the-parser.md) and replace that file, or useMATH_EN_MODELwith themathbinary. - Run directory. Every
mathsolvecommand creates its run directory at startup:MATH2_RUN, or math2-2026-09-26. Set it explicitly:export MATH2_RUN=runs/math2 - Dataset root.
mathsolve steps|qreadandworld quant-*read datasets from raw (MOVA_DATAdefaults todatain the current folder). Call that directory<DATA_ROOT>below. The expected files are listed in section 6. The world scripts come with the corpus:train/get-corpus.shputs them incorpus/math-scripts/.
Pitfalls
- If the model file is missing,
stepsandqreadfail at load time. This is not a data problem. mathsolve steps --set tinyalso reads a TinyGSM sample from tinygsm. It is optional; skip it unless you have that data.
1. Exact core
Gist
All arithmetic is exact:
- v1 (
math/src/rat.rs, i128) for units and temperatures. An overflow is an explicitOverflowerror, never a silent f64. - v2 (
math/src/big.rs) for word problems: arbitrary-size integers and rationals viadashu(Q = RBig, always reduced).
calc.rs evaluates expressions on v2. Every result gets an independent check: the same expression computed with different arithmetic (f64, or a fingerprint modulo 2^61−1 for huge integers). A failed check blocks the answer.
When to use
- Checking a number or formula.
- Debugging why a solver step produced a value.
- Running the gate's negative controls after touching the core.
Steps
# expression -> exact value + independent check
cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- calc "3^40 + 17" "(16 - 3 - 4) * 2"
# negative controls: each must be RED (caught); exits non-zero if any passes
cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- controls
# English question -> expression tree -> steps, checks, answer (units, %, stats, probability)
cargo run --release --manifest-path math/Cargo.toml --bin math -- eval "What is 15% of 80?"
# frozen suite: id <TAB> question <TAB> expected
cargo run --release --manifest-path math/Cargo.toml --bin math -- test math/data/suite-v1.tsv
Example
expr: 3^40+17
✓ independent path: fingerprint modulo 2^61−1 fingerprint 628450412988459063
answer: 12157665459056928818
controls ends with negative controls red: 7 of 7.
In math test, the "expected" column takes one of:
- a value with an optional unit (
12,≈ 22.2222 °C,50/9 Δ°C); NU(an honest "I don't understand");ERR:dim,ERR:temp,ERR:overfloworERR:div0.
The summary line # TOTAL <n> <hit> <honest NU> <wrong> gives the totals.
Pitfalls
- Round only on output. A non-terminating decimal prints as
≈ … (exactly a/b). - Temperature is affine: point − point = difference, and point + point is an error with an explanation.
- Don't edit
suite-v1.tsvto make it pass. A new suite version goes in a new file.
2. Step solver: mathsolve steps
Gist
A transition system reads the numbers in text order and, at each one, chooses SHIFT, SKIP or REDUCE (op + direction). The features come from the en UD tree (unit noun, verb class, subject, cue words, question words). The model is a structured perceptron with beam search and early update. The exact core computes every subtree.
With --exact, the gold trees come from SVAMP equations (( 290.0 / 2.0 )) or GSM8K calculator steps (<<48/2=24>>). Without it, the gold trees are guessed by weak supervision. Training happens in memory on every run; no model file is written.
When to use
- Training and evaluating the word-problem solver on SVAMP or GSM8K.
- Trying a feature or idea as a flag and measuring it against the baseline.
Steps
The flags come from the "steps" arm of math/src/bin/mathsolve.rs. Defaults are in brackets.
| flag | meaning |
|---|---|
--set gsm8k|svamp|mix|tiny [gsm8k] | svamp: train on SVAMP train, test on SVAMP test. gsm8k: train on GSM8K socratic train, test on its test split. mix: train on SVAMP + GSM8K, test on SVAMP. tiny: train on GSM8K + a TinyGSM sample (--tiny N [20000]), test on GSM8K |
--exact | exact gold trees from the annotation (recommended; needed by --world, --princ, --qread, --arith-feat) |
--train N [all] | cap on training items |
--epochs E [5] / --beam B [16] / --maxq Q [9] | perceptron epochs, beam width, max quantities per problem |
--rerank K [0] | train a judge over the top-K candidates. Its training candidates come from models trained without their own fold (4 folds) |
--world <scripts.jsonl> | train the world writer on verified world scripts (section 4) |
--world-feat | use the learned world's answer as a judge feature (with --world) |
--qread | use the world reader's answer as a judge feature. It only counts when every number was read (section 5) |
--princ <scripts.jsonl> | sentence-principle classifier from scripts (5-fold cross-check printed) |
--dag | v5 DAG model (trees with repeated numbers; text order not needed) |
--augment, --curriculum, --star N, --shapes, --arith-feat, --bag, --bag-judge, --rethink | experimental variants (data augmentation, curriculum, self-training rounds, shape library, ensembles, second pass with a wider beam) |
--show N, --explain N | print the first N test items with their steps / a full explanation |
--hints N | calls an LLM via a CLI for up to N disagreement cases, with a small spend cap. Optional; leave it off for local runs |
Environment variables:
MATH_NOLEX=1|2: non-lexical features (used by the benchmark command below).MATH_IGNORE=0..4: the "ignore extra numbers" trigger.MATH_DUMP=<file>: per-item dump, oneid<TAB>ok(0/1)<TAB>value<TAB>treeline per item.MATH_SHOW_MISS=N: show N items where the right answer was ranked but not chosen.MATH_HUMBLE=<th>[0.7]: confidence threshold for the self-assessment.MATH_UNITHARD,MATH_MAXVIOL,MATH_IMPLIED: experiments.
Example
The configuration used as the benchmark baseline:
export MATH2_RUN=runs/math2
MATH_NOLEX=1 cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- \
steps --set svamp --exact --epochs 20 --beam 32 --rerank 5 --world corpus/math-scripts/scripts.jsonl
# GSM8K, same settings without --world
MATH_NOLEX=1 cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- \
steps --set gsm8k --exact --epochs 20 --beam 32 --rerank 5
Add --qread to the SVAMP command to use the world reader as a judge feature.
What the output reports (in order):
<set> (exact gold): training with gold N; no tree …; no exact tree X; tree not in text order Y. This is the training data and the excluded items with their reasons: no exact tree, or a tree not buildable in text order.epoch k: correct on training a/N: training accuracy per epoch.<set> test: SLM alone (v4) correct acc/n (p%): the headline test accuracy.top-K: correct among them …: how often the right answer is among the top-K candidates.- Self-assessment:
- accuracy when only answering above a confidence threshold (
threshold); - accuracy when the first model and the judge agree (
opinions agree); - accuracy for the most confident 10/20/30/50% (
most confident); - trigger counts, e.g.
v2: answered — correct / wrongandworld: …(world agreement).
- accuracy when only answering above a confidence threshold (
Pitfalls
- Determinism first. Run the same command twice and diff the
MATH_DUMPfiles before claiming a gain. An unordered map can produce a "gain" that is only run-to-run noise. - Report the exclusion counts from line 1 together with accuracy.
- For a small SVAMP gain, compare per-item dumps (e.g. a McNemar test on the items that changed), not just percentages.
- Without
--exact,--world,--princand--qreadsilently have no training items to attach to.
3. World scripts: format
Gist
A world script turns a word problem into a quantity world:
- the state is
entity.thing → exact number; - every command is anchored to the sentence that justifies it (
@N) and labelled with the principle it applies (k=).
The executor (world/src/quant.rs: parse_line, run, check) runs scripts with the exact core. It accepts a script only if it passes every gate.
When to use
- Reading or writing training data for the world writer.
- Checking a hand-written script against a problem.
Steps
The line grammar comes from parse_line. Blank lines and lines starting with # are skipped.
@N set E.x = EXPR k=KIND
@N add E.x = EXPR k=KIND
@N sub E.x = EXPR k=KIND
@N mul E.x = EXPR k=KIND
@N div E.x = EXPR k=KIND
@N move A.x -> B.x = EXPR k=KIND (from A to B; A must exist)
@N ask EXPR (k= optional)
N: a 1-based sentence number. Sentences are split at.,?or!when followed by a space or the end of text, so$2.50is not split.- Cells are
entity.thing: lowercased, with non-empty parts around the dot, e.g.janet.eggortom.$. EXPR: numbers, cell references,+ - * /and parentheses.KINDis one ofgiven total gain loss transfer rate compare part convert combine unit. Any other value is an error.
Gates (each reports an error):
- the sentence
@Nexists; - every number in
EXPRappears in sentence N or an earlier one. Digits and number words both count, a percentage counts both as20and as0.2, and the constants0 1 2 3 4 7 10 12 24 30 52 60 100 365 1000and their reciprocals are always allowed; add/sub/mul/divandmoveapply only to an existing cell (usesetfirst);- no division by zero;
- there is an
ask; - the answer equals the gold answer.
Corpus file (corpus/math-scripts/, JSONL, one problem per line; written by quant-vmm, read by load_done, quant-stats and steps --world/--princ):
{"id":"svamp-train-chal-777","set":"svamp-train","question":"There are 87 oranges ... How big is each group of bananas?",
"gold":"145","script":"@1 set philip.orange = 87 k=given\n...\n@3 ask philip.banana_per_group\n",
"ok":true,"errors":[],"round":1}
okis true only if the gates found no errors.roundis 1 for the first attempt and 2 if the single retry fixed the script.stepsuses onlyok: truerecords and matches them to training problems by the exactquestiontext. For SVAMP that text isBody+ space +Question, with". "inserted ifBodylacks final punctuation.
Example
Script file s.txt for SVAMP train item chal-777:
@1 set philip.orange = 87 k=given
@1 set philip.banana = 290 k=given
@2 set philip.banana_group = 2 k=given
@3 set philip.banana_per_group = philip.banana / philip.banana_group k=unit
@3 ask philip.banana_per_group
cargo run --release --manifest-path world/Cargo.toml -- quant-check svamp-train svamp-train-chal-777 s.txt
# answer Some("145"); errors 0
cargo run --release --manifest-path world/Cargo.toml -- quant-stats corpus/math-scripts
# green counts per set, plus principle and verb histograms (reads <dir>/scripts.jsonl)
A script that uses 300 instead of 290 fails with two errors: number 300 not from the text up to sentence @1 and answer 300 ≠ reference 145.
Pitfalls
k=must be preceded by a space; it is split off withrsplit_once(" k=").- Set names for
quant-check/quant-vmmaresvamp-train,svamp-test,gsm8k-trainandgsm8k-test. SVAMP ids are<set>-<ID>; GSM8K ids are<set>-<index>. - Negative controls live in
world/src/quant.rstests and must stay red:cargo test --release --manifest-path world/Cargo.toml --lib quant.
4. Training the world writer
Gist
The small model learns to write the quantity world itself (math/src/qworld.rs):
WorldModellabels each number with an action (=,+,-, rate) and a cell;AskModelpicks a query template (State,Diff,Sum,Ratio,Product).
Labels come from verified world scripts (section 3). Without trained models, a rule-based writer (qworld::write) is used.
When to use
You have verified scripts (shipped in corpus/math-scripts/ or generated yourself) and want the world as a third opinion or as a judge feature.
Steps
- Optional: generate more scripts with an LLM.
quant-vmmsends batches to an LLM through a CLI (claude -p), checks each script, retries red ones once with the error list (the gold answer is never shown), and appends to<dir>/scripts.jsonl. LLM costs are logged in<dir>/calls.jsonl. It resumes by skipping ids already present. This step is paid and optional:MOVA_SUBSCRIPTION=1 cargo run --release --manifest-path world/Cargo.toml -- \ quant-vmm svamp-train runs/quant --limit 20 --batch 10 --par 2 --cap 2MOVA_SUBSCRIPTION=1makes the CLI use its own login instead of looking for a locally stored API key.--capis the spend ceiling in USD [10]. The model and effort come fromPRAG_MODEL/PRAG_EFFORT[medium]. - Train and use the writer inside
steps(it is not saved; it is retrained on every run):cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- \ steps --set svamp --exact --rerank 5 --world corpus/math-scripts/scripts.jsonl --world-feat - Read the report lines:
world: training problems with scripts N, number labels M: training problems matched to scripts, and labelled numbers;query: templates from scripts …: template histogram;- at test time,
world: correct, agrees with judgeand similar lines count how often the world was right and whether it agreed with the judge.
Pitfalls
- Scripts match only if the
questiontext is byte-identical to the problem text built by the loader. - Few scripts give little signal. Treat more scripts as more training and test data, not as a guaranteed accuracy gain.
5. World reader: qread (world v2 for numbers)
Gist
math/src/qread.rs reads a problem as events in time:
- who: the subject; a pronoun maps to the previous subject;
- what: the noun with its modifiers;
- how much: a number, or an unknown variable for "some" / "several";
- verb class: have → set, get/make → add, lose → subtract, give → transfer.
"He had 492 left" or "if he has 116 now" are not events but conditions on the state. The world solves for the unknowns from them (linear equations). The question becomes a query: state now / initially, sum of a class of events, difference, total.
If the world doesn't understand something, it returns None with a reason in the log and lists the numbers it could not read (unread). It does not guess. Rates and shares ("each", "per", "half", …) are left to steps.
When to use
- Additive and transfer problems with unknown starting amounts.
- As a judge feature (
steps --qread).
Steps
# probe one problem (the last sentence is the question)
cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- qread-text \
"Paul had some crayons. He gave 52 crayons to his friends and lost 535 crayons. He had 492 crayons left. How many crayons did he have at first?"
# coverage and accuracy on SVAMP: qread [svamp|svamp-train] [N = wrong answers to print]
cargo run --release --manifest-path math/Cargo.toml --bin mathsolve -- qread svamp 5
QREAD_STRICT=1: only answer when every number was read.QREAD_DUMP=<file>: writeindex<TAB>ok<TAB>valuefor every answered item.
Example
Some(1079.0) unread []
t0: paul have(have) crayon Set None
t1: paul give(give) crayon Sub Some(52.0)
t1: paul lose(lose) crayon Sub Some(535.0)
t2: paul have(have) crayon Is Some(492.0)
state initially Some("paul") Some(("crayon", "crayon"))
unread lists the numbers the world could not read; state initially is the state at the start.
qread svamp prints svamp: the world answered A/300, of them correct K (p%): the world answered A of 300, and K of those were right. Report both coverage (A/300) and accuracy (K/A).
Pitfalls
- High accuracy on low coverage is not a solver. Quote both numbers.
- As a judge feature (
--qread), the reader's answer is used only whenunreadis empty.
6. Evaluating on SVAMP / GSM8K
Gist
The datasets are not in the repo. Download them (both MIT) and convert them to the JSON arrays the loaders expect under <DATA_ROOT> (section 0).
Steps
- SVAMP: take the 700/300 split from Hugging Face
ChilleD/SVAMP(train.json,test.json). The original 1000 problems are on GitHub atarkilpatel/SVAMP(same fields, no split). Each item needsID,Body,Question,Equation,Answer.<DATA_ROOT>/mwpdata-svamp/svamp-train.json <DATA_ROOT>/mwpdata-svamp/svamp-test.json - GSM8K: take the socratic files from GitHub
openai/grade_school_math(grade_school_math/data/train_socratic.jsonl,test_socratic.jsonl). The loaders want a JSON array, not JSONL. Fields:question, andanswerwith<<…>>steps and a final#### N.jq -s . train_socratic.jsonl > <DATA_ROOT>/mwpdata-gsm8k-socratic/gsm8k-socratic-train.json jq -s . test_socratic.jsonl > <DATA_ROOT>/mwpdata-gsm8k-socratic/gsm8k-socratic-test.json - Run the commands from section 2 (
steps --set svamp|gsm8k …) and section 5 (qread svamp). Each command prints its own summary; see the output lists above. - Before claiming any change:
- run twice and confirm identical results (diff
MATH_DUMP); - report the excluded training items (line 1 of
steps) and the reader's coverage; - compare per item against the baseline.
- run twice and confirm identical results (diff
Example
SVAMP test (300 problems) with the benchmark configuration plus --qread:
- 179/300 (59.7%) correct;
- against a baseline with the same lexicon: 19 items gained, 6 lost (McNemar p = 0.015);
- against a stronger baseline the gain is not significant (p = 0.27).
GSM8K test (1319) was around 10–11% with the same solver. Treat these figures as a snapshot, not a guarantee.
Pitfalls
- SVAMP "test" in this guide means the 300-item split. Don't compare it directly with numbers computed on all 1000 SVAMP problems.
- GSM8K training problems without an exact tree (implicit numbers) or with trees that reuse numbers are excluded unless you use
--dag. Say so whenever you quote a number. - Never tune on the test split. Make fixes based on errors in the train split only.
This page in Markdown → https://md.mova.elamur.ai/free/en/docs/train/math-world/