Write an exam once as a plain Typst document. Get N deterministically shuffled sets, matching answer keys, and machine-readable grading data — the master is the answer key.
Distributing multiple shuffled versions of an exam is standard in large courses, but doing it by hand is error-prone exactly where mistakes cost the most: keys drift from papers, sets end up with different questions, and nobody can reconstruct which shuffle went to print. quizforge treats the exam as a pure function — the setter writes one ordinary Typst document, and every set (question order, MCQ option order, answer key, grading table) is derived deterministically from it.
The master compiles, as-is, into the answer key;
--input set=B --input mode=exam yields the corresponding student paper.
A complete, minimal master — this entire snippet is a working quiz:
#import "@preview/quizforge:0.1.0": * #show: quiz.with( id: "quiz-1", // seeds the shuffles — freeze once printed course: "ES 335: Machine Learning", title: "Quiz 1", duration: "20 minutes", sets: ("A", "B", "C", "D"), answer-grid: true, ) = Multiple Choice + #m(2) Increasing $k$ in $k$-NN typically has which effect? - ✓ Increases bias, decreases variance - Decreases bias, increases variance - None of the above // auto-pinned last #explain[Larger neighbourhoods average more: smoother, stabler.] = Fill in the Blanks + #m(1) Computing inner products in feature space without constructing the features is the kernel #blank[trick]. = Long Answer + #m(3) Derive the normal equation for least squares. #answer(5cm, rubric: [+2 gradient; +1 solving.])[ $nabla L = -2 X^T (y - X theta) = 0 arrow.r.double hat(theta) = (X^T X)^(-1) X^T y$]
Question types are inferred (options → MCQ; #blank[…] → fill-in-the-blank;
otherwise subjective with an answer space). Marks totals, per-part subtotals, cover page,
set codes, and the MCQ answer grid are all computed — the author never does arithmetic.
Structural mistakes (no option marked correct, a blank without an answer, prose stranded
between shuffled questions) fail compilation with a message naming the question.
Everything shown is a real artifact: the actual exam source, real build output, the genuinely shuffled sets, and the generated key. Direct link ↗
All randomization derives from FNV-1a hashes of explicit strings, fed to an xorshift32 Fisher–Yates shuffle implemented in pure Typst — no clocks, no OS entropy, no package dependencies. Rebuilding an exam tomorrow, on another machine, or in CI reproduces it byte-for-byte (the test suite pins a golden CSV and a golden question order that must match across macOS and Linux).
| Randomized quantity | Seed | Consequence |
|---|---|---|
| Question order | exam|set|q|part |
Differs per set; unaffected by editing question text |
| Option order | exam|set|o|question |
Depends only on that question's own identity — editing one question never reshuffles another |
Bank sampling (pick) | exam|sample|part |
No set id in the seed → every set receives identical questions; sampling cannot break fairness |
The paper, the key PDF, and the grading data are all projections of one realized structure per set, so they cannot disagree; the build tool nevertheless independently re-verifies that every printed answer letter, mapped through the recorded permutation, lands on an originally-correct option — and refuses to ship otherwise.
Each card links to the full set-A paper and key. Question bodies are ordinary Typst content — tables, native figures, code listings, chemical formulas, matrices — and third-party packages (cetz, unify, physica) work inside questions, options, and blanks.
Machine LearningkNN & loss-curve figures, confusion matrix all versions ↗
ML 2023 archivereal past-paper questions, ported all versions ↗
Physicsprojectile figure, SI units, derivation all versions ↗
Chemistryformulas, two-blank equation balancing all versions ↗
Algorithmscode traces, true/false, master theorem all versions ↗
Linear Algebramatrices, multi-select, proof rubric all versions ↗
Economicssupply–demand figure, elasticity table, essay all versions ↗
Feature tourevery capability, one question each, with code snippets ↗
= headings open parts (text under a heading becomes that part's
instructions) · + items are questions · - items are options ·
✓/✔/#yes marks correct options (two ✓ → multi-select
automatically) · “None/All/Both of the above” options pin to the last position
automatically · large quizzes may span files via #include. The
feature tour shows every capability below as a minimal
code snippet next to its rendered output.
| Marker | Meaning |
|---|---|
#m(2) | Marks for the question (default 1; fractional allowed) |
#blank[ans], #blank(width: 3cm)[ans] | A blank; the answer prints only in the key and exports to the CSV |
#answer(6cm)[model], #answer(none, rubric: […])[…] | Subjective answer space (or none for booklet-style papers) + key-only model answer and rubric |
#explain[…] | Key-only explanation for an MCQ |
#pin / #pin-first | Pin an option last / first under shuffling |
#opts(shuffle: false, columns: 2, compact: true, multiple: true) | Per-question overrides — compact flows options inline to save space |
#section(shuffle: false) | Freeze question order within a part |
#qid("slug") | Freeze a question's identity across wording edits |
quiz.with(id, course, title, date, duration, sets, answer-grid, instructions,
answer-space, header, footer) — header/footer accept
auto (generated), none, fixed content, or a function of
(exam, set, mode, total). A second, constructor-based front-end
(make-exam with mcq()/fib()/subj(), topic/difficulty filters
and seeded pick sampling) serves semester-scale question banks — see the
package documentation.
Every compiled document embeds a pure-data <answerkey> node
(typst query exports it; the student PDF never contains answers). The build
tool aggregates all sets into answer_key.csv:
| exam_id | set | qno | question_id | type | marks | answer | option_order |
|---|---|---|---|---|---|---|---|
| es335-quiz-1 | A | 3 | es335-loss-curves | mcq | 2 | D | 2|3|4|1 |
| es335-quiz-1 | A | 4 | q1487422410 | mcq | 2 | AB | 1|2|4|3 |
| es335-quiz-1 | B | 9 | q2146314541 | fill_blank | 1 | trick |
Grade MCQs by joining student responses on (set, qno);
option_order records each set's full permutation for audit. A
manifest.json stores the SHA-256 of every PDF, proving which files went to
print. Sample CSV ↗
The suite currently runs 490 checks: an invariant matrix over every set of every exam in the repository; a 24-seed structural fuzzer that generates random exams (section counts, question types, option counts, pins, multi-select, fractional marks, sampled sections) and re-verifies all invariants; RNG position-uniformity with a pinned hash regression; 100-question papers under a time budget; third-party package content (cetz, unify, physica) in questions, options, and blanks; CSV escaping round-trips; and byte-reproducibility of PDFs.
git clone https://github.com/nipunbatra/quizforge cd quizforge make install-local # @local/quizforge:0.1.0, usable from any project make watch # live preview while writing (the master = the key) make build EXAM=exams/quiz3.typ # all sets + keys + answer_key.csv + manifest
Requires Typst ≥ 0.13; the Python tooling (build/tests)
is optional and uses only the standard library. Publication to
Typst Universe is planned as
@preview/quizforge; the package directory is submission-ready.
@software{quizforge2026,
author = {Batra, Nipun},
title = {quizforge: deterministic randomized exam sets in Typst},
year = {2026},
url = {https://github.com/nipunbatra/quizforge}
}