Math Worksheet Generator
Generate a student worksheet PDF + full step-by-step answer key PDF for any math topic from elementary through AP Calculus BC. Compiles LaTeX with tectonic (no TeX installation required — it auto-downloads packages).
This skill is agent-agnostic: it works in any agent harness that can read files and run shell commands (Claude Code, Gemini, Codex, OpenClaw, etc.). It uses no harness-specific primitives; every step is plain file I/O and shell commands, and the platform-specific delivery step is optional with a portable fallback.
Throughout this document, $SKILL_DIR means the directory containing this SKILL.md. Resolve it once at the start (e.g. SKILL_DIR=/path/to/math-worksheets) — do not rely on $0, which is only meaningful inside a script. In Claude Code the skill's directory is already available as ${CLAUDE_SKILL_DIR}, so SKILL_DIR="${CLAUDE_SKILL_DIR}" works; other agents resolve the path however they locate the skill.
A note on accuracy
Generate the problems with whatever model your agent is already running. Correctness does not depend on the model getting the math right the first time: the SymPy verification gate (steps 4–5) independently checks every answer and blocks the build if any check fails, so a wrong answer never reaches the PDF. Stronger reasoning models simply need fewer retries to pass the gate. The skill does not detect, switch, or recommend models.
Limitations
What the gates guarantee is bounded, and the boundaries are deliberate:
- Manual answers are not machine-verified. Proofs, constructions, sketches, and "explain your reasoning" responses are declared
manualin the verification file and printed with a ♠ marker in the answer key; only a human can grade them. A sheet with zero machine-checkable answers refuses to build unless explicitly acknowledged (step 4). - Verification proves the math and its transcription, not pedagogical fitness. A green gate chain means every printed answer was recomputed and matched — not that the problems teach the requested topic well or ramp sensibly. Those judgments stay with the human reviewing the output.
approxand trap checks confirm the arithmetic of the formula you wrote, not that the formula fits the story. Keep expressions faithful transcriptions of the problem's givens; see the trust-boundary notes in step 4.- The verifier's guarantee is version-specific. Its accuracy was measured against ground-truth corpora on a pinned SymPy release; every run stamps the version it used and flags drift from the measured baseline.
Prerequisites
tectonic (LaTeX compiler — auto-downloads packages on demand). Install with whichever is available:
brew install tectonic # macOS or Linux with Homebrew
cargo install tectonic # any platform with Rust
sudo apt install tectonic # Debian/Ubuntu (recent releases)
# or download a release binary: https://github.com/tectonic-typesetting/tectonic/releases
python3 + sympy for answer verification: pip3 install sympy. The scripts pick the first python3 that can actually import sympy (machines often carry several pythons); pin one with MWS_PYTHON_CANDIDATES=/path/to/python3 if needed.
No tectonic? scripts/compile.sh falls back to pdflatex automatically. Unlike tectonic, pdflatex does not download packages — install the full set up front: texlive-latex-base texlive-pictures texlive-latex-recommended texlive-latex-extra (the last one supplies enumitem, mdframed, and friends used by the templates).
Output directory (create if needed): ~/Documents/Worksheets/. In headless or sandboxed environments where the user won't browse ~/Documents, use ./worksheets/ inside the workspace instead and report the paths.
Workflow
1. Gather requirements
Ask (or infer from context):
- Student: name, grade, course (e.g. "8th grade, Pre-Algebra")
- Topic: e.g. "factoring trinomials", "solving two-step equations"
- Problem count: default 10 if not specified
- Page use: the page budget is COMPUTED from the problem set (
scripts/page_budget.py), not fixed. Fifty graphing problems that each need a coordinate plane legitimately run past 20 pages and are allowed to; a flat cap could only be met by shrinking the work space, which is the one thing that must never give.build.shprints the ideal page count and the double-sided sheet count before compiling, so paper cost is visible. If a set is bigger than intended, reduce the PROBLEM COUNT, never the work space. Hard ceiling: 100 problems — past that, split into several worksheets - Paper size: US Letter by default. For A4 or Legal, add the paper as a documentclass option (
\documentclass[12pt,a4paper]{article}) with metric geometry margins, and pass--paper a4topage_budget.py. Seereferences/latex-templates.md→ "Paper size" - Accessibility: for large-print or dyslexia-friendly output use
\documentclass[17pt]{extarticle}(or 14pt) plus\accessiblemode{large|dyslexia|both}. The page budget adapts automatically; larger type simply means more pages. Seereferences/latex-templates.md→ "Accessibility" - Locale:
\mwslocale{eu}prints decimal commas and\times. The verify JSON stays canonical (period decimals) — only the printed form is localised, via\dec{}and\mtimes. Pair this with A4 paper for European users - Format preference: timed quiz, homework practice, mixed difficulty, or topic drill.
For a timed quiz, point values are the default: place
\probpts{N}at the end of each stem andScore: \underline{\hspace{1.5cm}} / \totalpoints\ ptsin the title block (rendered in step 4c). Difficulty stars (\probmeta{N}) are opt-in for practice/mixed-difficulty sheets on request; skip markers for early-elementary drills unless asked.
Photo input shortcut: If the user provides a photo of homework or a textbook page, read the image with whatever vision capability your agent provides (or by reading the image file directly) to extract problem types, format, and difficulty, then mirror that style exactly.
2. Design problems
Design problems appropriate to the student's level. Increase difficulty gradually across the set. Every problem must be mathematically correct — verify your own solutions.
See references/problem-library.md for topic-specific problem type menus.
The grade level never prints on the worksheet or the study guide. \wstitleblock takes a course argument and does not typeset it: a child working a grade below or above reads the label before the mathematics, and it tells the student nothing they need. The level is the adult's information, so it prints on the ANSWER KEY — in the title block and again in a generated Curriculum section beside the standards codes and the difficulty range, which is where a parent or teacher actually looks. Keep passing the course argument: it is the one place the level is declared, and the answer key's curriculum section reads it from there.
Tag every problem with "standard" (a code from references/standards-map.md — never invent codes, and grep that file before concluding it lacks a row: 11 reviewed cases asserted a missing row that was there, two of them naming the exact code they called absent, and then tagged a high-school sheet with a grade-8 standard), "difficulty" (1–5 per that file's ladders), "bloom" (recall/apply/analyze/justify, same file), and "skill" (a short stable name for the skill the problem exercises, e.g. "right-triangle-trig" — reuse the same name across problems of the same Part). Skill tags are all-or-nothing and GATING: the study guide must contain an entry tagged with each distinct worksheet skill, enforced by build.sh's coverage-ss gate; a partially tagged sheet fails it. Ramp difficulty: start at 1–2, majority 2–3, end with one or two 4–5 challenges. Verification reports standards coverage, the bloom mix, the skill mix (the set the study guide must cover), and flags ramp drops, so a parent can see exactly which standards the sheet exercises and at what cognitive level.
Facets (skill coverage): a facet is a distinct method the sheet tests (e.g. side-from-angle vs angle-from-sides vs pythagorean) — standards codes are usually one constant per sheet and cannot show this. For sheets of 10+ problems, declare top-level "facets": [...] in the verify JSON and tag every problem with a "facet" from that list; consult references/problem-library.md → "Facet checklists" (topics without a table use your own lowercase-kebab names). The gate is strict: an unlisted facet, a planned facet with zero problems, or an untagged problem is a build failure. Tag TRUTH is your responsibility — the gate verifies plan/tag consistency, not that a tag describes its problem. Tag the verify_ss_ entries' "facet" too: the facet-coverage gate (step 5) requires every worksheet facet to have a study-guide worked example.
Interleave after the warm-up: a blocked warm-up over the first third of the sheet is fine; after that, keep same-facet runs to 3 or fewer so the student must choose the method — unless the sheet is genuinely a drill, declared with top-level "format": "drill". verify.py flags longer runs (exit 2, manual review) with a concrete swap suggestion. See references/problem-library.md → "How to interleave".
Misconception traps: consult the misconception table (references/problem-library.md → "Misconception traps & error analysis") when choosing givens — pick numbers so the canonical wrong method lands visibly off the correct answer — and declare "traps" on the 2–3 problems you designed that way. Traps are optional, but declared ones are machine-checked (step 4) AND printed in the answer key as a "Common wrong answers" block (scripts/render_quick_answers.py emits one \commonerror line per trap, so the grader sees "If they got 7.37: used cos instead of tan"). Declaring traps therefore pays twice: it hardens the problem design and it turns grading into teaching.
Tiered worksheets (differentiation, on request): when asked for tiers (support/on-level/challenge), build ONE set of problem skeletons, then re-parameterize per tier — same structure and standards, different givens and difficulty band (support: 1–2 with a hints box and a worked first step; core: 2–3; challenge: 3–5). Each tier gets its OWN verify JSON (the gate re-checks every tier) and file prefix: wsS_/wsC_/wsX_ + matching keys. Because construction is JSON-first, a tier is a data change, not a rewrite.
Tier naming for the build driver: give each tier its own stem — verify_factoring_S_2026-07-27.json with wsS_factoring_S_2026-07-27.tex (or plain ws_factoring_S_...; both are discovered). What matters is that two tiers never share a stem in one directory: scripts/build.sh derives the document names from the verify JSON's stem, and two candidates for one role is a hard discovery error, not a guess.
3. Write LaTeX source
Write three .tex files to /tmp/:
ws_TOPIC_DATE.tex— student worksheet (blank work areas)ak_TOPIC_DATE.tex— answer key (full step-by-step solutions)ss_TOPIC_DATE.tex— skills summary / study guide (cheat sheet)
The skills summary is a reference card (2 pages by default — see "Length" below) the student can use while working through the worksheet or when studying. Per skill section: formulabox → examplebox → tryitbox. Its structure is not house taste; each element carries a documented learning effect, and the macros exist so using the effect costs one line:
- The worked example IS the worked-example effect: novices learn more from studying a worked solution than from solving, and the
\stepstructure with a strategy first line is what makes the reasoning visible rather than just the arithmetic. - The try-it is retrieval practice — the single best-supported study behaviour there is. It re-parameterizes the example (same skeleton, new givens) and prints only the stem plus the upside-down answer, so checking requires attempting first.
\why{...}(optional, at most one per example) prints a one-line self-explanation aside after a step — the reason the move is legal, not a restatement of it. A printed model of self-explanation teaches the habit the research prompts train. Keep it conceptual and number-free; the prose checker reads any numbers in it against the entry.\fadestep{...}(optional, inside a try-it) turns it into a completion problem: the setup shown ("Started for you: …"), the finish left to the student. Backwards-fading measurably beats the straight example-to-problem jump on multi-step skills — use it when the example needed 3+ computational steps, skip it on one-move skills where the bare try-it is the right next step. End the box with the check answer right-aligned:\hfill\rotatebox{180}{\footnotesize check: $\ans{...}$}.- Dual coding: when a skill is spatial (trig ratios, transformations, area models, number lines), put a small value-free diagram in its formulabox next to the rule, built from the shipped figure macros or house styles — a rule the student can see is retained better than the same rule in symbols alone. Value-free, so the figure-scope rules never apply; budget for it via "pages" below if two diagrams push past the default.
- Boxes carry printed title tabs (RULE / EXAMPLE / TRY IT / WATCH OUT) so a student scanning for the formula finds it without reading — do not add your own headings inside boxes.
It contains:
Length. The default budget is 2 pages, and the discipline of the small page is most of what makes a reference card one. Declare a top-level "pages" (integer 1–6) in the verify_ss JSON only when (a) diagrams the skills genuinely need — a unit circle, a transformation grid, labelled solids — do not fit the default, or (b) the user asked for a longer guide. verify.py validates the field; build.sh reads it for the compile-ss page cap. The five-full-sections ceiling below scales with it: about 2–3 full sections per page.
Study guide without a worksheet. When the user wants ONLY a study guide ("make Leo a reference sheet for the unit circle"), write just ss_TOPIC_DATE.tex + verify_ss_TOPIC_DATE.json and build with bash scripts/build.sh /tmp/verify_ss_TOPIC_DATE.json --study-guide-only. Every guide-integrity gate still runs (verify, template, compile with page cap, answer binding, structure, prose, overprint); only the sheet-relative gates (skill coverage, facets) skip, because there is no sheet. The guide's worked examples are still fully machine-verified.
- One section per distinct skill tested, matching the worksheet's
"skill"names. The slug belongs in the JSON, not in the heading —\skillheadingis gated at 57 characters (the budget includes the"Skill N --- "prefix, which eats about 12), and the coverage gate reads the JSON tag, never the printed title. Six eval-run agents overflowed this heading by echoing the slug in parentheses to make the tagging visible to a reader; it does not need to be visible there. Sections are —build.sh'scoverage-ssgate fails the build if any worksheet skill lacks a tagged study-guide entry. Five full sections is the hard ceiling for the 2-page cap (a full section costs ~234pt against a 1382pt two-page budget); aim for 3–4. When over budget, drop the watch-out box first, then merge two thin skills, then shorten worked examples — never drop the try-it. Add no\vspacebetween boxes; they carry their own spacing. Seereferences/latex-templates.md→ "Page budget" - A formula/rule box (blue) per skill — the key facts and formulas
- A mini worked example (green) per skill — a
\stepstrategy line naming why this tool applies, then the computation — fewer steps than worksheet problems, never a bare answer chain (see the exemplars inreferences/latex-templates.md) - A try-it (violet) per worked example — a re-parameterization of that section's example (same skeleton, new givens), printing ONLY the stem plus the verified answer upside down INSIDE the box via
\rotatebox{180}{\footnotesize check: $\ans{...}$}— no worked steps; solving it is the student's job. Formula-only sections (no example) are legal and need no try-it - An optional watch-out box (orange) — common mistakes worth flagging
- Optional key vocabulary section at the bottom
See references/latex-templates.md → "Skills Summary / Study Guide Template" for the full shell and box macros.
Verify the study guide too. Its worked mini-examples are math the student learns from first, so they must not be exempt from the gate (audit 3c). Write a second verification file /tmp/verify_ss_TOPIC_DATE.json — one entry per worked example's computation AND one per try-it. Entries MUST be listed in document order (example, try-it, example, try-it, …) because binding is positional; tag each try-it entry "role": "tryit" and each entry with the "skill" it demonstrates. The build driver (step 5) verifies it and binds its printed answers automatically; a missing ss_ document or verify_ss_ JSON is a build failure, not a skip. Formula boxes (no computed answer) need no entry; every worked example or try-it with a printed result does. check_answer_key.py segments ss documents by box (examplebox/tryitbox) — keep one worked example or try-it per box, and one id per box — so box count = problem_count = the number of distinct ids. A box whose example needs more than one check (a distance AND the perimeter built from it) may carry several entries under that one id; what must not happen is two boxes sharing an id, or one box holding two, and print each result with \ans{...} (defined in the study-guide shell); a result set in bare \boldsymbol is invisible to the gate. A try-it may be type manual only when its paired example is also manual (exit-2 visibility, never silent). The structure gate (tests/check_study_guide.py) additionally requires ≥2 \step lines and a boxed final answer per examplebox — the first step is the strategy sentence, capped at ONE sentence (page 1 fills fast and the guide is hard-capped at 2 pages — enforced mechanically: the compile-ss gate reads the page count from the engine's log and fails a 3-page guide).
Subtitle from facets: when "facets" is declared, write a top-level "subtitle" in the verify JSON composed from that facet list first, then copy it verbatim into the worksheet's title block. The facet-coverage gate (step 5) binds the two, so the title can never promise a skill the sheet doesn't plan.
See references/latex-templates.md for document templates, coordinate planes, tables, geometric figures, and answer key patterns.
Use the figure HOUSE STYLES before raw TikZ — references/latex-templates.md → "Figure house style". The preamble ships graph styles (wsgrid, wsgridwide, wsgridq1, wstrig, wsfuntall + wscurve/wsasym/wsopen/wsclosed), chart styles (wsbar, wshist, wsboxplot, wsboxplot pair, \wsdotplot, wsstemleaf, \wspictorow) and figure-macros.tex ships geometry marks (\congtick, \parallelmark), a transformation grid (\gridtrifig), fixed-size solids (\cylfig … \cylnetfig) and the K-4 model set (\tenframefig, \arrayfig, \basetenfig, \clockfig, \fraclinefig, \tapefig, \coinrowfig, …). Four rendered-page reviews found the raw-TikZ route producing grids students cannot plot on, self-overprinting solids, color-only semantics that die in photocopy, and hand-rolled K-4 models with broken proportions — the styles encode the fixes once. Two standing rules: figures are SPACE-GENEROUS (budget workspace_cm for the style's natural size rather than scaling it down), and every "sketch/plot" problem's answer key prints the model graph on the same grid style the worksheet used.
Draw diagrams in TikZ, not as imported images. TikZ uses the document's own fonts and line weights, so a figure's labels match the surrounding mathematics exactly — an imported bitmap or an externally-drawn PDF does not, and cannot be regenerated when the numbers change. The shipped preamble loads TikZ, pgfplots and the libraries below for every document, so nothing needs requesting:
| Library | Use it for | Template |
|---|---|---|
arrows.meta |
number lines, vectors, mapping diagrams — -{Stealth} reads as an arrow at small sizes where a bare -> reads as a tick |
"Number line" |
patterns |
shaded fraction / percent / probability area models; hatching survives photocopying where a light grey fill does not | "Shaded area model" |
decorations.pathreplacing |
braces on bar models and part-whole diagrams | "Brace annotating a part" |
pgfplots: statistics |
box plots | "Box plot" |
pgfplots: fillbetween |
area between curves | "Area between two curves" |
pgfplots: groupplots |
two displays with shared limits | "Two displays for a compare item" |
adjustbox |
scaling a figure that is genuinely too wide | "Fitting an oversized figure" |
positioning, intersections, shapes.geometric |
relative node placement, named-path constructions, sorting-diagram nodes | — |
The binding rule applies to every one of them: a figure's numbers come from the same JSON the checker reads. The sharpest trap is the box plot. Hand pgfplots raw data and it computes quartiles by an interpolating convention, while verify.py's stats type uses school median-of-halves — on [4, 6, 7, 9, 11, 12, 18] verify says median 9, q1 6, q3 12 and pgfplots draws median 8 with a box from 5 to 11.5. The sheet would print a figure contradicting its own answer key, and no gate reads inside a plot. Declare the five-number summary as stats entries and pass those values with boxplot prepared.
forest, tikz-cd, circuitikz, chemfig and quantikz are deliberately NOT loaded — see the reasons in templates/worksheet-preamble.tex. Factor trees and probability trees, the one genuinely mathematical case, draw fine with TikZ's built-in child syntax at no extra load cost.
Answer-key shell: the ak_ document starts exactly like the worksheet
(\input{worksheet-preamble}), then \akheader{TOPIC} + \aktitleblock{...}, and ONE
\input{qa_TOPIC_DATE} line directly under the title block — the build driver's
quick-answers gate regenerates that bank from the verify JSON every build and fails a
key that hand-rolls its preamble or never \inputs the bank (both messages teach the
fix). \akheader switches \ans{...} to a compact same-line box, so end each worked
solution's last step with \ans{x = 5.00}; keep the display \[ \boxed{...} \] form
for long or multi-value answers. See references/latex-templates.md → "Answer Key
Patterns".
Preamble — \input the shipped template, never retype it. Start every document with:
\documentclass[12pt]{article}
\usepackage[margin=1in, top=0.75in, bottom=0.75in]{geometry} % ss_: margin=0.85in, top/bottom 0.7in
\input{worksheet-preamble}
\input{figure-macros} % when using the shipped figure macros (\rtfig, \trifig, \refrt)
$SKILL_DIR/templates/worksheet-preamble.tex carries the packages, \problem, \fittedtitle, the \wsheader/\akheader/\ssheader + title-block macros, and the study-guide box environments; templates/figure-macros.tex carries the figure macros. compile.sh (and therefore build.sh) stages both files beside your .tex automatically, so /tmp compiles just work — but only when you compile through the scripts, never by invoking the engine directly. Only geometry stays in the document (margins differ between worksheets and study guides). build.sh enforces this mechanically — the template-ws/template-ak/template-ss gates (tests/check_template_use.py) fail any document that hand-rolls its shell, \inputs a non-shipped preamble file, or redefines a shipped macro/environment/color.
Work space defaults: \problem[5cm]{...} per problem; 8cm for multi-step; 10cm+ for graphs.
The workspace must live inside the problem's unbreakable block (the \problem macro's
minipage, or a per-item minipage in lists — see the templates): \vspace glue outside it
is silently discarded when it falls at a page or column break, leaving a bottom-of-page
problem with zero room to work. These are a floor, not a suggestion: tests/check_layout.py
fails a worksheet whose problems get under 2.5cm and flags workspace \vspace left outside
a minipage. A sheet with correct answers and nowhere to write them is a sheet the student
cannot use.
Page overrun: count problems per page BEFORE choosing a remedy. page_budget.py
prints two NOTEs whose fixes point in opposite directions, and applying the wrong one
makes the sheet worse. pdftotext the failing PDF and look at what actually landed on
a page:
- 2 or more problems per page — the blocks pack fine and the budget is simply
under-measuring them (its stem charge is a flat 0.6cm). Declare
workspace_cmcovering the block's real height. That is the honest move: the declaration is a measurement, and it raises the ceiling without adding content. - 1 problem per page — the block is taller than half a page, so nothing can share
with it and the rest of every page is stranded. Shorten the block — a smaller
figure, or the work space beside the figure instead of under it. A larger
workspace_cmhere raises the ceiling without recovering one centimetre of the stranded space, and it is no longer a measurement of anything.
A side-by-side minipage[t] figure/question layout is the usual way to recover that
stranded whitespace, and it compresses no work space — but put \vspace{0pt} first in
BOTH columns, or a tikzpicture's bottom-sitting baseline makes the columns stack and
doubles the block instead (the tell: shrinking the figure changes the page count by
exactly zero).
Answer location (every problem gets one): \problem sheets emit the right-aligned
answer blank automatically whenever the workspace argument is positive — write nothing
extra. On enumerate/\item sheets, end every item with \ansline (or an inline
\ansblank for drill formats: $7 + 5 =$~\ansblank). Mark \noansline only where the
worked product IS the answer — graph sketches, proofs, constructions (typically the
manual-type set). tests/check_layout.py enforces one answer-location macro per item;
answer keys and study guides are unaffected.
Units are verified data, not decoration: any problem whose final answer carries a
measurement unit declares "answer_unit" in its JSON entry ("ft", "m", "cm^2",
"square units" — distinct from the deg/rad angle-mode field "unit") and ends with
\answerline{<unit>} on the sheet (write exponents math-wrapped: \answerline{cm$^2$};
\answerline replaces \ansline there and suppresses \problem's automatic blank).
The key must print the same unit inside \ans{}/\boxed{} (\text{ft}).
Both directions are gated: tests/check_answer_line.py fails a declared unit with no
matching \answerline and an \answerline unit the JSON never declared;
tests/check_answer_key.py fails a key whose box omits the declared unit or prints a
unit the JSON never declared — a metres problem answered in feet no longer passes any
gate. Prefix currency symbols are out of scope: write "in dollars" in the stem.
Figure scope: a figure carrying numbers belongs to one problem, but on the page it
merely sits near several. If problem 6 shows a triangle labelled a=6, b=8 and
problems 7-8 show none, a student reading 7 will apply the nearest figure to it — the
figure is correct and the worksheet is still wrong. So within one problem list, no
problem may be left with no figure at all while another problem's figure carries
values. A problem holding its own value-free figure — a blank coordinate grid to
plot on, an unlabelled diagram to mark up — is fine, and mixes freely with graphed
problems beside it: the picture inside its block is visibly the one it means, and a
grid's axis numbers are its own scale, not another problem's data.
Shared labelling conventions go in a single value-free reference figure placed with the
directions, captioned so it cannot be mistaken for a problem's givens, e.g. "How every
triangle here is labelled. No values shown: use the numbers given in each problem."
Use the shipped \refrt macro for it. Its convention is the renderer's: sides a/b/c
opposite vertices A/B/C, right angle at C, hypotenuse c — the reference figure and
the renderer-built \probfig figures share one page, so
tests/test_figure_convention.py fails the build if the macro and
render_figures.py ever mark the right angle at different vertices.
tests/check_layout.py enforces the scope rule.
A data display several problems read from goes ABOVE the first \problem.
The scope rule is all-or-nothing per problem list, and a data display IS its
values, so the rule's usual remedy — make the figure value-free — cannot apply
to it. A bar chart set inside problem 3 of a mixed graph/table/algebra sheet is
a valued figure beside figureless problems, and check_layout fails the sheet;
an author who does not know the pattern deletes the chart or fights the gate.
Problem regions run from one \problem to the next, so a display placed before
the first one belongs to no region and is scoped to none of them. Caption it
with the problems that use it — "Problems 4-7 refer to this chart" — so a
student is never left guessing which givens are theirs. Five eval batches
arrived at this independently; it is the standard answer for mixed-representation
sheets.
Know what that costs, because nothing else will tell you. A display above
problem 1 is outside every problem region, so figure_label_numbers never reads
its printed values against the JSON: a drifted axis tick or a mis-plotted point
on a SHARED graph is invisible to the entire gate chain, on the one figure the
most problems depend on. It is also invisible to the page budget — charge its
height to the problems that consume it via workspace_cm. So: source the
plotted values from the same data array the read_data checks use (never
retype them), and read the rendered page yourself before shipping. Hoisting a
figure out of a problem to quiet the scope check, when only that problem uses
it, trades a gate that works for one that cannot run — keep a single problem's
figure inside its own block.
Triangle figures MUST come from the renderer, never hand-drawn TikZ with values.
Step 4b generates \probfig{N} macros from the verify JSON — every triangle problem
automatically, plus approx and eval problems that declare a "figure" object. A
write-the-ratio problem ("write $\tan A$ as a fraction") is verified as eval and is
renderer-figured like any other right triangle — the renderer covers it, so the
hand-macro escape hatch never applies to it. Reference the macros
in the worksheet instead of writing TikZ: hand-computed figure coordinates are exactly
the retyping drift the JSON-first pipeline exists to prevent (the reference SSA swing
figure itself shipped with wrong hand-computed constants until the renderer replaced
them). Leave a \probfig{N} placeholder while writing the .tex; the macro exists
once step 4b's figs file is \input. Hand-built TikZ per references/latex-templates.md
remains the path only for figure kinds the renderer doesn't cover — circles, sectors,
solids, transversals, coordinate grids. \includegraphics in worksheet problems is
rejected as unverifiable (check_prose_consistency.py exits 2 — no checker can bind an
external image's values to the JSON): figures come from scripts/render_figures.py or
the TikZ templates, never from image files.
4. Write and run the verification file
Before compiling, write /tmp/verify_TOPIC_DATE.json — a structured data file describing each problem and its expected answer. The bundled scripts/verify.py evaluates this using SymPy. No generated code is ever executed. The build driver (step 5) runs it first and fail-fast; to iterate on verification alone, see "Debugging individual gates" below.
Field reference that cannot go stale: python3 "$SKILL_DIR/scripts/verify.py" --schema prints every type's required/optional fields, the allowed functions/constants/variables, and one working example per type, generated live from the enforced schema (--schema json for machine-readable output).
JSON format — always set problem_count to the number of problems on the worksheet. Two gates enforce coverage, and they are not the same gate:
verify.pyhard-fails unless every problem id 1..N has at least one check. This counts problems.tests/check_answer_slots.pyhard-fails unless every id has one entry per response the problem asks for — every printed\ansline/\ansblank/\answerline, and every lettered(a)/(b)/(c)sub-part in the stem. This counts answers.
The answer key reads: header, Quick Answers, worked solutions — then a final page of its own. The generated bank emits the verification and curriculum summaries (and the common-wrong-answers table) via \AtEndDocument, so they claim the LAST page automatically; the ak_ author writes nothing extra and the one-\input contract is unchanged. Answer keys also breathe by design: \akheader switches on rubber inter-problem space, \raggedbottom, and a small stretchable \parskip — the key's reader is a grader scanning solutions, and a page set solid reads as a wall. The worksheet keeps its measured fixed layout (work space there is a promise to the student).
The answer key says what it can and cannot vouch for. Its Quick Answers bank has three states, and a "What is verified" note names the problems in each: a plain value is machine-checked; $\spadesuit$ (a spade — chosen because no maths in these documents uses the glyph, where the old --- sat one glyph from a minus sign) marks an answer only the instructor can judge, with the worked solution stating what a correct response must contain; and [unchecked] marks a problem printing more responses than the verification covers, where at least one printed answer carries no guarantee. The note is silent on a fully verified sheet — a legend explaining marks that never appear is the noise that makes real warnings invisible. Aim for a key with no [unchecked] marks; when one is unavoidable, the instructor at least knows where to look instead of trusting a complete-looking column.
When a problem carries more than one entry, give each a "slot" — the name of the response it covers ("AC", "the ones digit", "(b)"). No check reads it; the Quick Answers bank prints it, so a grader can tell which value answers which question. Without it the bank joins values in array order, and a sheet asking for "AC and BD" printed 2. 2.83, 8.49 with BD first.
A slot label is a promise about FORM, and tests/check_answer_slots.py holds you to the narrow cases it can read. The run-2 judge rejected a sheet whose bank row read the equation = 6 (the check verified the slope; the student writes y = 6x) and another whose word form / colon form / fraction form slots all carried the identical value 3/5. Three hard-fail rules, each measured 100%-precise on the 2953-slotted-entry corpus: a slot whose head noun is equation must have = in its value — key the equation itself with equiv (expr "y - 6*x", expected "y = 6*x"), or rename the slot to what the check verifies ("of the equation", "equation value at 4" and similar part-namings are exempt); a colon form or word form slot names a transcription the expression grammar cannot hold (3:5, words) — declare that response manual with the printed form in the desc, and let the fraction-form entry carry the machine check.
The second gate exists because the first was mistaken for it. "At least one check per problem" was read as "a partially-verified answer key can never slip through", and those are different promises: a problem printing three blanks satisfies the per-id rule with one entry. In a 300-case review, 172 shipped a printed answer that nothing verified — often the very skill the problem was tagged with. If a problem asks the student to write the number word and the ones digit, that is two entries. If a blank is working space rather than an answer, print it with \scratchblank and it is not counted. Use it for a scaffolding blank that transcribes a given — "$y$ of $C$ ___ $-$ $y$ of $B$ ___" where both coordinates are printed in the stem — and for a value the problem already asked for elsewhere. A check on either is vacuous, and dressing one up as a verified answer is the same overclaim the gate exists to stop:
{
"topic": "derivatives and trig",
"problem_count": 17,
"problems": [
{"id": 1, "type": "solve", "expr": "x**2 - 5*x + 6", "expected": [2, 3]},
{"id": 2, "type": "factor", "expr": "x**2 - 7*x + 12", "expected": "(x-3)*(x-4)"},
{"id": 3, "type": "eval", "expr": "(x-1)*(x+2)", "at": {"x": 0}, "expected": -2},
{"id": 4, "type": "zeros", "expr": "x*(x-3)**2", "expected": [0, 3]},
{"id": 5, "type": "expand", "expr": "(x+2)**2", "expected": "x**2 + 4*x + 4"},
{"id": 6, "type": "diff", "expr": "x**3 - 4*x", "expected": "3*x**2 - 4"},
{"id": 7, "type": "integrate", "expr": "6*x**2", "expected": "2*x**3"},
{"id": 8, "type": "limit", "expr": "sin(x)/x", "to": 0, "expected": 1},
{"id": 9, "type": "equiv", "expr": "sin(2*x)", "expected": "2*sin(x)*cos(x)"},
{"id": 10, "type": "solve_interval", "expr": "2*sin(t) - 1", "var": "t",
"interval": [0, 360], "unit": "deg", "expected": [30, 150]},
{"id": 11, "type": "approx", "expr": "9*tan(35*pi/180)", "expected": 6.30, "tol": 0.01},
{"id": 12, "type": "distance", "points": [[1, 2], [4, 6]], "expected": 5},
{"id": 13, "type": "midpoint", "points": [[2, -3], [8, 7]], "expected": [5, 2]},
{"id": 14, "type": "slope", "points": [[2, 1], [2, 9]], "expected": "undefined"},
{"id": 15, "type": "polygon_area", "points": [[0, 0], [5, 0], [6, 4], [1, 3]], "expected": 17},
{"id": 16, "type": "triangle", "given": {"a": 7, "b": 11, "C": 34},
"solve_for": "c", "expected": 6.51},
{"id": 17, "type": "manual", "desc": "Graph sketch — verify visually"}
]
}
Type reference:
| Type | Verifiable? | What it checks |
|---|---|---|
solve |
✅ | Roots of expr=0 match expected list (optional var, default x). An equation with no solution is keyed "no solution" and an identity "all real numbers" — both are decided from the expression, not from an empty root list, so keying an identity [] fails. Free symbols beyond var make it a literal equation: it solves for var in terms of the others |
zeros |
✅ | Zeros of expr match expected list (duplicates collapse) |
factor |
✅ | Factored form is equivalent to expr |
expand |
✅ | Expanded form is equivalent to expr |
eval |
✅ | expr evaluated at given values matches expected |
diff |
✅ | Derivative of expr matches expected (optional order) |
integrate |
✅ | Expected antiderivative differentiates back to expr — omit the +C |
limit |
✅ | Limit of expr as var → to; optional dir: "+", "-", "+-" (default) |
equiv |
✅ | expr and expected are the same function (trig identities, simplification). The only type that accepts an equation: where the student's answer is an equation, write it as one — "expected": "(x-3)**2 + (y+5)**2 = 25" — and the check compares lhs - rhs exactly as before. Do this whenever the ask is "write it in ⟨form⟩" for a locus (centre-radius, standard form of a conic); leave it |
…(truncated)