Building a maths agent
Delegating the queen of the sciences to the machines
2026-05-31 — 2026-07-27
Wherein the Author Documents the Construction of a Bespoke Mathematics Agent, Wherein It Is Revealed That Iterative Refinement and a Modest Scratchpad Outperform Elaborate Fan-Out, and That Lean’s Compiler Admits of No Persuasion.
Mathematical reasoning agents are a thing, and as a working mathematician I want ’em. Or rather, if they are to exist, I want not to be the one without them. And if they do exist and I have one, I want one that lets me reason faster over new domains, check my work, and augment my feeble meat brain as smoothly as possible.
The theory and practice of how to do this are weirdly accessible, utterly not what I expected, but actually relatively simple to implement.
That is just as well, as the marketplace is thin. As working mathematicians, we are resigned to using tools long past their utility. The field does not exactly seethe with polished, friendly off-the-shelf options, as it does for munging office memos, or for agentic coding. So — we build our own, I guess? Or rather, I have been begrudgingly building my own. This post is distilled from my attempt to speed-run the last two years of mathematical reasoning LLM advancements for my own benefit.
This page is mostly AI slop, i.e. LLM descriptions of my decisions in some recent exploratory projects. However, it is useful, so I am publishing it now rather than waiting for a more polished version.
What follows is what I actually built, in what order, and, occasionally, what broke. Why any of it is shaped this way lives in the reasoning notes instead: that page holds the landscape and the arguments, this one holds the receipts. tl;dr it ends up being somewhat about models and much more about specialized harness design, all of it downstream of what counts as a mathematical answer in the first place — a boxed number that a solver argues for in English, an informal proof that only a judge can grade, or a Lean proof that a compiler accepts or rejects.
I already run consumer-grade agent harnesses for code — a model and a sandbox and a loop that splices the two. What do I need to add to get sweet theorems?
1 The path through
Suppose I already use some coding harness. I can proceed step by step to add features, one at a time, walking through harness design space until it is more optimized for mathematics than for code. That space has four axes — wiring (how the calls connect to one another), verification (what gets to say an attempt was any good), context (what each call is shown) and dispatch (which model or tool fills which slot) — and every step below moves exactly one of them, which is what the second column of the table records. Some steps, in a recommended order (I won’t divulge whether I actually followed this order):
| Step | Axis moved | What it buys | Cost to build | Measured effect |
|---|---|---|---|---|
| Easy mode | — | an agent with a generalist backend, used cleverly | free | the baseline |
| Lend it an oracle | dispatch | a maths model behind a solve() tool, for the sub-problems the generalist should not attempt |
an afternoon | least of anything on offer |
| Build the loop | wiring → sequential | an explicit generate-run-verify system, specialist models throughout if we want | a weekend | largest single contributor |
| A scratchpad | context | what failed carried across attempts, so the loop stops repeating itself | hours, once the loop exists | second largest |
| Fan-out | wiring → parallel | many samples, cross-checked — compute traded for accuracy | cheap to build, dear to run | large, but only as good as whatever picks the winner |
| Lean | verification → exact | the problem formalized into machine-checkable form | days of toolchain plumbing | changes what every other step can do |
These all cost different amounts of effort to build, and yield different amounts of effect. That last column is not my measurement. It comes from AxProverBase (Requena et al. 2026), a deliberately stripped-down Lean-proving agent whose authors assembled it one component at a time and scored each addition separately — the nearest thing to a controlled experiment on any of this that I know of. Ranked by effect, they put iterative refinement first and memory second, which are the third and fourth steps here; building the oracle is nevertheless easier and more fun than either.
I suspect that the table continues past my ability to build. In particular, the recursive, self-grading, re-formalizing orchestrators at the top of the mathematics leaderboard have enough startup money behind them that buying access off the rack is probably the better move.
2 Easy mode
The baseline: an ordinary harness, an ordinary model, no axis moved.
An ordinary agent harness I already use for code, pointed at a good generalist reasoning model, gets me some way toward powerful mathematics. Claude, the latest DeepSeek, or a big Qwen3-Thinking are all respectable mathematicians; tell them to “do the maths”, then “check the working with Python/SymPy”.
This works pretty great with e.g. Claude Desktop, Claude Code etc. NB local clients are less capable at rendering equations, so we may want to build Open WebUI to make equations easier on human eyes.
3 Lend it an oracle
Moves dispatch: one slot in the loop gets filled by a specialist instead of the generalist.
Now we can use our existing agent as an orchestrator, but let it delegate mathematics (or the checking of mathematics) to a specialist maths model. Practically, this means we give it a solve() oracle tool to solve knotty maths problems, splicing the result back into its own reasoning. Oracle in the strict sense: the orchestrator does not attempt the sub-problem and does not check the answer; it asks and then believes, so whatever confidence the tool reports is all the confidence there is.
Python harnesses like Smolagents or Qwen-Agent can simply call a Python function. For other agents (Claude-like, Goose, Codex, Gemini CLI, pi, Hermes) we expose the solve() as a CLI tool. Both of these can be documented by an agentskills.io SKILL.md in skill-supporting agents. Skill-blind agents can use an MCP server.
I whipped up mathx as an example of doing all three, in a relatively swappable manner.
We might wish to fan-out in such an oracle. Done synchronously, this can be slow and hit time limits on tool use, or just get boring. Asynchrony and parallelism depend on the orchestrating agent. When we own the loop, a fan-out is just asyncio.gather, and there is no timeout that we do not ourselves set. For tool-use agents, the async-handle pattern is probably what we want — we set up a handle-and-poll pair of ordinary tools: submit_solve() returns a job id at once and check_solve(id) returns status-or-result at once, so each call is fast. Some harnesses also dispatch background sub-agents of their own, and MCP supports Tasks at the protocol level; that might work too.
4 Build the loop
Moves wiring to sequential: the verdict comes back and we try again.
What iterates is attempts at one problem. Two roles, and the loop is just the two of them passing work back and forth: a proposer, the model that writes a candidate answer or proof, and an executor, whatever runs or checks what the proposer wrote. The proposer writes a candidate, the executor checks it, the verdict — a compiler error, a failed numeric check — goes into the next prompt, and round it goes again until the check passes or the iteration budget runs out (AxProverBase allows 50 rounds per problem). This is not the same thing as fan-out: there the attempts are independent and mutually blind, whereas here attempt \(n+1\) has seen what attempt \(n\) got wrong.
If easy mode plus an oracle aren’t enough, we can write a custom loop that specializes the generic one for mathematical needs. This is the single largest contributor in the AxProverBase ablations, and it is also where the maths usage diverges from the coding usage.
The difference is in the executor: a maths executor by default only ever checks a calculation, which is side-effect-free — fifty attempts cost fifty times the tokens and leave nothing else changed, whereas fifty attempts at a repository leave fifty conflicting sets of edits — and that is what makes fan-out simple.
Two maths-harness gotchas:
- TIR models speak their own tool-call dialect, so the orchestrator has to parse fenced code templates and splice results back in the shape each model expects. Budget for writing that parser per model family; it is the thing that stops a generic harness working.
- The model writes the code we run unobserved, so sandboxing is wise. The only maths-specific detail is that the specialists assume a full scientific Python (SymPy, NumPy), so build the sandbox accordingly.
5 A scratchpad
Moves context: what a call sees now includes what previous attempts learned.
A stateless loop repeats itself forever, and fixing that is the second-largest lever in the whole architecture — behind iterative refinement, ahead of the search tools. It is also cheap to add. This is memory a few turns wide, not the session-and-project kind, and what a maths loop specifically forgets is which lemmas exist in Mathlib and what they are called this month — so it burns its budget rediscovering that Nat.foo was renamed three versions ago. The crude version pastes the whole transcript of past attempts into the prompt, but that bloats the context, makes every call slower and dearer, and eventually overflows the window. The version that works is a short self-managed note: after each attempt the proposer rewrites a running scratchpad of what it learned — which lemmas don’t exist, which tactics misfired — pruned so it never grows without bound. On the AxProverBase numbers, that self-managed note buys around 7% more theorems at ~20% lower cost, with half the run-to-run variance, beating both no memory at all and a rolling transcript of recent attempts. With memory in place — and the library-search tool alongside it — the prover stops re-making the same name and syntax errors, freeing budget for proof search.
6 Fan-out
Moves wiring to parallel: many samples at once, and something downstream to choose between them.
Fan-out improves mathematical accuracy by evaluating many candidate solutions — maj@k or Pass@k, depending on what came back. We can run a lot of these fuckers at once, since the tokens stream over the network in parallel.
The goal here is overall efficiency: do more parallel copies of a cheaper model get us further than fewer copies of a more powerful one?
Asked for \(7^{2026} \bmod 13\), VibeThinker-3B and Ornith-1.0-9B both answered 4, correctly and with sound reasoning. VibeThinker spent 786 completion tokens; Ornith spent 6,316. Multiply by \(k\) and that ratio is the economics of fan-out — a model three times the size costing eight times the tokens per sample is not a near-miss, it is a different budget. So benchmark accuracy picks the shortlist and tokens-per-correct-answer picks the winner.
The cheapest thing to fan out is a model that runs no code at all — a pure chain-of-thought reasoner emits no Python and no Lean, so its executor is a no-op, and maj@k collapses to plain sample-and-vote, no sandbox per chain.
Sampling wide is the easy half. Everything hard about fan-out is downstream of it, in whatever picks the winner, and that is a different problem depending on what came back.
6.1 Picking a winner from answers
maj@k turns out not to be trivial to implement, because written mathematics is not formal and it is therefore not clear when two expressions are equivalent. The reasoning notes state the ceiling; what follows is what it cost me to hit it while implementing mathx.
Is 0.5 \log \cos(ax-b) the same as \frac12 \ln \cos(b-ax)? The standard fix (e.g. in math-verify) is to parse both answers into a Computer Algebra System (CAS) and test equality there. It is better than nothing and it is not enough. The LaTeX inconsistencies are each reactively fixable, one patch at a time, but the supply of such inconsistencies is inexhaustible. And math-verify’s strict=False tries positional variable-matching for the denotation problem, which did not help me and additionally clustered things it should not, like \(\mu_1\) with \(\mu_2\).
So the fallback is an LLM judge, guessing whether two things are the “same”. OpenAI’s simple-evals scores MATH with a model-based equality-checker prompt rather than rules; NVIDIA’s NeMo-Skills ships an LLM judge beside its sympy checks. The trained verifier models are less useful than they sound: they grade candidate-against-ground-truth, not candidate-against-candidate, which is what clustering needs. xVerify has bad licensing, and the Omni-MATH-2 audit (Ballon et al. 2026) found Omni-Judge wrong in 96% of its disagreements. CompassVerifier (Apache-2.0) is the credible trained option, and its human-labelled VerifierBench is a free test set for whatever judge we do run.
In the little toy mathx project, I went for a tiered, audited approach. Exact match wins, then CAS equality (both directions — verify() is asymmetric), then an opt-in LLM judge for the pairs the CAS refuses, with order-bias hygiene (both presentation orders must independently say equivalent), and a conservative rejection of anything that does not parse. The margin display attributes each merge to the actual method used (e.g. 11/12 (3 judge merges)). On one of my test problems (maj@16), this got us from 4/12 naïve matches to 8/12 after the parsing fixes, and up to 11/12 with the judge.
6.2 Picking a winner from proofs
When the branches come back carrying arguments rather than values, there is no tally to repair, and the aggregation step has to be a judge that reads them. Nomos is the worked example of building one, and I have not built anything at that weight.
To avoid owning this much of the loop, there is OpenRouter’s Fusion Router, a pre-rolled, hosted fan-out-and-judge. Point a request at openrouter/fusion: a panel of models answers in parallel, and a judge model compares (rather than merges) their answers into structured consensus/contradiction/blind-spot analysis that the outer model writes its final answer from — at about 4–5× the cost of a single completion for the default three-model panel. Its judge is a generalist reading prose, not a CAS or a Lean compiler, so it inherits every equivalence problem above rather than escaping one — worth the money where the verdict was always going to be soft, no substitute for the compiler when I want one that can’t be talked round.
7 Lean
Moves verification to an exact grade: the checker cannot be talked round.
Everything up to here is the coding harness with a maths model dropped in. Lean breaks that pattern in two places, and both are plumbing problems rather than conceptual ones. What the compiler-in-the-loop buys is in the reasoning notes; what it costs to install is here.
The first break is the executor, and it is mostly good news. The orchestrator is the solver’s loop with a different halting rule, and the sandbox is still the same run(code) -> result box — now with a Lean server where the IPython kernel sat, POSTing a candidate proof and getting back {"ok": bool, "errors": [...]}, and the errors going back into the next prompt where a tool result used to go.
A Lean toolchain plus a built Mathlib is gigabytes and hours to compile, and the prebuilt cache that skips the rebuild only matches an exactly pinned toolchain — so pin Mathlib to the model’s commit, or proofs fail for reasons that have nothing to do with the maths, because the lemma the model cites was renamed after it was trained. Underneath, the executor runs atop one of lean-repl (the lightest), Pantograph, or LeanDojo, usually behind the Kimina Lean Server so Mathlib isn’t re-imported for every proof. This lean_image business is fiddly, version-sensitive plumbing. Modal’s own theorem-proving case study shows how it is done.
The second break is at the front of the pipeline, and it has no clean fix. A prover takes a formal statement, so anything starting from an English sentence needs autoformalization first, from a separate and much less reliable model (Kimina-Autoformalizer, Goedel-Formalizer-V2). Practically: start from a formal statement, as the benchmarks do, and the prover mirrors the solver almost line for line. Start from English and the least trustworthy link in my chain is now the statement rather than the proof, which is much more annoying to debug because the failure looks like a maths failure.
8 Where the compute comes from
What the steps cost to run once built, where fan-out is the only one that costs much.
Build and debug the whole loop on a laptop first — a local IPython kernel for the solver, lean-repl on disk for the prover — and never pay for a GPU until we fan out.1 Modal is a tidy backend to fan out to.
Tokens come from a model behind a scale-to-zero, OpenAI-compatible vllm serve endpoint — a few dozen lines mount a Volume so the weights download once, and the GPU drops to zero between requests, meaning we pay only for the seconds a request actually runs.
Fan-out is then just a .map — one chain per call, each with its own sandbox, all sharing the one endpoint:
The same shape sweeps a whole problem set, or runs a prover’s Pass@k with the Lean verifier as the executor. A heavy tool — a Gröbner basis, a big symbolic integral, a Sage or PARI-GP call — goes on its own cheap CPU modal.Sandbox next to the GPU endpoint, so we are not paying for an idle H100 to run SymPy.
8.1 What it costs, rented
The frontier provers are not something we rent by the token: they are pilot-only and priced in dollars per problem, Aleph’s runs landing at $23–68 each. That is the number a home-built loop has to beat, and one loop has published enough to compare against.
AxProverBase — the minimal agent whose ablations rank the steps above — ships as working code: an off-the-rack Claude Opus 4.5, no fine-tuning, in an iterative compile-and-retry loop with memory and Mathlib search, no recursive decomposition. It reaches 54.7% on PutnamBench, which is 600-odd problems from the Putnam undergraduate competition formalized in Lean and currently the live proof benchmark. That is near-parity with the open specialist prover Hilbert while spending about 100× fewer tokens, at roughly $12.6 a problem against Aleph’s $23–68. So the price of the amateur version lands within a factor of a few of the professional one.
What can we rent instead of self-hosting? Fewer options than I’d like. Few specialist maths models are hosted per token, and the one serverless home for the narrow solvers (OpenMath-Nemotron, AceMath2) is Featherless, which caps concurrency per plan — exactly the wrong constraint for fan-out. So sampling these widely usually means self-hosting, on RunPod (from ~$0.27/hr) or Modal (scale-to-zero, which wins on bursty use).3 The exception is at the top of the range: DeepSeek-Prover-V2-671B would need an eight-GPU node to self-host, but Novita meters it cheaply (~$0.70/$2.50 per 1M tokens), so pairing it with a local lean-repl loop is a cheap way into industrial proving.
8.2 On a laptop
When the model runs no code, the whole loop collapses onto a single Mac: maj@k is just \(k\) chat calls and a Counter — no Modal, no .map, no per-chain sandbox. Point it at a local endpoint that can batch the samples and keep the model resident; vllm-mlx is what I actually run, with oMLX as the near-identical fork. A small-but-strong reasoner shines here — many copies for the price of one specialist — and VibeThinker-3B delivers, even at laptop scale, frontier-level verifiable maths at 3B if the numbers hold. We wrap it in a plain solve() and call it straight from the loop, not as an MCP tool — which is only needed when the agent is doing the orchestration.
# maj@k against a local oMLX endpoint — the reasoner emits no code, so no sandbox
from openai import AsyncOpenAI
import asyncio, re
from collections import Counter
client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="x")
BOXED = re.compile(r"\\boxed\{([^}]*)\}")
async def sample(problem):
r = await client.chat.completions.create(
model="vibethinker-8bit",
temperature=1.0, top_p=0.95, # per request; see below
messages=[{"role": "user", "content": problem}], max_tokens=32000)
hits = BOXED.findall(r.choices[0].message.content or "")
return hits[-1].strip() if hits else None # last boxed = the final answer
async def solve(problem, k=16):
answers = await asyncio.gather(*(sample(problem) for _ in range(k)))
votes = Counter(a for a in answers if a)
return (votes.most_common(1)[0][0] if votes else None), votesTwo details. Set the sampling parameters per request: greedy decoding returns the same answer \(k\) times, so there is nothing to vote over at all, and the local servers will not set them for us — vllm-mlx has no per-model sampling keys and falls back to a temperature of 0.7, cooler than VibeThinker wants, and which servers honour which parameters is its own saga. And extraction is easier than it looks — a reasoning model buries its answer under a long <think> trace, but most OpenAI-compatible servers expose the post-think conclusion separately (reasoning_content, given a --reasoning-parser), so the content we regex is the short conclusion and the last \boxed{} is reliably the answer the model is committing to. Return the whole votes tally, not just the winner: the margin is the loop’s confidence signal — 14/16 is a different thing to act on than a 6/5/5 split, where the driver can escalate \(k\) or surface the disagreement instead of committing.
9 What the build taught me
Three things, in decreasing order of how much they surprised me.
The loop is the half I can build, and it is also the half that pays. The ablations put iterative refinement first and memory second, and both of those are a weekend of ordinary Python around models I did not train. Nothing further up the table needed a GPU either — the entire thing debugs on a laptop, and the first dollar goes on fan-out.
But the model I point the loop at cannot be chosen from a benchmark table, because responsiveness to a scaffold appears on no leaderboard. The number that matters is how the model behaves inside my loop — whether it reads a compiler error and does something different next round, whether its scratchpad note is any good. So the shortlist comes from benchmarks and the choice comes from measurement, which means budgeting for the measurement.
And most of the fiddly work was not mathematics. Deciding whether two answers are the same, pinning Mathlib to the commit the model was trained against, getting a local server to honour a temperature — that is where the days went, and none of it is the part I found interesting. The mathematics was the easy bit, which is either encouraging or ominous depending on the hour.
10 Incoming
- The Journal of Artificial Mathematics
- Bolzano (source) — a colleague’s “automatic researcher” aimed at open problems
Which chat window we read all this in is a separable and much duller problem, and has its own page.
11 References
Footnotes
Laptop-scale model picks (OpenMath-Nemotron, Qwen2.5-Math, Goedel-Prover, all via MLX) are in the Mac notes.↩︎
AceMath is CC-BY-NC: non-commercial only, wherever it runs.↩︎
Privacy ranking, briefly: self-hosting on Modal is strongest; the metered shortlist (Novita, Featherless) was pre-filtered to no-train endpoints; keep unpublished proofs off first-party APIs that may train on submitted data, and be thoughtful about OpenRouter, which delegates retention upstream.↩︎
