Overview
A Python library that marks typed maths answers the way a careful teacher would. It treats 7/2, 3 1/2, 3.5 and 3\frac{1}{2} as one answer, reads LaTeX, unicode and SymPy spellings, and compares exact fractions rather than using a flat tolerance, so -380 never passes for 380 and 2 never passes for 12. Units are aliased and checked (12 cm is not 12 km, 42 weeks is not 42 days), scientific notation must be written as scientific notation, and answers wrapped in a sentence ('The volume is $50\pi$.') are graded by extracting whole values, never substrings. Algebra keeps its form: (x-6)(x-3) matches (x - 3)(x - 6) but an expanded answer does not, unless you opt in to equivalence. Per-question flags cover significant figures, multi-part answers, rounding of exact values and multiple-choice ties, and every verdict names the rule that decided it. Every SymPy parse goes through a guard that refuses injection input and runs with builtins removed. Also grades multiple-answer, ordering, matching and dropdown submissions, and ships a CLI with a JSON-lines batch mode. 310 pytest tests passed on CPython 3.11, 3.13 and 3.14 on Windows. English prose only, and no time limit on SymPy.
Source preview
# Units that contradict each other end it here, before any rescue can
# re-admit a bare number ('21 km' against '21 cm').
su, ku = split_unit(canon(given)), split_unit(canon(expected))
if su and ku and not units_agree(su[1], ku[1]):
return False, 'unit_mismatch'
strict_form = canon(given)
loose_form = loose_canon(given)
forms = [strict_form] if strict_form else []
if (loose_form and loose_form not in forms
and not mangling_changed_the_value(strict_form, loose_form)):
forms.append(loose_form)
# Normalise both sides the same way, so '20.56 m/s' typed against a bare
# '20.56' is accepted as well as the reverse.
forms += [c for c in answer_candidates(given, legacy=False, verdict_initial=False)
if c not in forms]
for form in forms:
if form in cands:
return True, 'candidate'
for c in cands:
if numeric_equal(form, c):
return True, 'candidate'
if same_value_with_unit(form, c):
return True, 'unit'
if approximates_exact_answer(given, expected, row_permits_rounding=o.accept_rounded):
return True, 'rounded'
if phrase_rescue(given, expected, every_part=o.every_part):
return True, 'phrase'
if written_the_same_way(given, expected):
return True, 'same_form'Excludes tax, added at checkout where it applies.