ADR-102 — Mathematical expression answers: a math field, numerical equivalence, no CAS¶
Status¶
Proposed (2026-10-11), decided with the product owner after the challenge of
issue #460; prototype on branch qt-math, not merged. Requirement F-QST-12.
Scope: the math question type (packages/qt-math), the reading and
comparison rules of @quiz/domain/math, and the student view of the type.
Relations: settles issue #460 by its option B (a dedicated type, not a kind
of short). Relates to ADR-056 §2 (the
evaluator of parameters is mathjs, and this record adds no second use of it)
and §10 (parameterized keys stay with
mcq, short and cloze), ADR-069
(the CSP refuses eval; \log and \ln read as the provided calculator
does), ADR-019 (behaviour is compared,
never form), ADR-044 §2 (the grading
column, and a regrade reads the same verdict) and
ADR-033 (the debrief groups by
the text typed). Amends docs/spec/04-types-de-questions.md by its §4.16
and docs/spec/02-exigences-fonctionnelles.md by F-QST-12.
Context¶
Physics, electronics and mathematics courses ask for a formula as an answer
— "give the equivalent resistance of $R_1$ and $R_2$ in parallel" — and
want it graded automatically, whatever its form: R_1 R_2 / (R_1 + R_2) and
1 / (1/R_1 + 1/R_2) are one answer, R_1 + R_2 is the series mistake
(issue #460, modelled on STACK's workflow in Moodle). No type does it today.
shortcannot carry it. Itslowercaseprefilter foldsRandrinto one name; itsnumbermatcher compares one value, not a function; the domains of several symbols, a tolerance over a sample and a graph have no place in a config whose everykindshares the fields (§4.5); and a poll groupsshortanswers by their text (ADR-033).- The API process holds no computer algebra system, and nothing on the serve path waits for the runner (ADR-056 Alternatives, ADR-101): a formula must be graded in pure TypeScript, instantly, on the server, for the immediate feedback of an exercise — and in bounded time, since grading runs on the API's thread and a student chooses the text.
- The browser's CSP refuses
evalandnew Function(apps/api/src/csp.ts, ADR-069): an evaluator that compiles an expression to JavaScript is out, on the client as on the server, where the same rule keeps one code path for both. - The verdict of an answer must not depend on the paper it was written on: a regrade (ADR-044 §2), the preview and the debrief (ADR-033) read one verdict for one text.
- MathLive's
<math-field>is already a dependency ofapps/web(FormulaDialog), and its sibling parser, Compute Engine, is pure JavaScript. Invariant 4 (toStudent) and 05 §5.7 bound what the student view may carry.
Decision¶
-
A dedicated type,
math(option B of issue #460), inpackages/qt-math, registered through both registry entry points, shown as "Math expression" / « Expression mathématique ».QuestionTypeIdgainsmath. Its rules are owned bydocs/spec/04§4.16; this record owns why. -
The field is MathLive's
<math-field>, the stored answer is LaTeX.MathField.tsxloads MathLive lazily (import("mathlive")) and falls back to a plain LaTeX input where the web component cannot load, so the surface is never blank. MathLive's own keyboard toggle sits inside the field, and its virtual keyboard opens on focus on a touch screen (policyauto); the type adds no button of its own. The answer is{ latex }(at most 300 characters,MATH_MAX_LATEX);isAnsweredis "not blank". The editor types the accepted answers in the same field. -
Compute Engine (
@cortex-js/compute-engine) parses and canonicalises; a float interpreter of ours evaluates; the server re-reads.readMath(latex, options)in@quiz/domain/mathturns the LaTeX into Compute Engine's canonical MathJSON tree, so\frac{1}{x},x^{-1}and1/xare one reading,2xa product,\sin^2 xa power of a sine. The engine never evaluates anything: its arithmetic is exact and unbounded, and a thirteen-character answer,x+10^{10^{6}}, held the API's thread for 87 s under itsN()— a denial of service on grading. The values come from a small interpreter of the canonical tree over JavaScript floats (OPERATORS:Add Negate Multiply Divide Rational Power Sqrt Root Abs Ln Log Sin Cos Tan Arcsin Arccos Arctan Sinh Cosh Tanh, the constantsPiandExponentialE), whose cost is the size of the tree, bounded by the length cap; a power that overflows is simply no value. Any other construct the engine can produce — a sum, an integral, a derivative, a factorial, a complex number,min/max, an equation, an infinity — is refused at read time with the problemunsupported(naming the operators), so the echo says so while typing. A read fails on the first problem met, in this order:empty,too_long,syntax,unknown_symbol(with the names),unsupported,forbidden_function. The parse is bounded too, the way ADR-099 bounds a regex before it costs anything: two shapes measured exponential in the engine's parser — a run of adjacent signs (------x: 18 signs, 250 ms, doubling with each one) and absolute-value bars nested deep (||||x||||,\vertlikewise: 60 levels never finished) — are refused lexically before the parser runs (parsesInBoundedTime: a run of three or more+/-once whitespace is removed; more than 8 bar tokens|,\vert,\lvert,\rvert,\mid), reported as thesyntaxproblem, as is a numeral the engine cannot hold (1e999…), which used to throw. The grader reads the STORED LaTeX again on the server; what the browser computed is an echo and nothing else. There is no grammar of our own (ADR-056 rejected one), and neither Compute Engine'scompile()(which usesnew Function) nor itsN()is ever called: the CSP of ADR-069 is untouched. The reading conventions are the provided calculator's (ADR-069):\logis base 10 (\log_bwith its base),\lnnatural, angles are in radians, ande,iandpiare constants (MATH_RESERVED_SYMBOLS), refused as symbol names. A symbol name followsMATH_SYMBOL_PATTERN: a letter, letters and digits, an optional_subscript(x,R1,R_1,omega). -
Grading by numerical equivalence, no CAS. The key and the answer are evaluated at
samplespoints (default 10, 3 to 50) drawn uniformly in each symbol's[min, max]. Two values agree when|k − a| ≤ tolerance × max(|k|, |a|, K), withKthe largest|k|over the sample: relative, with a floor at the key's magnitude so a near-zero value of a large function does not fail on float noise (compareMath, which returns{ equivalent, compared };tolerancerelative, default1e-6, at most0.5). A point where the key has no real value (a pole, a root of a negative number, an overflow) is skipped; a candidate without a value where the key has one is wrong; a first key with no value anywhere on the domain is refused at publication (math.key_undefined). The points are derived from the QUESTION's content —samplePointsseeded byhashSeed("math", <first key>, <symbols>)— never from the attempt's seed: the same text gets the same verdict on every paper, in the preview and on a regrade (ADR-044 §2, ADR-033). Ascircuitcompares waveforms (ADR-019),mathcompares values, never form. -
Accepted answers are an ordered list.
answers[]holdsvalue(LaTeX) andpoints(0 to 1, a fraction of the item); the first that matches wins, likeshort's matchers, and the item earnsround2(points × itemPoints). Each key must read against the symbols (math.key_<problem>at publication:math.key_syntax,math.key_unknown_symbol,math.key_unsupported…). Anticipated mistakes with a feedback are a later addition of the same list, without a migration. -
The field's rules travel to the student and are enforced while typing (04 §4.5: a constraint says what the field accepts). They are ONE object,
fieldRules(config)→{ symbols: <the names>, forbidden, maxLength }:forbiddennames functions from the closed listMATH_FUNCTIONS(sin cos tan asin acos atan sinh cosh tanh sqrt exp ln log abs power;e^xcounts asexp);maxLengthis 10 to 300, default 200. Under the field, the echo "Read as" shows the parser's reading rendered, or the problem with what fixes it (the declared names for an unknown variable, the function at fault, the construct that has no value here). A non-blank answer still unreadable at the close is worth 0 withdetails.status = "invalid"and the whole problem (MathProblem) stored indetails.problem: the review says "Not readable" and why, from the stored problem, without re-parsing; the teacher may override it in the grading table. A blank or absent answer is no answer: 0,status: "graded". There are norequiredfunctions: a requirement is gamed byx + \sin x − \sin xand is a form check in disguise, deferred with the others (decision 11). -
toStudentspreadsfieldRules(config)into the view beside the prompt andplot; it strips the keys, the intervals, the tolerance and the sample count. The student view IS the reading options: the player reads the answer with the view itself, and the grader withfieldRules(config), so what the field accepts and what the grader reads cannot differ. A key is read against the symbols alone. The details are{ status, problem?, matchedIndex, fraction, latex };studentDetailsdrops onlymatchedIndexwhile the key is hidden. -
An optional graph,
plot: "none" | "answer" | "target", with exactly one symbol (math.plot_one_symbol). The student's expression is drawn in plain SVG over the symbol's interval as they type; the student view then carriesaxis: { from, to }, that interval. With"target", the first key's curve travels as VALUES only —target: (number | null)[], 64 values (MATH_PLOT_POINTS) on the even grid overaxis,nullwhere the key has no value — never its text: a deliberate numeric exit of the key, in the spirit of 05 §5.7 and likecircuit'sshowExpected(04 §4.11), tested intoStudent.test.ts. The grading points are random (samplePoints), never the plotted grid. No GeoGebra applet: the CSP, the bundle, and SEB and kiosk sessions rule an embedded applet out. -
The other surfaces follow the existing rules. The grading table has one column, the LaTeX as a chip tinted by its verdict, sorted by the normal form of the text (ADR-044 §2); the live cell shows the LaTeX on one line; the debrief groups by the text typed, as
short(ADR-033) — not by equivalence. The three surfaces are lazy (React.lazy), the host's Suspense covering the load. Not shuffleable;defaultPoints1. -
Security and performance. The student's text is capped at
MATH_MAX_LATEX(300 characters) before the parser sees it, and the two exponential shapes are refused lexically (decision 3); the evaluation is the interpreter's, linear in the tree, never the engine's. Measured on 2026-10-11: under 300 characters every other shape stays within ~120 ms (300 letters of implicit multiplication, the worst), and an answer written by a person parses in 1 to 3 ms. The bound serves N-PERF-02 (an autosave answered under 200 ms): the live grader runs the parse on every autosave. The engine is NOT stateless: it declares every symbol name it meets in its global scope, and a stream of fresh names made a read 16× slower after 20 000 reads (a pushed scope per read made it worse, measured), so one engine per process is built on first use (~40 ms) and recycled every 1000 reads (ENGINE_READS), which keeps the cost flat (~0.8 ms per read over 12 000 fresh-name reads). Compute Engine is pinned exactly in@quiz/domain(0.58.0), as mathjs is (ADR-056 §2): a verdict depends on how a text is read, and an upgrade must not change one silently. -
Out of this prototype, to be settled later, each without closing a door: parameterized keys (ADR-056 §10 keeps
mcq,shortandcloze; the amendment that admitsmathmust say an interpolated value reads as one parenthesised number); equations, inequalities, intervals, sets and vectors as answers; form checks (simplified, factored, expanded, a required function — a sympy image inapps/runnerif ever, never a CAS in the API process); a maximum degree (a syntactic proxy, dropped); drill and polls (DRILL_TYPESandPOLL_TYPESunchanged); a formula blank inclozeon the same engine; negative marking; anticipated mistakes with a feedback; sharingMathFieldwithapps/web'sFormulaDialog(a follow-up); afractionToneshared in@quiz/uifor the chips tinted by a fraction of the points; moving the parse off the event loop (a worker) if a future measurement needs it.
Consequences¶
- A formula is accepted in any equivalent form, graded on the server in pure TypeScript, instantly, with no runner on the path: an exercise can give immediate feedback, and a regrade reads the same verdict.
- A syntax slip is told while typing and never graded as a mistake of mathematics; the teacher sees "Not readable" and keeps the last word.
- Security: grading is bounded by the text's length. The engine only
parses, in a few milliseconds for a human answer and ~120 ms at worst
under the 300-character cap once the two exponential shapes are refused
lexically; the interpreter's work is the size of the tree, which the cap
bounds, and a value too large is simply none. Had the engine's exact arithmetic evaluated, one short
answer could hold the API for minutes, and it was observed to (decision
3). The price is a closed vocabulary: a sum, an integral, a derivative,
a factorial or an equation is told "not available here" while typing,
and the key is held to the same vocabulary at publication
(
math.key_unsupported). - Bundle: MathLive (~300 kB) and Compute Engine (~1.1 MB) are loaded
lazily, only when a
mathquestion is opened (React.lazyon the three surfaces,import("mathlive")in the field); neither enters the initial bundle (N-PERF-05).@quiz/domaingains a dependency that the API loads once per process. - The target plot is a numeric exit of the key, chosen by the teacher
per question, as
showExpectedis forcircuit: 64 values of the first key over the window. A student can read values off it, not the formula. With any plot, the window IS the plotted symbol's interval, so that interval is no longer hidden; a teacher who wants it secret keepsplot: none. - A wrong function that coincides with the key at every sample point is
accepted: with ten points drawn at random over the domain and a
relative tolerance of
1e-6, the probability is negligible for the functions a course asks for. Conversely, the domain is part of the question:\sqrt{x^2}equalsxon[0.5, 2]and not on[-3, 3], which is what the teacher means by declaring the interval (issue #460). - A key undefined on part of the domain is compared on fewer points; a candidate is judged only where the key has a value. A key that does not read in a stored version is skipped by the grader, and publication keeps that from happening to a new version.
- Work: the strings in
enandfr(N-I18N-01; the problem sentences once, underqt.math.problem.*), the issue keys ofapps/web/src/question/issues.ts, the registry entries, the leak fixture oftesting.tsin the registry's contract test, and the documentation of §4.16, the guide and the repository map.
Alternatives considered¶
- A
mathkind ofshort(option A of issue #460). Lowercase folding mergesRandr; the per-symbol intervals, the tolerance over a sample and the plot would be fields every otherkindcarries and never reads; the poll tally and the stats would group by text asshortdoes. One more package costs less than ashortthat is two types. - A hand-written parser. One more grammar to secure and document
(ADR-056 rejected a language of our own); Compute Engine already reads
what MathLive writes, and its canonical form is what makes
\frac{1}{x}andx^{-1}one reading. - Compute Engine's own evaluation (
N()). The first prototype used it: exact, unbounded arithmetic, andx+10^{10^{6}}held the thread for 87 s. A float interpreter of twenty operators costs a page of code and is linear in the tree. - A pushed scope per read, or a fresh engine per read, instead of recycling. A pushed scope measured slower than the global one; a fresh engine costs ~40 ms a read, forty human answers' worth. Recycling every 1000 reads keeps the cost flat.
- Symbolic equality (Compute Engine's own simplifier, or a CAS). A
simplifier decides
(x+1)^2 = x^2+2x+1some days and not others, and a CAS in the API process is exactly what ADR-056 kept out; numerical equivalence is what Numbas does by default and whatcircuitalready does. - Required functions, as the first prototype had beside the forbidden
ones. Trivially gamed (
x + \sin x − \sin x): what a teacher means by "use the sine" is a check of form, which belongs with the other form checks of decision 11. - STACK/Maxima, or sympy through the runner. The right tool for form checks, and a round trip to the runner for every answer at the close of an exam, with an exam that breaks when the runner is down (ADR-101). Kept for the form checks of decision 11, if ever asked.
- A GeoGebra applet for the input and the graph. An iframe or a script
the CSP would have to admit, megabytes in the bundle, and a surface SEB
and the kiosk cannot vouch for; a
<math-field>and an SVG polyline are the whole need. - Sample points drawn from the attempt's seed (as parameters are, ADR-056 §5). Two students who wrote the same text could get two verdicts, and a regrade or the preview would read other points than the paper did (ADR-044 §2, ADR-033).