4. Question types¶
4.1 Contract of a question type¶
A type is a TypeScript package that exports a QuestionType object:
| Member | Role |
|---|---|
id |
Stable identifier, e.g. mcq. Used in the database and in the canonical format. |
configSchema |
Zod schema of the configuration, statement included. Validated at publication and at import. |
answerSchema |
Zod schema of the student's answer. Validated at every autosave. |
defaultPoints(config) |
Default points when the question is added to an evaluation. |
toStudent(config, seed) |
Returns the configuration visible to the student: no key, no explanation, no hidden tests, choices shuffled according to the seed. Pure function, tested. Amended 2026-10-10 (ADR-097): a choice or a dropdown option is named by its displayed position, never by its canonical index. |
fromStudentAnswer(config, view, answer) / toStudentAnswer(config, view, answer) |
Optional (mcq, cloze), added 2026-10-10 by ADR-097: the answer a student sends, in the coordinates of the view they were served, back to the canonical answer that is stored (null: a position the view does not have, refused with 422 answer_invalid), and the inverse for every answer handed back to a student. Identity when absent. toSolution names the key in the same coordinates; studentDetails drops the canonical indices of the student's own choices with the key. |
grade(config, answer, ctx) |
Returns a graded result or a pending runner/LLM request (GradeResult in packages/core/src/contract.ts). ctx provides the seed, the item's points, the runner service and an LLM-availability flag; the API owns model calls (ADR-063). |
distinctTexts(config) |
Optional (mcq). The texts an instance of a parameterized question keeps distinct (ADR-056 §7). Variables themselves are a header field of the version, instantiated by the API before any hook is called, never by the type. |
parameterIssues(template) |
Optional (short). What a parameterized template may not do although its instances could (a computed text key, ADR-056 §10). |
sampleIssues(template, sample) |
Optional (short). What a parameterized template may not do given the values publication drew: a tolerance below half the step of its key's format (ADR-056 §6). |
Editor |
React component for editing the draft. |
Player |
React component for answering. Receives the student configuration, the current answer, an onChange callback. |
Review |
React component for review: answer, key, grading, for the teacher and for the student feedback. |
grading |
Required client member: the type supplies its grading-table columns, expected cells and answer sort keys (ADR-044). |
toCanonical / fromCanonical |
Conversion from and to the canonical format, if it differs from the raw configuration. |
configVersion, migrate(config, from) |
Version of the configuration schema and upgrade on read. Lets a type evolve without an SQL migration, see 5.2. |
generator |
Optional AnswerGenerator: a narrow proposal schema, instructions and merge; an optional runner settlement computes expected outputs. The API calls the model (ADR-059); variants remain deferred. |
searchText(config) |
Text indexed for the full-text search of the pool. |
Rules:
- A type has no tables. Its configuration and its answers live in JSONB in the core tables.
- A type makes no direct network call. Runner execution uses
ctx.runner; an LLM request is returned as a pending result for the API to execute.ctx.llmis an availability flag, not a service. - A type has no drill hook (ADR-041 §4). A question is drillable when its type is in the drill's scope —
mcq,short,cloze,categorizeandblocks(ADR-103) in v1,DRILL_TYPESin@quiz/domain— andgradesettles its answer automatically and finally: graded at once, not pending a runner or an LLM, not a proposal for the teacher. The drill takes its correctness fromgrade's points and its time from the review. - The phase 1 types live in the monorepo under
packages/qt-*, with two entry pointsserverandclient, see 5.2. Loading is static, through two registries.
4.2 Canonical format¶
Planned import/export format (F-EXP): the canonical package and CLI are not
implemented yet. The examples describe the intended interchange format, not
a current API payload. One YAML file per question; images live in a sibling
assets/ folder, referenced by relative path.
id: 01920e6d-1e00-7000-8000-000000000001 # stable UUID v7, generated at creation
type: mcq
name: Arithmétique de pointeurs # internal name: a short title, never shown to students
concepts: [pointers, pointer arithmetic] # labels or ids, resolved as in ADR-081 (addendum §2)
difficulty: 2
version: 3
shuffleable: true
explanation: |
`p + 1` avance de `sizeof(*p)` octets, donc de 4 pour un `int`.
config:
prompt: |
Soit `int *p` pointant sur l'adresse `0x1000`. Que vaut `p + 1` ?
choices:
- { text: "0x1001", correct: false }
- { text: "0x1004", correct: true }
- { text: "0x1008", correct: false }
policy: all_or_nothing
The config field is specific to the type. The header fields are common. Exporting a pool produces pool.yaml with its metadata, one folder per category, one file per question. Import honours the ids: an existing question with the same id receives a new version if the content differs.
4.3 Random values¶
Available for mcq, short (a number matcher) and cloze; code later. Decided by ADR-056, which replaces the first design of this section (ranges with a step, {{R1}}).
variables:
- { name: h, expr: "randint(1, 100)", format: int }
- { name: g, expr: "choice([3.71, 8.87, 9.81, 24.79])", format: ".2" }
- { name: t, expr: "sqrt(2*h/g)", format: ".2" }
condition: "t > 1"
variablesis a header field of the version, besideexplanation, not part of the type's configuration. A question with at least one variable is parameterized;randomizableis derived from it at publication.- Expressions are evaluated by a restricted mathjs instance (a whitelist of functions, no
import,parseorevaluate, capped length and tree, no ranges);randint,uniformandchoicedraw fromstreamSeed(attempt.seed, item.id, "vars"). A row reads the rows above it; the optionalconditionrejects a draw, retried at most 100 times. - The statement, the choices, the keys, the blanks and the explanation use
[[h]]or[[sqrt(2*h/g)]], only in a parameterized question;\[[escapes. An unknown name is a publication error. A cloze blank reads{{#[[t]]:1%}}. - A variable is its formatted value (
int,.ndecimals,nssignificant figures), rounded half away from zero; the dot is the decimal separator in every language. - The values are drawn the first time the item is served in an attempt and STORED with it; every later read (review, regrade, feedback) uses them. A retake has a new seed, so new values.
- Instantiation is one pass that turns the version into an ordinary static one before
toStudent,gradeorReviewsee it. The variables never reach a student (5.7). - Publication validates on 200 draws, on every write path. The editor shows five instances computed by the API; "freeze" comes later. A poll refuses a parameterized question.
4.4 Multiple choice mcq¶
Configuration (config version 2): prompt markdown, choices[] with text markdown and correct, mode single or multiple, optional maxSelections, policy one of the five below or inherit (default), shuffleChoices. Version 1 carried a penalty factor and an allowNegative flag; the v1 → v2 migration maps partial and penalized onto symmetric and drops both fields.
Answer: selected[] indices of the choices in canonical order. Shuffling is applied by toStudent; the answer is always in canonical indices. Amended 2026-10-10 (ADR-097): the STORED answer is in canonical indices, ascending and unique; on the wire a student sends and reads the displayed positions of their view, and the server converts at its single entry and exit. An answer with more choices than maxSelections is refused, like two choices on a single question.
Which rule applies, in this order:
- the evaluation uses negative marking (
settings.negativeMarking, ADR-026): every choice question of the evaluation, single or multiple answer, is scored with the negative rule below, whatever its policy (the same setting coverscategorizewith a rule of its own, §4.13, ADR-036); - otherwise a
singlequestion is alwaysall_or_nothing; - otherwise the question's own
policy, or, when it saysinherit, the evaluation'smcqPolicy(seeded at creation from the teacher's preference).
Scoring, with C correct choices, W distractors, c correct ones ticked, w distractors ticked. The formulas live in @quiz/domain/mcqScore:
| Policy | Formula | Comment |
|---|---|---|
all_or_nothing |
1 if c = C and w = 0, otherwise 0 | Default |
true_false |
(c + (W − w)) / (C + W) | Every choice is its own true/false item |
discordance |
d = (C − c) + w; d = 0 → 1, 1 → 0.5, 2 → 0.2, more → 0 | French medical QRM convention |
symmetric |
max(0, c/C − w/W) | Zero expectation before the floor |
ripkey |
0 if w > 0, otherwise c/C | One wrong tick voids the question |
The five policies lie in [0, 1]: a question is never worth less than no answer.
Negative marking (evaluation setting, ADR-026): f = c/C − w/W, not floored, in [−1, 1]; with a single answer among n choices, +1 for the key and −1/(n − 1) for a distractor. No answer (nothing ticked, "Leave unanswered", cleared) is 0. Random guessing has an expected value of 0. The per-question points may be negative and are shown as such; the TOTAL of the evaluation is floored at 0 (attemptTotal), and the grade is computed from it. The student is told in the waiting room and on each choice question; a poll refuses the setting.
The fraction is multiplied by the item's points and rounded to the hundredth.
Editor: list of choices, one checkbox per choice to mark it correct, add with Enter, reorder by drag, live preview.
4.5 Short answer short¶
Configuration (configVersion: 2): prompt, kind text / number / date / time, the constraints of that kind, the question's prefilters, and matchers[] evaluated in order; the first match awards the points.
Field constraints: they say what the field ACCEPTS, never what it expects. They are therefore not part of the key: toStudent passes them through and the player enforces them in the input, where the browser applies them itself.
kind |
Constraints | Student's field |
|---|---|---|
text |
minLength default 0, maxLength default 255, hard cap 500 |
input type="text" with minlength and maxlength |
number |
optional min and max (empty = unbounded), integer default false |
input type="number" with min, max and step |
date |
optional from and to |
input type="date" with min and max |
time |
none | input type="time" |
Only one constraint bears on grading: integer. A non-integer answer to an integer question is worth 0, whatever the matchers. The others belong to the field, never to the grade scale: an answer recorded before a constraint was tightened is graded on its merit. A number matcher whose expected value is not an integer in an integer question is refused at publication, with the key short.integer_expected.
Prefilters: two normalisations decided ONCE for the question, applied to the student's answer AND to every exact value before the comparison. The input of a regex undergoes them too; its i flag, however, remains its own.
| Prefilter | Default | Effect |
|---|---|---|
trim |
true | Removes leading and trailing whitespace, on both sides |
lowercase |
true | Compares in lowercase, French folding, accents stay significant (decision D9) |
Runs of whitespace inside an answer are always collapsed to a single space. In v1 these three settings lived on each exact matcher (caseSensitive, trim, collapseSpaces): the v1 → v2 migration reads trim and caseSensitive from the FIRST exact matcher to make them the question's prefilters, then removes them from every matcher.
| Matcher | Fields | Semantics |
|---|---|---|
exact |
value |
Equality after the question's prefilters |
regex |
pattern, flags |
Full match, on the prefiltered input |
number |
value, tolerance, toleranceMode abs / rel, optional unit accepted or ignored |
Numeric comparison, comma and dot accepted |
date, time |
value, tolerance in days or minutes |
Local formats accepted, normalised to ISO |
llm |
rubric markdown, optional reference |
Proposed LLM grading, phase 2 |
Each matcher may carry points as a fraction, default 1, to accept a partially correct answer.
A regex pattern (here and in a cloze blank) follows the RE2 syntax, matched in time linear in the input: no backreference (\1), no lookahead or lookbehind. Such a pattern, a syntax error, flags outside imsu, more than 300 characters or a program too large (ADR-099) is refused at publication, one issue per matcher or blank (regex.invalid, regex.invalid_flags, regex.unsupported, regex.too_complex), and so are the patterns of one question that together would cost too much to build (regex.config_too_complex), never on a read: a version published earlier stays served and graded, the refused pattern never matching. The student's input is cut to 500 characters before matching (PLAN-MVP D10). In a parameterized question a cloze regex blank holds no [[…]] reference (cloze.computed_regex_key), like a short text key (ADR-056 §10).
Editor: the kind is a segmented control, the constraints of that kind sit on the same line, to its right; the prefilters are two checkboxes below the list of accepted answers.
Answer: text string.
4.6 Fill in the blanks cloze¶
Configuration: text markdown containing blanks, global caseSensitive default false. The player renders the markdown with a field or a dropdown at each blank. Blanks have equal weight by default.
Blank syntax, inspired by Moodle Cloze, simplified:
| Syntax | Meaning |
|---|---|
{{Newton}} |
Text field, answer Newton, normalised equality |
{{Newton\|Isaac Newton}} |
Accepted alternatives |
{{=Newton\|Maxwell\|Faraday\|Galilée}} |
Dropdown, = marks the correct option, shuffled order if the question is shuffleable |
{{#3.14:0.01}} |
Numeric with absolute tolerance |
{{#3.14:1%}} |
Numeric with relative tolerance |
{{/^[0-9a-f]+$/i}} |
Regular expression |
{{2*Newton}} |
Weight 2 for this blank |
\{{ |
Literal braces |
Inside a markdown code block the blanks stay active, which allows "complete this code". A code question does not use this syntax.
Inside a blank, a backslash before an ASCII punctuation character makes that character literal: \|, \}, \*, \\ are the four one meets, and the rule is broader so that the blank editor (below) can write any answer; an answer starting with #, / or = would otherwise become a number, a regex or a dropdown.
Blank editor. The body of a blank is a grammar, not a value: one does not type it. Typing {{, clicking an existing chip, pressing the "Insert a blank" button or Enter on a selected chip opens a card anchored under the chip, which asks for the FORM of the blank (any of these answers, dropdown, number, regex) plus the weight. It reads the existing body and rewrites it with the domain functions (parseBlankBody / formatBlank), never by concatenation: what the card shows and what the grader reads cannot diverge. The raw syntax remains available in the markdown source pane.
A blank in a table cell. An unescaped | separates two columns, but a | INSIDE a blank is not one. On the domain side this is settled: parseCloze runs BEFORE the markdown and replaces every blank with a sentinel (decision D5), so the row is split on a cell that no longer contains a bar. On the rich editor side, it is the editor's job to guarantee it: it replaces the | of a blank body with a private-use character on read and restores it at the very end of serialisation, after the table rendering has aligned its columns. {{=passante|bloquée}} in a cell is therefore ordinary writing.
The labels of a dropdown reach the student, they are the options, but correct never does.
Answer: blanks[] strings in order of appearance. A dropdown stores its canonical option index as a decimal string (decision D4); amended 2026-10-10 (ADR-097): on the wire a student sends and reads the option's displayed position instead.
Scoring: sum of the weights of the correct blanks over the sum of the weights.
4.7 Code code¶
Configuration:
config:
prompt: markdown
language: c # c, cpp, python, js, rust in phase 1
runtime: backend # backend (default) or runno, see "Where the trial runs"
template: | # initial code, with locked regions
#include <stdio.h>
// @@lock
int main(void) {
// @@endlock
// votre code
return 0;
}
files: # additional files read by the program, optional, PUBLIC (ADR-096)
- { name: data.csv, content: "..." }
action: run # check compiles only, run executes
compileArgs: "-Wall -Wextra -std=c17"
limits: { timeMs: 2000, memoryMb: 128, outputKb: 64 }
runsPerMinute: 10
cooldown: fixed # or progressive (ADR-024)
tests:
mode: io # io or tap
cases:
- { name: "cas simple", stdin: "3 4\n", expected: "7\n", visible: true, points: 1 }
- { name: "négatifs", stdin: "-3 4\n", expected: "1\n", visible: false, points: 1 }
# command line: args becomes argv[1..] of the program
- { name: "somme argv", args: ["3", "4"], expected: "7\n", visible: true, points: 1 }
# the two checks are independent: here only the exit code counts
- { name: "refuse un argument invalide", args: ["oui"], expected: "",
compareStdout: false, expectedExitCode: 1, visible: false, points: 1 }
compare: { trimTrailing: true, ignoreCase: false, numeric: null }
- Locked regions: marked by
@@lock/@@endlockcomments in the language's comment syntax (@@unlockis a synonym of@@endlock). The author does not type them: selecting lines and pressing the lock button writes them, and the editor reports an unknown marker or an unlock with no open lock by its line; a lock left open runs to the end of the file. The player shows the whole program in ONE editor, locked lines greyed and read-only, marker lines hidden but counted, so a compiler's line numbers are the student's (ADR-024). The server rebuilds the final file from the template and the editable regions, never from the client's raw text, which prevents a locked region from being modified. - Reference solution, split like the template:
referenceSolutionis not a complete file, it is the content of the EDITABLE regions of the template, in their order. With a single editable region, the whole reference is that region. With several, the pieces are separated by an@@nextmarker LINE, written in the language's comment syntax exactly like@@lockand@@endlock(/* @@next */or// @@nextin C,# @@nextin Python); the marker line disappears along with the line breaks around it. The author writes it in the student's editor, one region per template region, prefilled with the template's text; the markers are written for them. If the number of pieces does not match the number of regions, the editor says so and attempts nothing: the source is always rebuilt from the template (invariant 14), never taken as-is. iomode: each case sendsstdin, possibly a command line, and checks what the teacher asked for. This is the phase 1 mode.args, the command line of a case: an array of strings, one per argument, which becomesargv[1..]of the program (sys.argv[1:]in Python,process.argv.slice(2)in JS). The runner passes them to the program as process arguments, never through a shell: a space, an apostrophe, a$or a;inside an element is a character of that element, not a separator. When absent, the program is launched without arguments.-
The two checks of a case are independent, and a case must enable at least one (otherwise the configuration is refused:
code.case_checks_nothing):compareStdout(defaulttrue) comparesexpectedto the standard output according tocompare;expectedExitCode(default0,null= any) compares the process exit code.
The verdict of a case is read in this order: it fails on an accident (wall clock exceeded, memory cap reached, process killed without a clean exit code), then every enabled check must pass; a disabled check says nothing. A case that does not compare
stdoutexposes no expected output, neither to the player nor in the grading details. - Where the trial runs,runtime(ADR-015), under "Advanced options" as "Instant" / "Same as grading"; a new C or Python question starts in the browser (ADR-024):backend(the schema default) runs the student's trial in the containerised runner;runnoruns it in the browser, in WASI inside a Web Worker, for the only languages that runtime embeds: C and Python. The runtimes are hosted by the platform, never loaded from a third party at request time. Any other language falls back tobackend, as does a browser that cannot start the worker.The browser runs, the server grades.
runtimeonly describes the student's "Run" button: grading always goes through the server's runner, which rebuilds the source from the template and the editable regions. A result produced by a browser is not proof. WASI is not Linux (nofork, no signals, partial<sys/…>, the browser's clocks and randomness), so a trial that passes in the browser can still fail at grading: the player presents the trial as a trial, and the editor warns the author when the reference passes on the server but not in the browser. -tapmode, phase 3: the teacher providestestFileandcommand. The runner executes the command and reads a TAP stream on stdout:ok 1 - nameandnot ok 2 - name. Each line is a case. TAP libraries exist for C, Python, JS, Rust. - Points: the sum of the cases' points. AnallOrNothingoption on the question gives all or nothing. - Player buttons (ADR-024): Compile builds and reports the compiler's messages, at their line in the editor (RunBody.compileOnly); Run the tests, the primary action, launches the visible cases and shows for each the command line, stdin, the expected output (when it is compared), the obtained output and the verdict; Free try opens a stdin area with its own command line, the only place where arguments come from the browser. Hidden cases are only run at grading. The last result of each tool stays on screen, and running the same code again is allowed. Run the tests and Free try share one cooldown and refill after each use:cooldown: fixedwaits 3 s,progressive3 s then 30 % longer each time up to 30 s, forgiven when the student pauses; a server run never waits less than60 s / runsPerMinute. Compile has no cooldown (one at a time only): on the server it spends a budget of its own,compilesPerMinute = min(60, max(20, 3 × runsPerMinute)), and never a test run, and a test run never spends a compilation. A refused run (429) reads "Too many runs in a minute" under the buttons (ADR-024, addendum of 2026-09-25). - Arguments are entered one per numbered row (argv[1],argv[2]…,argv[0]shown as the program), with a preview of the command line quoted as a shell would need it. - WhattoStudentpublishes (invariant 4): the statement, the template split into segments, the limits, the visible cases whole, the count and points of the hidden cases, the comparison options, and the extra files andcompileArgswhole: every run, the student's own included, hands them to the program, so they are public program inputs and never hold a key (ADR-096). The hidden cases' stdin, command line and expected output and the reference solution stay home. Secret test data goes in a hidden case; a test-only secret file waits for the runner to run hidden cases in a container of their own. - Order of the cases at grading (ADR-096): the grading request runs the visible cases first, then the hidden ones, each group in the teacher's order; the stored breakdown keeps the teacher's order. A program cannot carry a hidden input into an output the student reads. - Feedback on a hidden case (D15, ADR-096): its verdict, its points and, when it failed, a coarse category — timed out, out of memory, crashed or failed; its name only whenshowHiddenCaseNamesis on. Never its expected output, output, stderr, exit code or time, unless the policy shows the key. - Answer:regions[]content of each editable region,lastRunsummary of the last run for the dashboard. - Code editor: Monaco, theme aligned with the platform, VS Code shortcuts, configurable tab width, no language server.
4.8 Rich answer rich ("Essay")¶
Shown as Essay / « Rédaction » in the interface; the id rich is the one stored in the database and in the canonical format. Brought forward from phase 2 in a first version graded by hand (issue #192, packages/qt-rich).
Configuration (configVersion: 1): prompt, rubric markdown (may be empty), optional reference model answer (markdown), optional maxChars, format markdown (default) or plain.
Limit: in CHARACTERS, not words — a schema can count characters, and a word is ambiguous in code or a formula. It is counted on the stored text, markdown marks included (UTF-16 code units, what String.length and a textarea's maxLength count). maxChars is 1 to 50 000; an answer never exceeds 50 000 characters whatever the question says (answerSchema), because the autosave sends the whole answer every 300 ms. The server refuses an answer over the question's maxChars with 422 answer_invalid (answerMisfit, key rich.too_long); the player never sends one — past the limit it shows the excess in red and sends nothing until the answer fits. The editor and the player translate a count into A4 pages at 3 000 characters a page, as a hint.
Student: toStudent keeps prompt, format and maxChars; the rubric and the model answer never leave (invariant 4). format: markdown gives the student the host's formatted editor (PlayerProps.RichText, lent without an image upload); plain, a textarea.
Answer: { text }, markdown or plain text according to format. No image in v1: pool assets are readable by every teacher session of the pool, and a student upload would need an ADR on personal data and retention.
Manual fallback: grade proposes 0 points (state: proposed, details.reason: manual) for a written answer, and a validated 0 for nothing written (reason: empty); the teacher sets the points and a comment in the grading panel, which shows the answer beside the rubric and the model answer (toSolution). The dashboard cell shows the character count, never the text. Not pollable, no drill.
The key a student reads: the rubric never reaches a student, even under showKey; the studentSolution hook keeps the model answer alone, or nothing (ADR-037).
LLM scoring (ADR-063): after the close, when a model is available and the question has a rubric or model answer, grade returns a pending LLM request. The API queues it, masks student names in the answer, and sends the statement, rubric, reference, answer and item's points. The reply is a proposal with confidence and a per-criterion breakdown inferred from the free-text rubric. The teacher validates it; a successful proposal is retained unless explicitly regraded. There is no model call while the evaluation runs or is paused. Without a model or grading material, the manual fallback applies.
The justification and details.ai (model and criteria) remain teacher-only even after validation. A teacher may explicitly copy the justification into the comment and edit it before saving (ADR-063 §7). A structured rubric with label, points and description remains deferred. The development stub is ADR-063 §9 (formerly ADR-045).
4.9 Code image codeimage¶
A variant of code (ADR-021): the same program, judged by the picture it prints instead of by test cases. It lives inside packages/qt-code (src/image/), registered as its own type beside code. Brought forward from phase 3.
Configuration:
config:
prompt: markdown
language: c # the languages of `code`
runtime: backend # backend (default) or runno, exactly as `code`
template: | # locked regions with @@lock / @@endlock, as `code`
...
referenceSolution: | # the editable regions, split by @@next, as `code`
...
files: [] # as `code`
compileArgs: "-Wall"
limits: { timeMs: 2000, memoryMb: 128, outputKb: 128 } # outputKb defaults to 128 here
runsPerMinute: 10
image: { width: 16, height: 16, palette: bw } # sides 3..128; bw, color16 or gray256
target: # the picture to draw; null in a fresh draft
{ width: 16, height: 16, palette: bw, pixels: "0101…" } # compact encoding below
- Everything about the program is
code's: languages, template and locked regions, the reference solution split by@@next,runtime, limits, extra files, compiler flags, the rebuild of the source from the stored template (invariant 14). There are no test cases and noaction: the program is always compiled and run, once, with an empty stdin and no command line. - The output protocol: the program prints
width × heightintegers on stdout, separated by any whitespace (spaces, tabs, newlines), read row-major from the top left. Line structure does not matter. - Palettes:
bw0..1 (0 black, 1 white),color160..15 (a pastel version of the sixteen classic CGA/VGA colours, in their classic index order, defined once asPASTEL_16),gray2560..255 (grey levels, 0 black). - Reading the output is one pure function (
parseImageOutput), shared by the grader and the player: the firstwidth × heighttokens are the pixels; a token that is not an integer, or is outside the palette's range, is an invalid pixel; pixels the output never reached are missing; tokens after the last pixel are ignored and counted as extra. Invalid and missing pixels are always wrong. Each of the three produces a warning shown to the student. - The target is captured by the teacher: in the editor, "Try the reference solution" runs it — in the browser for
runtime: runno, else throughPOST /questions/:id/try, whose grading details carry the image — shows the picture it draws, and "Use as target" copies it intoconfig.target. A canonical file may also write it by hand. The target stores the size and palette it was captured under beside its pixels: the pixel count alone cannot tell a 4 × 3 target from a 3 × 4 one. A draft may lack it (decision D16); publication refuses a missing target (codeimage.target_missing), one captured for another size or palette (codeimage.target_size) or with a value outside the palette (codeimage.target_value). These are publication checks (publicationIssues), not schema ones: "try" and "preview" accept a draft without a fitting target, and a target that no longer fits the image (after a resize or a palette change) reads as no target everywhere. - The compact encoding of an image, used for the target and for the computed image in the grading details: one lowercase hex digit per pixel for
bwandcolor16, two forgray256, row-major;x(orxx) marks an invalid or missing pixel in a computed image. A 128 × 128 target is 16 KiB of text (32 KiB in grey levels) rather than a 16 384-entry JSON array. - Grading:
points × matching pixels / total pixels, rounded to two decimals (@quiz/domain/round). The two-phase runner pattern ofcode:gradereturnspending: runnerwith one run,finalizeRunneris pure. Stdout is graded whatever the run's end — a non-zero exit, a timeout, a crash or an out-of-memory kill after half the image still earns the matching half. Only a compile failure scores zero by itself; no answer and an unavailable runner are handled as forcode. - Player: the code editor of
code(one Monaco editor, locked lines read-only, ADR-024), a Run button — in the browser or on the server, the same rule ascode; the server path is the genericPOST /attempts/:id/simulatethrough the type'sinteractiveRequest, budgeted byrunsPerMinute— and the image area:- a view toggle, Target | Computed | Difference: the difference paints each cell green where the student's pixel equals the target, red otherwise;
- a layout toggle, Single | Side by side: side by side shows the computed image on the left and, on the right, the target or the difference (the view toggle picks it); the two stack on a narrow screen;
- the grid has square cells and a very light line between them while the cells are large enough to carry one; it is drawn on a
<canvas>(up to 16 384 cells, no element per cell); - "x / y pixels correct (z %)", the warnings, and how the run ended. Before the first run the computed image is an empty placeholder. Every run replaces it.
- What
toStudentstrips (invariant 4): the reference solution. The extra files andcompileArgsare published whole, public program inputs as forcode(ADR-096). The target is published on purpose: it is the picture to draw, like a visible case ofcode. - Answer:
{ regions[] }, ascode. - Points:
defaultPointsproposes 1. The class debrief shows the distribution of pixel accuracy (100 %, 90–99 %, 50–89 %, 1–49 %, 0 %).
4.10 Drawing drawing, replaced¶
The free-form drawing type planned for phase 3 (an embedded Excalidraw, graded by LLM vision on a PNG) is replaced by the diagram type (§4.14, ADR-046): its free-form needs are the free kind of a diagram, a canvas of basic shapes and freehand strokes. The id drawing was never shipped and is not reserved.
4.11 Schematic circuit¶
The student wires a two-port box: a fixed canvas with four ports on its border, in+ and in- on the left, out+ and out- on the right. The teacher decides what drives the input, what loads the output, and how the answer is graded. Components are dropped on a grid, rotated and mirrored, wired orthogonally; junctions are read from the wire geometry, so the schematic is the only thing stored.
Configuration:
config:
prompt: markdown
palette:
kinds: [R, C, L, D, DZ, NPN, OPAMP, GND] # from the 16 of the library
maxComponents: 10 # terminals (GND, VCC, VEE) do not count
supplies: { vcc: 15, vee: -15 } # null hides the rail's symbol
commonGround: true # in- and out- ARE node 0; off, two free nets to wire
stimuli: # at most four, like the cases of a `code` question
- name: "1 kHz"
source: { kind: sine, amplitude: 1, frequencyHz: 1000, offset: 0 }
sourceOhms: 50 # series resistance of the source; 0 = ideal
load: { kind: resistor, ohms: 10000 } # or { kind: open }, { kind: capacitor, farads: 1e-9 }
analysis: { stopMs: 5, skipMs: 1, points: 500 } # a transient: `kind: tran` is the default
points: 1
visible: true
- name: "10 kHz" # same shape; visible: false = run at grading only
source: { kind: sine, amplitude: 1, frequencyHz: 10000 }
load: { kind: open }
points: 1
visible: false
- name: "Bode" # an AC sweep (ADR-083): the source is the DC bias
source: { kind: dc, volts: 0 }
load: { kind: open }
analysis: { kind: ac, fStartHz: 10, fStopHz: 100000, pointsPerDecade: 20 }
points: 1
reference: { components: [...], wires: [...] } # the teacher's own circuit: the key
grading:
mode: simulation # manual (default) or simulation; llm is closed (ADR-063)
tolerance: 0.05 # simulation, transient stimuli: the pass threshold
bode: { magDb: 1, floorDb: 60, phaseDeg: 10 } # simulation, AC stimuli: the envelope
rubric: "..." # criteria for the teacher's manual grading
showExpected: false # overlay the reference's curve on the visible stimuli
simulationsPerMinute: 10 # N-SEC-07, the budget of the Simulate button
- The palette lists the kinds the student may place, out of the sixteen of the library (R, C, L, four diodes, four bipolars and MOSFETs, an op-amp, and the
GND,VCC,VEEterminals).maxComponentscaps what is placed: a terminal names a net, it is not a part, so it does not count. - Supplies:
vccandveepublish a rail at the voltage the teacher wrote;nullremoves the symbol from the palette. The op-amp is ideal and clamped to those rails. commonGround(default on) makesin-andout-the reference node. Off, they are two independent nets the student has to wire, and aGNDsymbol is what names the reference.- A stimulus is a test case: a name, a source (
dc,sine,pulse,step), a source resistance, a load (open,resistor,capacitor), an analysis, points, andvisible. The analysis is either a transient window (kind: tran, the default:stopMs,skipMsto drop the settling,pointssamples kept) or an AC sweep (kind: ac, ADR-083:fStartHztofStopHz, 0.01 Hz to 1 GHz,pointsPerDecade5 to 200, at most 2000 points). An AC stimulus needs adcsource, whosevoltsis the bias the circuit is linearised around (circuit.ac_needs_dc_sourceotherwise): the EMF isDC <volts> AC 1, and what is measured is the Bode plot ofv(out)against it, the source resistance and the load included. A hidden stimulus is only run at grading, exactly like a hidden case of acodequestion (docs/06 Q8 applies to its name). - The reference is a schematic, not a netlist and not a waveform: the teacher draws the circuit they expect. A
simulationgrading without a reference, or without a stimulus, is refused at publication (circuit.simulation_needs_reference,circuit.simulation_needs_stimulus). - Three grading modes:
manual(default): the teacher reads the schematic — and, when there are stimuli, the simulated curves — and grades by hand;simulation: both circuits are simulated under every stimulus and the OUTPUT WAVEFORMS are compared (below);llm: closed (ADR-063): the simulation is a circuit's grading, never a model. The editor no longer offers it, publication refuses it (circuit.llm_not_available), and a version published with it is graded asmanual.
- The grading rule of
simulation(ADR-019): the student's schematic and the reference are each turned into a SPICE netlist, run through ngspice for each stimulus, andv(out)is compared sample by sample. A TRANSIENT stimulus passes when the normalised RMS distance between the two output voltages is at or undertolerancetimes the reference's peak-to-peak swing. An AC stimulus passes when the student's Bode plot stays, at EVERY frequency of the sweep, inside an envelope around the reference's (ADR-083): with the floor at the reference's peak minusbode.floorDb, a frequency where the reference is at or above the floor needs the magnitudes withinbode.magDband — unlessbode.phaseDegisnull— the phases, compared modulo 360°, withinbode.phaseDeg; below the floor the student's output only has to stay under the floor plusbode.magDb, and the phase is not compared. The envelope is checked on the full ngspice table, both runs sharing one frequency grid; the details store the decimated curves and the worst gap (envelope: { worstDb, worstDeg, outside },errornull). Either way a stimulus's points are then earned whole, and the total is the sum of the stimuli that passed. What is compared is behaviour, never topology: a circuit drawn differently, with merged resistors or another ordering, that produces the same output is a correct answer. - When the REFERENCE fails to simulate, or the runner is unreachable, the grading is stored
proposedwith the reason, nevervalidated: an unrunnable question is the teacher's problem, not a zero for the student. A student circuit that fails to simulate is a failed stimulus with its machine reason (floating_pin,spice_failed), which is information, not an incident. showExpectedoverlays the reference's output on the student's plot, for the visible stimuli only. It needs a reference, and it is what decides whether the reference's curve may travel in a grading's details at all.- Answer:
{ schematic }— the components with their position, orientation, designator and value, and the wires with their routed polyline. No netlist is ever stored, and none ever comes from the browser: it is rebuilt server-side from the stored schematic and the stimulus, every time (invariant 14). Values are parsed case-sensitively, because SPICE reads1Mas milli and1Megas mega. - Expand (ADR-046, second addendum): the player and the editor offer the canvas over the page exactly as
diagramdoes (§4.14) — an Expand button where the host lends a layer, a one-line note in place of the inline canvas while it is open, and, in the player on a screen under 1024 px, a preview that opens it. The student's layer keeps the remaining time, the save state and "Back to the questions" (Alt+←/→ too); the teacher's, over the reference circuit, keeps what is edited, the draft's save state and "Close". The netlist strip travels with the canvas into the layer. Escape first reaches the canvas, which cancels the wire being drawn, the armed part or the selection and closes the layer only when there was nothing to cancel; Escape in a field of the inspector only leaves the field. - Player button: "Simulate" runs the student's own circuit under the VISIBLE stimuli and plots
v(in),v(out)and the load current — or, for an AC stimulus, the Bode plot ofv(out), magnitude above phase on a logarithmic frequency axis — with the reference's curve beside them whenshowExpectedis on. It goes throughPOST /app/api/attempts/:id/simulate, is budgeted bysimulationsPerMinuteper attempt, and is refused once the attempt is closed like any other write. The button is absent when the question has no visible stimulus. - What
toStudentstrips (invariant 4): the reference, the hidden stimuli (only their count and their total points remain), the tolerance, the Bode envelope (magDb,floorDb,phaseDeg: the whole grading block goes), the rubric and the grading mode. A visible AC stimulus keeps its sweep, which the Simulate button needs. What stays is what the student needs to draw and to simulate: the prompt, the palette, the supplies,commonGround, the visible stimuli and the budget. - The key (
toSolution) is the reference schematic alone: the stimuli and the grading block are no part of it (ADR-037). - Points: the sum of the stimuli's points;
defaultPointsproposes it.
4.12 Poll poll, phase 2¶
This is not a question type but a single-item evaluation mode, which accepts mcq, short and a scale variant from 1 to N. The live projection screen draws the answers with the host's own PollBars (apps/web/src/poll), counted server-side by pollTally in @quiz/domain, for mcq and short alike. There is no per-type Stats component: that optional hook of QuestionTypeClient was never mounted and was removed on 2026-09-28.
4.13 Categorize categorize¶
Shown as Categorize / « Classement » in the interface. The student sorts cards into labelled columns; some cards may be distractors that belong nowhere and stay in the tray. Brought forward outside the phases (ADR-036, packages/qt-categorize); the origin of its board is mockups/categorize.html.
Configuration (configVersion: 1):
config:
prompt: markdown
columns: # 2 to 6
- { id: q7m2xk4a, label: "Entier", cards: [f3n8wz1c, j2r5hd7s] } # the key, in order
- { id: c9t1vp6z, label: "Virgule flottante", cards: [a4k7mq2x] }
cards: # 1 to 30, markdown, short
- { id: f3n8wz1c, text: "`int`" }
- { id: j2r5hd7s, text: "`size_t`" }
- { id: a4k7mq2x, text: "`double`" }
- { id: e2z5oa7r, text: "`string`" } # listed by no column: a distractor
ordered: false # the rank inside a column counts too
shuffleCards: true # per student
shuffleColumns: false # off by default: the column order often means something
policy: inherit # inherit (default), per_item, all_or_nothing
- The key lives in the columns: each column's
cardslists the ids of the cards that belong there, in the expected order. A card listed by no column is a distractor. A card belongs to one column at most. The schema refuses a duplicate id (categorize.duplicate_id), a key naming a card that does not exist (categorize.unknown_card), a card in two columns (categorize.card_twice) and a question without a single target (categorize.no_target). - Ids are opaque: every id matches
/^[a-z0-9]{4,40}$/; the editor mints eight random base-36 characters, never an index nor a label. The student's view carries them — the answer is written with them — so an id must say nothing about where a card goes. An author who writes ids by hand (MCP, import) is responsible for that: a readable id such asint-entierwould reach the student throughtoStudent.
Answer: { columns: { <column id>: [card ids, in order] } }. A card in no column is in the tray and absent from the answer. answerMisfit refuses an unknown column or card and a card placed twice (422 answer_invalid, key categorize.answer_misfit); the grader still reads such a stored answer defensively (first place wins, unknown ids ignored). isAnswered: at least one card is placed.
Scoring. The formulas live in @quiz/domain/categorizeScore. With T targets, D distractors, n = T + D, k columns, and in one answer t targets at their place, x targets placed at a wrong place, p distractors placed:
| Policy | Formula | Comment |
|---|---|---|
per_item |
(t + D − p) / n | Default. Every card is worth 1/n; a distractor left in the tray counts |
all_or_nothing |
1 if t = T and p = 0, otherwise 0 | Every target at its place, no distractor placed |
- An answer that places no card scores 0, whatever the policy: the tray is where an unplaced card stays, and an empty answer must not earn D/n for leaving the distractors "right".
- Which rule applies, in this order: the evaluation's negative marking (below); otherwise the question's own
policy; when it saysinherit, the evaluation'ssettings.categorizePolicy(absent =per_item; set under the evaluation's advanced options, a row shown only while the evaluation holds a categorize item; no per-teacher preference, unlikemcqPolicy); without an evaluation (the Try panel),per_item. The policies are named apart frommcq's on purpose:symmetricortrue_falsewould promise themcqformula. - "The order counts" (
ordered: true): a target is right only in its column AND at its exact 1-based rank. A card missing near the top of a column shifts every card under it; this cascade is accepted on purpose (ADR-036) for a rule the student can check by looking at the board. - Negative marking (the evaluation's
settings.negativeMarking, ADR-026 extended by ADR-036) overrides the policy: f = (t − (x + p) / (k − 1)) / T, not floored, in [−1, 1]. A target is +1 in its column and −1/(k − 1) elsewhere, so placing a target at random has an expected value of 0 (without the order); a card left in the tray is 0, target or distractor. The total of the evaluation is floored at 0 (attemptTotal), a manual correction ranges over [−max, max]. - Both policies lie in [0, 1]. The fraction is multiplied by the item's points (
defaultPoints: 1) and rounded to the hundredth.gradings.detailsrecords the counts, the resolved policy,negativeMarkingwhen it applied, and a verdict per card (placed,rank,expected,expectedRank,right).
Student (toStudent, invariant 4): the prompt, the columns without their cards (the key), every card (distractors included: which card is a distractor is the key too), ordered (a rule the student must know before answering), and negativeMarking: true when the evaluation uses it. The policy never leaves. The cards (shuffleCards) and the columns (shuffleColumns) are shuffled per student from (attempt.seed, item.id, purpose) on two streams (decision D19). Unless the feedback policy publishes the key, studentDetails drops each card's expected and expectedRank, keeps the verdict (right) of the cards the student PLACED only — feedback on their own answer, like cloze's per-blank verdict — gives an unplaced card no verdict, and drops the counts T and D. Which cards are distractors is the key; how many there are is not a secret (the published fraction lets a student compute it), and dropping the counts is defence in depth only.
Editor: the teacher writes the key on the same board the student answers on. Every card starts in the tray; dragging it into a column puts it in that column's key; a card left in the tray is a distractor — there is no separate checkbox to disagree with where the card sits. Removing a column sends its cards back to the tray. The options: the order counts, shuffle the cards, shuffle the columns, the policy (inherit by default).
Player: the prompt, a line saying whether the order counts, the negative-marking line when it applies, the tray of unplaced cards, and the columns side by side under it (they wrap onto more rows on a narrow screen). A card moves two ways, neither being the "real" one: drag and drop (the pointer, or the keyboard: Space picks up, the arrows carry, Space drops; a column is sortable inside) and click then click (select a card, then a column or the tray, each of which offers a named "drop here" button while a card is selected) — what works on a phone and with a screen reader.
Review: the student's board with each card marked right or wrong and, when the key is published, where each card was expected.
Dashboard and more: the live cell shows "placed/total", figures only. Not pollable, no aggregate (the hook would only see opaque ids).
4.14 Diagram diagram¶
Shown as Diagram / « Diagramme » in the interface. The student draws a diagram of a notation the teacher chose: a UML class diagram, a state machine, an entity-relationship model, a flowchart, an automaton, and so on. It replaces the drawing type of §4.10. Brought forward outside the phases (ADR-046); the origin of its editor is mockups/uml.html.
Two packages, so that the editor serves more than one type (ADR-046 §1):
packages/diagram(@quiz/diagram) is the engine: the scene model, the catalogue of kinds, the orthogonal router and the side anchoring, the straight-line layout and the text serialisers (./server, no React), and theDiagramEditor/DiagramViewcomponents (./client), whose text pane holds the parsers. It depends on@quiz/coreand@quiz/uiand never imports aqt-*package.packages/qt-diagramis the question type, registered in both registries.
Kinds (all eight in v1). The kind is chosen once per question; the student's toolbox holds that kind's elements and nothing else.
| Kind | Elements | Links | Lines | Text form |
|---|---|---|---|---|
class |
class: name, stereotype, abstract, body lines cut into compartments by ---, {static} underlines, {abstract} italicises |
association, navigable association, inheritance, realisation, dependency, aggregation, composition; a name and a multiplicity at each end | orthogonal | PlantUML |
usecase |
actor, use case, system boundary (contains, never blocks a line) | association, «include», «extend», generalisation | orthogonal | PlantUML |
state |
initial state, state (name and internal activities), final state | transition labelled event [guard] / action |
orthogonal | Mermaid stateDiagram-v2 |
er |
entity: attributes name : type, PK underlines |
relationship with a crow's-foot cardinality at each end (1, 0..1, 1..*, 0..*) and a verb |
orthogonal | Mermaid erDiagram |
flow |
start/end, action, decision | arrow with a label (oui, non) shown where it leaves |
orthogonal | Mermaid flowchart |
automaton |
state, with an initial and an accepting flag | transition labelled by its symbols | straight | Graphviz DOT |
graph |
vertex | edge, arc; a weight | straight | Graphviz DOT |
free |
freehand stroke, line, square, rectangle, circle, ellipse, triangle; a shape may carry a short label | none | — | none |
- Orthogonal lines are routed on the grid of 20 by the A* router of
circuit(a turn penalty, the boxes as obstacles, a small cost for running along another line). An end attaches to a side of its element, the side that best faces the other end while that side has a free grid point; the ends sharing a side spread along it in the order of their targets, so that they do not cross. A decision gives one end per vertex. Ends on an ellipse, a diamond or a circle slide onto the shape. - Straight lines join two circles centre to centre, bend apart into curves when several join the same pair, and loop over the top of an element that points to itself.
- A line may carry elbows (
via, at most 16), which the router must pass through; the student adds one by double-clicking the line.
Configuration (configVersion: 1):
config:
prompt: markdown
kind: class # one of the eight kinds above
reference: { nodes: [...], links: [...] } # the teacher's diagram: the key
starter: { nodes: [...], links: [...] } # optional: what the student starts from
rubric: markdown # the criteria, teacher-only
- The scene is the record (ADR-046 §2): elements with their position on the grid and their content, links with their ends, their elbows, their name and end labels; a
freescene holds shapes and strokes. The text form is DERIVED from the scene by the kind's serialiser, never stored. The server never parses a text written in a browser. - The starter is optional. The editor offers "Copy the reference into the starter", after which the teacher removes what the student must add; the starter is then edited on its own. A draft may lack a reference; publication refuses a question without one (
diagram.reference_missing) and a reference or starter holding an element or link the kind does not have (diagram.kind_mismatch). - Ids are opaque: the editor mints random ones. A starter reaches the student with its ids, so an id must say nothing (the rule of
categorize, ADR-036). - Limits: 80 elements, 160 links, 16 elbows a link, 120 characters a name, 40 body lines of 200 characters, 50 000 characters of text in all; for
free, 200 shapes and 4 000 stroke points in all; coordinates within ±20 000. No text holds a control character (a new line in a name would forge the text form). The autosave sends the whole answer every 300 ms, so the answer is bounded likerich's. The editor refuses an edit that would break a limit.
Student (toStudent, invariant 4): the prompt, the kind and the starter. The reference and the rubric never leave. The text form is not shown to the student in v1: the text tab exists for the teacher only (editor and review).
Answer: { scene }, the student's scene, which starts as a copy of the starter (or empty). answerSchema enforces the limits; answerMisfit refuses an element or a link that the question's kind does not have (422 answer_invalid, key diagram.answer_misfit). isAnswered: a stored scene that holds something. The player writes nothing until the student's first edit, so an untouched question has no answer at all; a scene edited back to the starter still counts as answered, and grade settles it as empty (ADR-046 addendum).
Scoring, v1: manual, like rich. grade proposes 0 points (state: proposed, details.reason: manual) for an answer, and a validated 0 for none, or for the untouched starter (reason: empty). An answer holding what the question's kind does not have — met when a regrade repoints the item at a version of another kind (F-GRADE-06) — is a proposal of 0 with reason: kind_mismatch, never an empty grade. The grading panel stacks the student's diagram, then the reference, then, for the teacher, the two text forms in tabs "Student" and "Reference", where a difference is easier to see than on the canvas. The grading table's cell gives the counts only ("7 elements · 6 links"), no thumbnail.
Scoring, later (not v1, ADR-046 §5): a proposal computed per kind, always proposed, never a diff of the texts (an ordering or a renaming would read as a mistake): the equivalence of two deterministic automata, which is decidable and the one kind that could be validated outright; vertices, edges and weights for a graph; classes, members and links matched by name, with partial credit, for class and er. And the LLM service (F-LLM-01..04, Q13), fed with the TEXT forms of the reference and of the answer, which costs no vision; free would go by a rendering. Both wait for their own ADR. Amendment (ADR-063): the LLM half is decided: a diagram with a rubric or a reference is sent, after the close, in the text forms of the answer and of the reference, and comes back as a proposal; free stays graded by hand until it can be sent as a picture.
The key (toSolution): the reference and the rubric; studentSolution keeps the reference alone (ADR-037).
Editor: a grid of cards, one per kind, each with its icon and, for the teacher, its name and one line on what it draws. Changing the kind of a draft empties the reference and the starter, after a confirmation; once the question has a published version the kind is locked (the editor shows it without the grid, from EditorProps.published). Then the reference canvas with its text tab (editable by the teacher, parsed on every keystroke; a line in error is named and the diagram is left as it was), the starter, the rubric. defaultPoints proposes 1. The reference and the starter each have an Expand button (EditorProps.Expand, ADR-046 second addendum): the canvas, text tab included, over the page under a bar with what is edited ("Reference diagram"), the draft's save state and one primary action, "Close" (Escape too, once the canvas has nothing to cancel).
Player: the canvas inline, under the prompt, and an Expand button that opens it over the page with a margin of 16 px. The overlay keeps a thin bar: the remaining time, the save state, and its one primary action, "Back to the questions" (Escape too). It is a layer of the page, not the browser's full screen: the student keeps the clock the server runs (invariant 5), and Safe Exam Browser (ADR-027) is not asked to change its window. The host lends it through the optional PlayerProps.Expand slot; where it lends none (the grading panel) there is no Expand button. Where a teacher plays the question (the Try tab, the previews), the layer is the teacher's: its bar holds the title and "Close" alone. Escape first lets the canvas cancel what it has in progress (a link being drawn, a tool, a selection) and closes the overlay only when there was nothing to cancel; Alt+←/→ closes it and moves to the neighbouring question. Both views edit the same answer, which the autosave sends as usual. On a screen under 1024 px the inline canvas is a preview and the overlay is where one draws.
- The toolbox shows icons only: the student is expected to know the notation, and a label would give it away. Every icon carries an accessible name, and every string of the editor comes from the host's dictionary in
enandfrthrough the package'sstringsprops, as forcircuit(N-I18N-01). - Double-click on the grid places the kind's usual element; drag from the border of an element to another links them; double-click on an element edits it, on a line adds an elbow, on an elbow removes it; wheel to zoom, right-drag to pan, Ctrl Z / Ctrl Y to undo and redo.
Review: the student's diagram and, when the key is published, the reference under it; the teacher also sees the text forms.
Dashboard and more: the live cell shows the number of elements and links, figures only. Not pollable, no drill rating, no aggregate.
4.15 Workspace workspace¶
Shown as Workspace / « Espace de travail » in the interface. An advanced question for a supervised evaluation: the student works in the online workspace (VS Code with a compiler, apps/codespace, ADR-047) and the server collects a file at the end. Decided by the product owner on 2026-10-05, ADR-075 (proposed). Planned: M6-08 and M6-09; nothing is implemented. Package packages/qt-workspace, registered through both registry entry points.
Configuration (sketch; M6-09 fixes the schema):
config:
prompt: markdown
language: c # the languages of the workspace image
template: | # locked regions with @@lock / @@endlock, as `code`
...
files: [] # extra files seeded in the volume, as `code`
collect: [main.c] # the declared path(s) the server collects; names, never request paths
compileArgs: "-Wall"
limits: { timeMs: 2000, memoryMb: 128, outputKb: 64 } # the runner's, at grading
tests: { mode: io, cases: [...], compare: {...} } # as `code`
referenceSolution: | # as `code`; teacher-only
...
- Supervised evaluations only: publication refuses it in an exercise, a poll, a drill and an exam that requires neither SEB nor kiosk (
workspace.requires_supervision). On a kiosk-only evaluation it is refused until proof B (M6-07). - Behaviour: opening the question mints a launch token and leads to the portal; the workspace is created for the attempt (a fresh volume for each) with a closed network and no git channel. At the attempt's deadline + 3 s (accommodations included) Quiz orders freeze and collection; the portal never decides the end. The volume is destroyed once the file is stored. The student keeps no copy.
- Seed and
toStudent(invariant 4): the volume is seeded from the student view alone: template, extra files, visible cases. Hidden cases and the reference solution never enter it;toStudentstrips them as forcode, and a test searches the seed payload and file tree for their values (05 §5.7). The student payload also carries no hash, blob id, token or portal path. - Answer: a blob reference to the collected file (or tar), with a SHA-256 stamped by the server on the bytes it received and the server's receipt time. Never uploaded by the browser. No file: an empty answer; a failed collection: empty and flagged (
details.reason: collect_failed), the volume kept for the teacher. - Grading: the two-phase runner pattern of
code. The server rebuilds the program from the stored template and the collected file (invariant 14) and runs all cases inapps/runner; points come from the cases, ascode. The result isstate: proposed; the teacher validates or corrects it in the grading panel, which shows the collected file beside the case verdicts. Compile failure proposes zero; no file, a validated zero (reason: empty). - Points:
defaultPointsfollows the cases, ascode. Not pollable, no drill, noshowKeyworkspace. The class debrief shows the distribution of case success. - Fallback: when the portal is down at opening, the teacher swaps in a
codequestion (§4.7); the type never runs outside the portal. - Capacity: at most 30 workspaces per evaluation until M6-05 measures the engine VM (
workspace.capacity).
4.16 Mathematical expression math¶
Shown as Math expression / « Expression mathématique » in the interface. The student types a formula — a function of the variables the question declares — in a math field, and it is accepted in any form that takes the same values: \frac{R_1 R_2}{R_1+R_2} and \frac{1}{1/R_1+1/R_2} are one answer, R_1+R_2 is the series mistake. Decided by the product owner on 2026-10-11 after the challenge of issue #460, ADR-102 (proposed; prototype on branch qt-math). Package packages/qt-math, registered through both registry entry points; the reading and comparison rules are @quiz/domain/math, pure, unit-tested, with Compute Engine as the parser, a float interpreter of its own for the values, and no computer algebra system.
Configuration (configVersion: 1): prompt, the symbols the answer may use, the accepted answers in order, the two knobs of the comparison, the field's rules and the graph.
| Field | Default | Rule |
|---|---|---|
symbols[]: name, min, max |
one symbol x in [0.5, 2] |
1 to 6. A name is a letter, then letters and digits, then an optional _subscript (x, R1, R_1, omega — what \omega parses to). e, i and pi are constants, refused as names (math.reserved_symbol); a name declared twice (math.duplicate_symbol) or an interval whose min is not below max (math.symbol_range) is refused by the schema |
answers[]: value, points |
one answer, points 1 |
1 to 10 accepted answers in LaTeX, each worth a fraction 0 to 1 of the item, compared in order; the first that matches wins, like the matchers of short (§4.5) |
tolerance |
1e-6 |
relative, over 0 and at most 0.5; 0.01 lets a rounded constant through |
samples |
10 | the sample points, 3 to 50 |
forbidden |
empty | functions from the closed list MATH_FUNCTIONS: sin cos tan asin acos atan sinh cosh tanh sqrt exp ln log abs power; e^x counts as exp |
maxLength |
200 | 10 to 300 characters of LaTeX; 300 (MATH_MAX_LATEX) is the cap of every read, key or answer |
plot |
none |
none, answer or target; a graph needs exactly one symbol (math.plot_one_symbol) |
Reading: a key and an answer are LaTeX, parsed by Compute Engine into its canonical tree, so \frac{1}{x}, x^{-1} and 1/x are one reading. The engine only parses and canonicalises; the values come from a small float interpreter of the canonical tree (@quiz/domain/math, ADR-102 decision 3): the arithmetic, powers and roots, absolute value, \ln, \log (base 10, or \log_b with its base, as on the provided calculator, ADR-069), the trigonometric, inverse trigonometric and hyperbolic functions, the constants e and pi; angles are in radians. Its cost is the size of the tree, which the length cap bounds; a value that overflows is simply no value. The parse is bounded too, as ADR-099 bounds a regex before it costs anything: a run of three or more adjacent signs (------x) and more than eight absolute-value bars (|, \vert, \lvert, \rvert, \mid) — the two shapes measured exponential in the engine's parser — are refused lexically before the parser runs, and a numeral the engine cannot hold (1e999…) is refused the same way; all three read as syntax. Under the 300-character cap every other shape measured on 2026-10-11 stays within ~120 ms, an answer written by a person in 1 to 3 ms, which is what N-PERF-02 asks since the live grader parses on every autosave; the engine is recycled every thousand reads so a stream of fresh names cannot slow it (ADR-102 decision 10). Any other construct — a sum, an integral, a derivative, a factorial, a complex number, min/max, an equation, an infinity — is refused at read time (unsupported, with the operators' names). A read fails on the first problem met, in this order: empty, too_long, syntax, unknown_symbol (with the names), unsupported, forbidden_function. A key is read against the symbols alone; an answer against the symbols AND the field's rules. The server reads the stored LaTeX itself: what the browser computed is an echo, never trusted. Nothing compiles an expression to JavaScript, so the CSP's refusal of eval (ADR-069) is never in the way.
Field rules: forbidden and maxLength, with the symbols' names, are ONE object (fieldRules), which says what the field ACCEPTS, as the constraints of short do (§4.5): toStudent spreads it into the student view, the player reads the answer with the view itself, and the grader with the same object, so the two cannot differ. Unlike short's, the rules bear on the grade, because the student was told: an answer that still breaks one at the close is "not readable" and worth 0 (below).
The student's field and the echo: a one-line MathLive <math-field>, rendered as it is typed, with MathLive's own keyboard toggle inside it and its virtual keyboard opening on focus on a touch screen; where the web component cannot load, a plain LaTeX input takes its place, so the surface is never blank. Under it, a hint names the variables and the reading conventions, then the echo: Read as, with the expression rendered as the parser understood it, or the problem in the student's words ("Unknown variable: y. The variables are x", "Not available here: Sum", "Not allowed here: sin"). A syntax slip is told while typing, never graded as a mistake of mathematics.
The grading rule (ADR-102): a blank or absent answer is no answer, 0 with details.status: graded. Otherwise the answer is read with the field's rules; if it does not read, it is worth 0 with details.status: invalid and the problem stored whole in details.problem. Otherwise samples points are drawn, each symbol uniform in its [min, max], from a seed made of the QUESTION's content — its first key and its symbols (samplePoints, hashSeed) — 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). The answer is then compared to each accepted answer in order (compareMath). At every point where the key has a real value, the answer must have one, and the two agree when
|k − a| ≤ tolerance × max(|k|, |a|, K), with K the largest |k| over the compared points
— relative, with a floor at the key's magnitude so a near-zero value of a large function does not fail on float noise. A point where the key has no real value (a pole, a root of a negative number, an overflow) is skipped. The first accepted answer that agrees at every compared point gives its points times the item's. What is compared is value, never form (ADR-019 for circuit): the domain is part of the question, and \sqrt{x^2} is x on [0.5, 2], not on [-3, 3]. Publication refuses a key that does not read (math.key_<problem>, one issue per key: math.key_syntax, math.key_unknown_symbol, math.key_unsupported, …) and a first key with no value anywhere on the domain (math.key_undefined); a key that does not read in a stored version is skipped by the grader.
What toStudent strips (invariant 4): the keys (answers), the intervals (min, max), the tolerance and the samples. What stays is the prompt, plot and the field's rules: the symbols' NAMES, forbidden and maxLength. The plot's exception: with a plot, the student view carries axis: { from, to }, the window of the one symbol — its interval, which therefore leaves with the plot; and with plot: target, the first key's curve as VALUES only, target: 64 values (MATH_PLOT_POINTS) on the even grid over the window, null where the key has no value, never its text: a deliberate numeric exit of the key, in the spirit of 05 §5.7 and like showExpected of circuit (§4.11). The grading points are random, never the plotted grid. Tested by toStudent.test.ts (the keys' text and points searched for in the serialised view, with and without a plot) and by the registry's contract test on the full fixture of testing.ts.
The plot: plain SVG, the student's expression drawn over the window as they type, on the same grid as the target, broken where it has no value or leaves the window; the target curve dashed behind it when the teacher asked for it. Display only.
Answer: { latex }, at most 300 characters (answerSchema); isAnswered: not blank.
Details: status (invalid: the answer could not be read; graded: compared to the keys, or no answer), problem (the whole read problem, code and names, when invalid), matchedIndex (which accepted answer matched, in key order; null when none), fraction (its points), latex (the answer as the parser read it, for the review's echo). studentDetails drops matchedIndex while the key is hidden; the verdict, the problem and the echo stay.
The key (toSolution): the accepted answers with their points; nothing in it is teacher-only, so a student shown the key reads the same.
Editor: the statement; the Variables, one row each with its name and its interval (From, To), up to six; the Accepted answers, each typed in the same math field the student gets, with its points as a fraction and the echo or the problem under it; and in the host's right column the Tolerance and the Sample points, the Forbidden functions (a multi-select over the closed list), the Max length, and the Graph as a segmented control (None / Answer / With target), disabled unless the question has exactly one variable. defaultPoints proposes 1; nothing is shuffleable.
Review: the answer as typed and its verdict — Accepted, Partially accepted, Not accepted, or Not readable with the reason read from the stored details, never re-parsed — the echo, which accepted answer matched when the key is shown, and the accepted answers once the solution section is shown.
Grading table (ADR-044 §2): one column, the LaTeX as a chip tinted by its verdict, sorted by the normal form of the text so two papers that wrote the same thing sit together; the expected row holds the accepted answers. A "not readable" answer is a 0 the teacher may override there.
Dashboard and more: the live cell shows the LaTeX on one line; the class debrief groups the answers by the text typed, as short (ADR-033) — never by equivalence. Not pollable, no drill (POLL_TYPES and DRILL_TYPES unchanged), no parameters (ADR-056 §10 keeps mcq, short and cloze).
Out of v1, each settled later without a migration (ADR-102 decision 11): parameterized keys, equations, inequalities, intervals, sets and vectors, form checks (simplified, factored, a required function), a maximum degree, drill and polls, a formula blank in cloze, negative marking, anticipated mistakes with a feedback.
4.17 Blocks blocks¶
Shown as Blocks / « Blocs » in the interface. The student assembles a program of blocks that drives a warehouse robot from its start cell to the loading dock without hitting a rack; the program runs on several warehouses, some hidden, so it must read its sensors rather than count cells. Decided by the product owner on 2026-10-11, ADR-103; package packages/qt-blocks, interpreter @quiz/domain/blocks. The origin of the player is the prototype the ADR names.
Configuration (configVersion: 1):
config:
prompt: markdown
world: { kind: warehouse } # a discriminated union, one member in v1; the world fixes the block set
toolbox: [forward, turn, repeat, repeat_until_dock, if_sensor] # a non-empty subset, default all
starter: [] # a program preloaded in the student's workspace, default empty
cases: # at least one
- name: "Allée dégagée"
map: # ASCII rows: . floor, # rack, ^ > v < start and heading, D dock
- "..#..#.."
- "........"
- ">......D"
- "...#..#."
battery: 40 # forward moves allowed on this map
visible: true
points: 1
- name: "caché A"
map: ["^...D", ".#.#.", "....."]
battery: 20
visible: false
points: 1
referenceSolution: # a program: the key; mandatory at publication
- kind: repeat_until_dock
body:
- kind: if_sensor
sensor: free
where: front
then: [{ kind: forward }]
else: [{ kind: turn, dir: right }]
allOrNothing: false
- A program is a list of statements:
forward;turnwithdirleftorright;repeatwithtimesand abody;repeat_until_dockwith abody;if_sensorwithsensor(freeorblocked),where(front,leftorright),thenandelse(Stmtin@quiz/domain/blocks). Kinds are English and carry no position: Blockly's own serialization is never stored (ADR-103 §5). Every program a schema accepts is held toPROGRAM_LIMITS(statements, nesting depth,repeat.times): the answer, and in the config thestarterand thereferenceSolutionalike, since the server runs the reference at publication. Each schema measures the raw input withprogramStatsbefore parsing it recursively. - A map is
MAP_SIZE.mintoMAP_SIZE.maxrows ofMAP_SIZE.mintoMAP_SIZE.maxcells each, all of the same length, with exactly one start and one dock.xcounts columns from the left,yrows from the top;^faces north (towards row 0). The schema refuses anything else with the key ofparseMap:blocks.map_too_small,blocks.map_too_large,blocks.map_bad_char,blocks.map_not_rectangular,blocks.map_no_start,blocks.map_two_starts,blocks.map_no_dock,blocks.map_two_docks. - The battery is per case: maps differ in size. A turn costs nothing, a forward move costs one.
batteryis an integer from 1 toBATTERY_MAX; the schema refuses more, and the interpreter clamps whatever it is given to that range, so a run stays bounded whatever the caller passes. - The reference solution is mandatory at publication, not for a draft. Publication runs it on every case, visible and hidden, and refuses a question whose reference is missing (
blocks.reference_missing) or does not reach the dock on a case (blocks.reference_fails_case, naming the case). The reference grades nothing.
The run: runProgram of @quiz/domain/blocks, the same function in the browser and on the server, whose header is the reference for what each statement does and how a run ends (dock, collision, battery, loop, short). Every statement visited and every loop iteration costs one step of stepBudget(battery), a budget derived from the battery, never from a setting, which holds for nested empty loops (ADR-103 §4).
Answer: { program: [statements] }, the stack under the workspace's single "when the robot starts" block; detached blocks are discarded on save. The answer schema enforces the program limits; answerMisfit refuses a program that uses a kind the toolbox does not offer (422 answer_invalid, key blocks.answer_misfit). isAnswered: the stored program is non-empty. The hook sees no config, so "differs from the starter" is carried by the answer itself: the player stores a program equal to the starter as the empty program, shows the starter for an empty one, and says "unchanged starter: not an answer yet" beside the block count. An untouched question is therefore unanswered and graded 0. A client that sends the starter itself (bypassing the player) is counted as answered and graded on it. A program a re-grade finds outside the toolbox (F-GRADE-06 after the toolbox narrowed) is graded as the empty program, validated, with no comment.
Scoring: as code (§4.7). A case passes when its run ends with dock; the grade is the sum of the points of the cases that passed, or all or nothing under allOrNothing. The path length is never rewarded. Grading is synchronous and final (state: validated): no runner, no proposal. gradings.details records, per case, the verdict, the reason, the moves and the trail.
Student (toStudent, invariant 4): the prompt, the world, the toolbox, the starter, the VISIBLE cases whole (name, map, battery, points) and, of the hidden cases, only their count and total points. toStudent never includes the referenceSolution key nor any container of hidden cases, which the test asserts by forbidden key; it also searches the serialized view for the VALUES of the key (05 §5.7): each hidden map's distinctive rows (maps travel as ASCII rows, never as coordinates), the hidden case names, the hidden batteries and a serialized fragment of the reference.
studentDetailsruns only when the key is not shown: undershowKey,filterDetailshands the details over whole. It keeps a visible case's full result (verdict, reason, moves, trail) and replaces a hidden case with{ name, visible: false, points, ok, reason }, wherenameis#nunlessshowHiddenCaseNamesis on andreasonis the coarse one (collision,battery,loop,short): never the trail, the move count or a step index. Its test searches its output for the hidden trails' cells and the move counts.gradings.detailsnever stores a map. The key (toSolution) is the reference program and the hidden cases; undershowKeythe hidden maps and the reference reach the review fromtoSolutiononly.
Editor: the prompt; the toolbox as checkboxes; the starter program and the reference solution, each in a block workspace; a secondary "Check the reference" button that runs the reference on every case in the browser and lists the verdicts; the case list, each case with its name, a clickable grid over its map (a click cycles floor and rack, a tool places the start with its heading or the dock), its battery, its visibility and its points; the "all or nothing" switch. Long sections are cards, as in the code editor.
Player: the prompt, then the block workspace full width — a permanent palette in three labelled groups (Movement, Control, Sensors) holding the toolbox's blocks, a Blocks | Code switch above it (the Code tab shows the JavaScript Blockly generates, read-only, never executed), the block count — then a Simulation card whose one primary action is "Test on every warehouse": it runs the VISIBLE cases in the browser through the interpreter and fills the case table (case, warehouse thumbnail, result, verdict), with a foot line on the hidden cases' count and points. The selected case is animated from the interpreter's trace, with a battery meter and the secondary buttons "Run here", "Step" and "Reset". No API route is involved; the grade is the server's. The workspace is navigable from the keyboard (Blockly's core keyboard navigation). Block labels are interface strings (qt.blocks.c.*, en and fr). Blockly is loaded lazily and self-hosted; without it (a chunk that failed to load) the surfaces show the program as read-only pseudo-code with a message.
Review: the student's program, read-only, and the case table with each case's verdict and reason; the teacher, and a student under showKey, also see the trails, the hidden maps and the reference.
Dashboard and more: the live cell shows the number of blocks as a bare figure (the cell is not translated), never a verdict. aggregate is the pass rate of the visible cases, as code. Not shuffleable, not pollable, no generator (wand) in v1. Drillable (§4.1): its grading is automatic and final. Optimised for desktop (docs/00 §0.4).