Teaching a small model to read tables, and prove every step

A 4-billion-parameter model that answers constrained questions over real financial tables and emits grounded reasoning traces, with every step tied to specific, verifiable cells. The headline: small models comprehend table tasks but can't reliably execute the arithmetic, so we let them comprehend and hand the arithmetic to proven code.

Held-out test, scored once TAT-QA · CC BY 4.0 QLoRA · single RTX 3090 Independently corroborated & honestly bounded

Results: the bottom line

How this started

At work I was benchmarking language models, comparing their answers on a set of data tables and talking the results over with an AI assistant. At some point I stopped and wondered: does the model actually understand the table in front of it, or is it just pattern-matching its way to a plausible-sounding answer? This project is my attempt to find out, using public tables I can share.

Tables like those are full of questions such as “which product was cheapest last year while still selling well?” A small AI can read the table and understand the question just fine. But, like a person who gets a word problem yet slips on the mental math, it often gets the calculation wrong.

So we split the work: the AI does the understanding, and a piece of ordinary, reliable software does the arithmetic (in effect, we hand it a calculator). The AI also has to show its work, pointing at the exact cells it used, so anyone can check the answer.

The payoff: correct answers went from roughly 6 in 10 to about 96 in 100, and nearly all of the AI’s explanations now point to the right cells. And when it meets a kind of question it was never taught, it holds back instead of guessing confidently, so the system is never worse than the AI on its own.

One thing to be upfront about: we wrote the answer key ourselves, so on its own this score shows the system agrees with our key, not that it is objectively right. To check the key itself, we had independent people and a separate AI re-answer the questions from scratch (details under Validation & limits).

95.7%
of answers correct on fresh, unseen tables, up from 59.4% when the AI did the math itself
96.9%
of its explanations point to the right cells in the table, up from 71.3% on its own
Never worse
than the AI alone: a built-in safety check holds back when the question is unfamiliar

On the locked test (254 tables, graded once)

A “locked test” means these 254 tables were set aside up front and the system was graded on them a single time, with no peeking and no retries.

What we checkedResultFor comparison
Right answers 95.7%59.4% when the AI did the math itself
Explanations that point to the right cells 96.9%71.3% from the AI’s own citations
Explanations that fully check out 94.9%
Understood the question well enough to compute 97.2%exactly right 87.4% of the time
Accuracy by question type (winner / threshold / trade-off) 97.6% / 96.6% / 92.9%

The score on these unseen tables matched the score during development, so it isn’t a fluke of one lucky test set. Could 95.7% be misleading, since we built the answer key ourselves? We checked it against independent people and a separate AI reasoning from scratch. See the Validation & limits tab.

How each stage got there

Each step of building the system, and what it added. The first three rows are different AI models on their own; the last two add the calculator and the show-your-work step.

StageValid explanationCells correctRight answer
Untrained small AI (Qwen3-1.7B)0%1%18%
Trained on examples (1.7B, fine-tuned)17%49%20%
Untrained bigger AI (Qwen3-4B, zero-shot)0.0%0.0%27.4%
Bigger AI, trained (Qwen3-4B, fine-tuned)44%70%52%
+ Calculator step (the executor)69%·71%· 95.7%
+ Show-your-work step (grounded citations)94.9%· 96.9%·95.7%
Untrained 1.7B18%
Trained 1.7B20%
Untrained 4B27.4%
Trained 4B52%
+ Calculator step95.7%

Right-answer accuracy, stage by stage. Flat through every model-only stage (more size and training barely move it), then the calculator step does what they could not. The solid bar is the answer built by the engine.

What teaching the AI on examples bought (and didn’t). Training the small AI on examples (“fine-tuning”) made its explanations far better than the untrained model: well-formed explanations 0% → 17%, cells pointed to correctly 1% → 49%. But it still got the answer wrong about as often (18% → 20%). Better explanations, same shaky arithmetic: that gap is exactly what a bigger model and then the calculator step went on to close.

· marks the two final steps (measured on the 254 locked-test tables); the earlier rows are from development. The big jump from the small AI to the larger one on the same training showed the bottleneck was the AI’s size, not the examples; and the right-answer score stayed stuck until the calculator step, confirming the weak spot was the arithmetic, not the reading.

In one breath

  • Let the AI understand, let software calculate. The AI reads the question and table and says what to work out; reliable software does the math. Right answers 59.4% → 95.7%.
  • Make it show its work. The explanation points to the actual cells the software used, not numbers the AI typed from memory. Explanations grounded in the real cells 71.3% → 96.9%.
  • Know its limits. Faced with a kind of question it was never taught, it defers to the AI’s own answer instead of guessing, so the system is never worse than the AI alone.

The statistics, for the technically inclined. Engine answer 95.7% carries a 95% Wilson confidence interval well clear of the model's 59.4%; the development and test numbers match almost exactly, so the result generalizes rather than overfitting the development set. Head-to-head on the same items, the calculator approach fixed +93 answers and broke only −1 (an exact-McNemar test gives p ≈ 10⁻²⁶), so the improvement is not noise.

The problem

Ask a small language model a precise question about a table ("which line item has the lowest 2019 value while 2018 stays at or above 12.7?"), and two things can go wrong. It can misread the table, or it can read it correctly and then botch the arithmetic: a comparison flipped, a dominance check fumbled, a number hallucinated into a citation.

For anything you'd actually trust, the answer isn't enough: you want the reasoning, and you want each step pinned to the exact cells it used, so a checker (or a person) can verify it. That is a grounded trace. The goal of this project was a small, local model that produces them.

The central question turned out to be which of those two failures dominates, because the fix is completely different. If the model misreads, you need better perception or more data. If it reads fine but miscomputes, you don't need a bigger model at all; you need to stop trusting its arithmetic.

The task & the benchmark

Tables come from TAT-QA (Zhu et al., ACL 2021; CC BY 4.0): real financial tables from company filings. TAT-QA's own questions are ~64% free-form arithmetic (change, %, sums) and only a sliver match clean, checkable predicates, so we keep the real tables and generate three families of constrained, programmatically-verifiable questions over them:

Taskquestion_typeExample
Winner selectionbest_under_constraint "Which line item has the lowest 2019 value with 2018 ≥ 12.7?"
Constraint filteringthreshold_filter "Which periods have rate ≥ 2.89 and term ≥ 3.65?"
Trade-offtradeoff_summary "Which line items are Pareto-optimal maximizing both 2019 and 2018?"

Every example ships with a machine-checked grounded trace; an independent rule-based validator recomputes the answer from a stored spec and verifies each cited cell. The dataset is 1,266 validated examples (100% pass the validator), with 177 used as silver training records and a leakage-free, table-level split: dev (190) / locked test (254) / a separate 12-example out-of-distribution anchor.

Why a table-level split, and how leakage was ruled out. The same source table appears in multiple examples (two orientations × several question types), so a naïve per-example split leaks tables across train/test. We re-split by source table (stripping orientation suffixes); 166/224 tables had straddled a per-example split. Disjointness is asserted at both the id and content-hash level on the exact file the model trained on. Total/subtotal rows are excluded from the entity universe by a single shared rule, so the inference-time universe matches the gold spec.

The approach

TAT-QA tables generate Q + gold + trace validate QLoRA SFT executor grounding safety gate

We fine-tuned Qwen3 (QLoRA, 4-bit, a single RTX 3090). Supervised fine-tuning alone got the model writing well-formed traces, but its answers were still only ~59% right. The error analysis was decisive: the remaining mistakes were arithmetic-execution errors, not misreadings. So:

The executor: answer by construction

Instead of trusting the model's arithmetic, the model emits a structured operation (the op type, the column names, thresholds, and directions it read from the question), and a small, deterministic, independently-tested engine computes the answer. The model's job becomes comprehension; the arithmetic is done by proven code. This is the move that takes answers from 59.4% to 95.7%.

Grounding: citations by construction

The model's hand-typed citations had the same disease as its arithmetic (wrong/derived numbers), so they got the same cure: the trace cites the cells the engine actually read, which are grounded by construction: they always exist and always match. The prose stays model-authored (the explanation); only the cited evidence becomes system-constructed (the proof). Grounded-trace rate: 71.3% → 96.9%.

The safety gate

The engine is a specialist: it only knows the operations it was trained on. If the model emits an operation the question doesn't actually support (no threshold language for a threshold op, no trade-off language for a trade-off op), the gate treats it as fabricated and falls back to the model's own answer (do no harm).

Is 95.7% real, or circular?

Honest worry: the test gold is computed by code that shares the engine's arithmetic, so on its own that number is consistency, not correctness. To break the circularity we ran two independent checks that never see the spec or the gold.

Blind LLM cross-check. 36 stratified examples, shown as raw table + question only, answered by independent annotators reasoning from scratch: 36/36 agreement with the stored gold (confirming the engine on every one of its sampled correct cases). Two independent reasoning paths agreeing on every case ⇒ the answers are corroborated, not merely self-consistent.

Blind human anchor. A real person, given a self-contained annotation UI, independently answered the same items on the two natural question types (n=24): 22/24 blind agreement with the stored gold: a second, human reasoning path reaching the same answers.

The limit: where it fails, and how it's caught

The one non-circular slice is an out-of-distribution task type (extremum) that is not in training and not in the engine's vocabulary. Here the model's own answer is right 100.0%, but it emits a trained operation instead, so the executor computes the wrong answer 0.0%. The engine isn't broken; the model just doesn't know the operation exists, and the executor overrides its correct intuition and fails confidently. This failure mode is invisible to any in-distribution (circular) test.

The safety gate is exactly for this. Re-scored on saved outputs: out-of-distribution 0.0% → 100.0%; in-distribution 95.7% → 96.1% (it fires on only 9/254, and never on a case the engine got right). Net: never worse than the model alone, and it degrades gracefully. Here is the gate firing on a real out-of-distribution example: the engine produces nothing usable, so the system keeps the model's correct answer.

OOD rank_models tatqa_0000_cols_rank_models_verified

In which year is the amount of total sales the largest?

categoryFixed PriceOtherTotal sales
20191452.444.11496.5
20181146.256.71202.9
20171036.970.81107.7

Model trace (model-authored prose)

  1. select Identify the total sales for each year.
  2. compare Compare the total sales values: 1496.5 > 1202.9 > 1107.7.
  3. conclude The largest total sales is in the year 2019.

Grounded evidence (cells the engine read, by construction)

  • category[row 0] = 2019
  • category[row 1] = 2018
  • category[row 2] = 2017
Engine answernone (rows [])
Model’s own2019 (rows [0])
GateGATE FIRED op not supported by the question → fall back to the model’s own answer
System answer2019 (rows [0]) [model]
Gold2019 (rows [0])

Caveat: the gate is a keyword heuristic validated on a small out-of-distribution set.

Honest limitations

  • In-distribution gold is spec-derived. Now corroborated by two independent blind oracles, but not proven: n is small, trade-off has only the LLM check, the human is one annotator.
  • The trade-off questions are non-natural. A non-specialist couldn't assess "Pareto-optimal" (verifiable, but not how a person would ask).
  • Specialization. The engine helps only inside its trained operation vocabulary; the gate makes that safe, but it is not a general table engine.
  • The trace prose stays model-authored. The system grounds the answer and the cited evidence by construction; an unaided 4B model's own citations remain ~71.3% (near its ceiling).
  • Coverage & domain. Only high-confidence ingested tables (~97%); selection / threshold / frontier reasoning, not TAT-QA's dominant free-form arithmetic; TAT-QA is financial, and transfer to scientific/ML tables is unverified.
  • Provenance. The 12-example anchor is TAT-QA-gold-anchored (their annotations, cross-checked by our independent recompute), not verified by anyone on this project.

See it run

Each card below replays a held-out example through the full path: the model emits an operation, the engine computes the answer and returns the exact cells it read (highlighted), the gate decides whether to trust it. No model is called; these are saved outputs re-assembled on CPU.

in-dist best_under_constraint tatqa_0036_cols_best_under_constraint_normal

Which period has the highest Legal reserve while keeping Cash dividends of at least 8.34063e+06?

categoryLegal reserveSpecial reserveCash dividends
Appropriation of earnings (in thousand NT dollars) Appropriation of earnings (in thousand NT dollars) 2018707299145139406916105
2019963947-34916269765155
Cash dividend per share (NT dollars) 20180.58
2019_10.75

Model trace (model-authored prose)

  1. filter Keep only rows where Cash dividends is at least 8.34063e+06.
  2. compare Row 1 has the highest Legal reserve (963947) among qualifying rows.
  3. conclude 2019 has the highest Legal reserve while keeping Cash dividends of at least 8.34063e+06.

Grounded evidence (cells the engine read, by construction)

  • Legal reserve[row 1] = 963947
  • Cash dividends[row 1] = 9765155
Engine answer2019 (rows [1])
Model’s own2019 (rows [1])
GateGATE PASS op supported by the question → trust the engine
System answer2019 (rows [1]) [engine]
Gold2019 (rows [1])

Generate the full set yourself: python scripts/demo.py (writes results/demo/index.html), python scripts/demo.py --list, or --id <example_id>.

Reproduce

# build dataset / leakage-free split / SFT data
python scripts/build_dataset.py
python scripts/freeze_splits.py
python scripts/build_sft.py

# train + score (WSL .venv-train, RTX 3090; see docs/training_env.md)
python scripts/train_sft.py --model Qwen/Qwen3-4B --out models/qwen3-4b-sft-exec
python scripts/eval_executor.py --dataset data/processed/eval_test.v0_1_0.jsonl \
       --adapter models/qwen3-4b-sft-exec --out results/p3_4b_exec_TEST.json

# re-score on CPU (no GPU): grounding, safety gate, this write-up, the demo
python scripts/eval_grounded.py
python scripts/eval_gate.py
python scripts/build_writeup.py
python scripts/demo.py

ML methodology reference: docs/methodology.html. Consolidated findings: RESULTS.md. Interactive demo: results/demo/index.html.