quizforge.

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.

480checks
8demo exams
0dependencies
MITlicensed
$ make install-local Get started
01 · Overview

The exam as a pure function

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.

Student paper, page 1
Student paper (set A) — cover, instructions, answer grid
Answer key, page 2
Answer key — ✓ options, filled blanks, model answers, rubrics

·90-second demo

Everything shown is a real artifact: the actual exam source, real build output, the genuinely shuffled sets, and the generated key. Direct link ↗

02 · Design

Randomness as a hash, not an accident

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 quantitySeedConsequence
Question orderexam|set|q|part Differs per set; unaffected by editing question text
Option orderexam|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.

04 · Reference

The document grammar

= 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.

Inline markers

MarkerMeaning
#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-firstPin 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

Exam parameters

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.

05 · Grading

Every answer, as data

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_idsetqnoquestion_idtypemarksansweroption_order
es335-quiz-1A3es335-loss-curvesmcq2D2|3|4|1
es335-quiz-1A4q1487422410mcq2AB1|2|4|3
es335-quiz-1B9q2146314541fill_blank1trick

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 ↗

06 · Guarantees & testing

What the build refuses to ship

  1. Determinism. Sets are pure functions of (exam id, set id, question identity); rebuilds are byte-identical, across platforms. Golden files pin a full grading CSV and a realized question order; CI re-derives them on Linux.
  2. Fairness. Every set contains exactly the same questions and total marks; only order differs. Verified for every exam in the repository, every set.
  3. Key correctness. Printed answer letters, mapped through each set's recorded permutation, land on exactly the originally-correct options — re-checked independently by the build tool at every build.
  4. Compile-time validation. 37 classes of authoring mistakes are rejected at compilation with messages naming the offending question.

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.

07 · Getting started

Three commands

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.

Citing

@software{quizforge2026,
  author = {Batra, Nipun},
  title  = {quizforge: deterministic randomized exam sets in Typst},
  year   = {2026},
  url    = {https://github.com/nipunbatra/quizforge}
}