Fixing real LaTeX compile errors in under a second: a fine-tuned Inkling-Small, TeXtinguisher (the benchmark it was measured on), and the pipeline that produced it. Companion to the post Fixing LaTeX errors in under a second (Sundial blog).
AI is moving mathematics quickly, yet most mathematicians' day-to-day tools have changed little in a decade, and one of the complaints we heard most often when talking to them was fighting LaTeX compilation errors: writing gets interrupted by a missing brace or two packages that clash, the log is two hundred lines long, and the flow is gone. Asking a chatbot works, but switching windows breaks the flow too, and a pasted snippet loses the context that lives in other files.
We wanted a model that fixes the error inside the editor, in well under a second, as a suggestion that can be reverted in a click, cheap enough to run on every compile. That rules out frontier APIs (several seconds, a page of reasoning per fix) and points at a small open model fine-tuned for one job: Thinking Machines' Inkling-Small through Tinker.
- A benchmark of real compile errors mined from TeX.StackExchange (asker's document still fails, accepted answer's
document compiles, diff replays exactly), plus real multi-file projects from GitHub histories and old arXiv sources that
no longer build under a current TeX Live. Every row is compile-verified; the released subset is in
release/. - The training recipe: supervised fine-tuning on real fixes, then GRPO with the compiler as the reward (a fix that compiles is rewarded, more if the PDF matches the human fix, nothing once it deletes content), with companion files in the prompt for multi-file projects.
- The evaluation harness, the Modal compile farm, and the merge/serve/bench scripts.
Result on 647 held-out real errors (669 mined, 22 dropped after an audit found their reference broken): the fine-tuned model fixes
85.2% (GPT-6 Astra 90.9%, Claude Fable 5.1 82.4%, GPT-5.5 80.7%) in 0.95 s per fix at about $1.30 per thousand fixes; the margin over
GPT-5.5 holds under a paired McNemar test (p = 0.02), Fable is within noise (p = 0.15), and Astra is ahead (p = 0.001) at fourteen
times the cost. The frontier models still delete less content when they fix, and still lead
on real multi-file projects; both are documented in the post and in release/DATASET_CARD.md.
scripts/pipeline (pythonbelow =../.venv/bin/python, run from the repo root; data paths resolve throughcommon.PROJ, so any cwd works).common.pyports sundial'slatex-log-parser.ts+fix-prompt.ts(reference/) so prompts match the product.modal/compile farm (modal_compile.py,modal_compile_hist.py), LoRA merge (merge_inkling.py), vLLM serving (modal_vllm_merged.py) and benches (bench.py,bench_throughput.py).data/(internal, not shipped) jsonl rows (prompt,target,src_dir,break_edits/file_edits, provenance). Frozen held-out sets:texse_heldout(287),texse2_heldout(669 mined, 647 after the audit; drop listtexse2_heldout_drop.json),multi2_heldout,skew_multi_heldout,skew_eval,project_eval,hard_eval3,eval(v1).refs.json= PDF text/pages of the human fix,refs_pool.json= teacher pseudo-refs,refs_skew.json= historic-TeX-Live renders. TeX.SE rows are CC BY-SA (licenseper row); arXiv/GitHub rows keep ids, sha and licence fields.*_report.mddescribe each mining run.release/public subset (scripts/build_release.py,release/DATASET_CARD.md): TeX.SE and licensed GitHub rows with sources; skew rows are metadata-only andrefs_skew.jsonis not shipped.out/eval results<set>_<tag>.json, RL logsrl_*/metrics.jsonl, chain scripts, figures.
# mine (COMPILE_BACKEND=modal MODAL_PROFILE=<profile> for the farm; GitHub miners compile locally otherwise)
python scripts/mine_texse.py --target 400; python scripts/mine_texse2.py --resume; python scripts/mine_texse_pool.py --resume
python scripts/mine_github_multi.py scan|mine|verify; python scripts/mine_github_multi2.py repos|walk
python scripts/mine_github_forks.py forks|walk; python scripts/mine_texse_multi.py parse|mine
python scripts/gen_skew_train.py --target 3000 --resume # organic arXiv version skew, historic refs
python scripts/gen_arxiv.py; python scripts/gen_data.py; python scripts/gen_project.py; python scripts/gen_multi_synth.py # synthetic sets
# split (frozen files; never re-draw after a checkpoint exists)
python scripts/split_texse.py --jsonl texse; python scripts/split_texse.py --data data/texse2_labeled.jsonl --out data/texse2_split.json --probed --jsonl texse2
python scripts/finish_multi2.py; python scripts/gen_skew_train.py --split; python scripts/split_data.py
# SFT
python scripts/distill.py --data data/texse_train.jsonl --teacher gateway:anthropic/claude-fable-5.1 --k 2
python scripts/build_pseudo_refs.py; python scripts/build_round5.py --mix <file:weight,...> --out train6.jsonl
python scripts/train_sft.py --data data/train6.jsonl --val-data data/val6.jsonl --epochs 3
python scripts/build_multi_sft.py; python scripts/train_sft.py --data data/multi_sft.jsonl --load-state tinker://.../weights/final --epochs 1
# RL (state path from out/checkpoints.txt)
COMPILE_BACKEND=modal COMPILE_TIMEOUT=40 python scripts/train_rl.py mode=fix load_checkpoint_path=tinker://.../weights/state2 train=<file:weight,...> log_path=out/rl_fix
# eval
scripts/eval_battery.sh <arm> <tag> [sets...] # arm = tinker://<sampler path> | inkling-small | gateway:<slug>
python scripts/run_eval.py --data data/<set>.jsonl --arm <arm> --out out/<set>_<tag>.json
python scripts/heldout_table.py texse2_heldout; python scripts/select_ckpt.py rlM2 rlM3; python scripts/refill_empty.py out/<set>_<tag>.json
# merge + bench (out/finalize.sh <tag> <sampler path> runs all of this)
modal run modal/merge_inkling.py --stage adapter|aux|merge|verify; modal deploy modal/modal_vllm_merged.py
python modal/bench.py <url> --model merged --n 15; python modal/bench_throughput.py <url> --concurrency 64 --n 320
| var | read by | meaning (default) |
|---|---|---|
TML_KEY, AI_GATEWAY_API_KEY |
common.load_env (../.env or .env) |
Tinker key (exported as TINKER_API_KEY); Vercel AI Gateway key for baseline arms |
COMPILE_BACKEND=modal, MODAL_PROFILE |
common.compile_project |
compile on the Modal farm latexfix-compile instead of local latexmk; rows with bundles stay local |
COMPILE_SLOTS |
agent_env (RL/eval) |
concurrent local compiles (6) |
COMPILE_TIMEOUT |
run_eval, RL |
seconds per compile (90; RL runs used 40) |
COMPILE_W STRICT_W MINIMAL_W STRUCTURE_W LENGTH_W |
fix_env |
reward weights (1.0 / 0.5 / 0 / 0 / 0); the model of record uses the defaults |
THINK_BUDGET, MAX_GEN_TOKENS |
common.system_prompt, fix_env |
reasoning allowance in the prompt (0) and RL generation cap (1500) |
EVAL_MAX_TOKENS |
run_eval, refill_empty |
eval generation cap (2000); Tinker arms add THINK_BUDGET |
GATE287, TP_FILE |
select_ckpt, plot_cost |
texse_heldout gate for checkpoint ranking (70.4); throughput bench json |
Local runs need latexmk (TeX Live) and poppler (pdftotext, pdfinfo) on PATH, tinker_cookbook with the tml_v0
renderer, and the tml_renderers partner wheel.
compiles: the patched project builds clean. <=40 removed: compiles and the edits remove at most 40 non-comment
characters (catches "delete or comment out until it compiles"). strict: compiles and the PDF text/page count match the
reference (similarity >= 0.985; skew refs use 0.93 on ASCII text). heldout_table.py also prints intact (nothing
removed, no structural construct lost).
The deletion metric is a heuristic: it counts characters removed inside SEARCH/REPLACE blocks or commented out, so
\iffalse, comment environments and macro stubs escape it. The compile farm image is texlive/texlive:latest, which was
TeX Live 2026 at the time of the final evaluations, while local compiles and the older refs used TeX Live 2025. Strict
compares against the accepted answer's document, which is often restyled, so valid alternative fixes score 0. Checkpoints
were selected on separate dev sets (texse_heldout, multi2_heldout, skew_multi_heldout) that share no questions
with the 647-row texse2_heldout headline set; numbers reported on the selection sets are optimistic.