ADR-036 — Categorize: a new question type, sorting cards into columns¶
Status¶
Accepted (2026-09-29, with packages/qt-categorize, @quiz/domain/categorizeScore
and the evaluation setting settings.categorizePolicy). No migration.
Extended by ADR-041: categorize participates in the drill.
The absence of a drill rating in this record describes the earlier version.
Amended 2026-10-09: the Rollback section is removed (the runbook owns operational procedures).
Context¶
The specification (docs/spec/04) lists no type where the student SORTS:
puts int, double and char * under "integer", "floating point" and
"pointer"; files the steps of a compilation in order; separates what is
undefined behaviour from what is merely unspecified. Teachers write these as
a string of multiple-choice questions today, one per item, which is long to
author, long to answer, and hides the structure the exercise is about.
Moodle's "drag and drop into text" and "matching" types are the usual
workaround; neither fits a board of columns.
The type was mocked up first (mockups/categorize.html): columns side by
side, a tray of the cards not placed yet, cards dragged or clicked into place,
optional distractors that belong nowhere, and an option where the order
inside a column counts. The questions its author settled before the code are
recorded here. The type is brought forward outside the phases of
docs/spec/00, like circuit (ADR-019) and codeimage (ADR-021).
Decision¶
1. One package, one config, the key inside the columns¶
packages/qt-categorize (./server, ./client), registered in both
registries, shown as "Categorize" / « Classement ». The id categorize is the
one stored in the database and in the canonical format.
config:
configVersion: 1
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
- { id: f3n8wz1c, text: "`int`" }
- { id: e2z5oa7r, text: "`string`" } # listed by no column: a distractor
ordered: false # the rank inside a column counts too
shuffleCards: true
shuffleColumns: false
policy: inherit # inherit | per_item | all_or_nothing
- The key lives in the columns: each column lists the ids of the cards that belong in it, in the expected order. A card listed by no column is a distractor, which the student is expected to leave in the tray.
- One card, one column. A card may be listed by one column at most; the
schema refuses a card twice (
categorize.card_twice), an unknown card (categorize.unknown_card), a duplicate id and a question with no target at all (categorize.no_target). The same rule holds for an answer:answerMisfitrefuses a card placed twice or an unknown id (422 answer_invalid) — a card in every column at once would be a free bet. - Every id is opaque and 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 the card and column ids (the answer is written with them), so an id must say nothing about where a card goes. The schema can enforce the alphabet and the length, not the meaning: an author who writes ids by hand (through the MCP server or an import) is responsible for their opacity — a readable id such asint-entierwould reach the student throughtoStudent, and the MCP guide says so. - Limits: 2 to 6 columns (one column is not a choice; six fit a laptop), 1 to 30 cards (past that the tray no longer fits a screen).
2. Scoring: two policies, and the evaluation decides like mcq¶
The formulas live in @quiz/domain/categorizeScore, the reference. With T
targets, D distractors, k columns, and in one answer t targets at their
place, x targets placed at a wrong place, p distractors placed:
| Policy | Fraction |
|---|---|
per_item (default) |
(t + D − p) / (T + D) — every card is worth 1/n, a distractor left out counts |
all_or_nothing |
1 if t = T and p = 0, otherwise 0 |
- An empty answer scores 0, whatever the policy. The tray is where an
unplaced card stays, so doing nothing leaves every distractor "right" and
per_itemwould pay D/n for it. Distractors count only once a card is placed. - The names are not
mcq's.per_itemis close tomcq'strue_false, and the negative rule below resemblessymmetric; reusing either name would promise themcqformula, which differs. Two names of their own, two formulas of their own. inheritand an evaluation-level policy, likemcq. A question that saysinherit(the default) is scored with the evaluation'ssettings.categorizePolicy, set under the advanced options of the evaluation (and of a template, ADR-031, since templates carry the whole settings object). It is stored in thesettingsJSON column, optional, absent =per_item, read throughcategorizePolicyOf— not a column besidemcq_policy, so it needed no migration. The wire enumCategorizePolicyof@quiz/contractsis spelled again (contracts depends on no package) andapps/apichecks it equal toCATEGORIZE_SCORE_POLICIES, both ways, at compile time (modules/pool/routes.ts), likeMcqPolicy. It reaches the grader asGradeContext.defaults.categorize(gradeDefaults). Without an evaluation (the teacher's Try panel),per_item.- Why
settingsand not a column likemcq_policy.mcqPolicyis a column because it is seeded at creation from a per-teacher preference (users.mcq_policy) and was written before the settings object carried scoring switches.categorizePolicyhas a natural default and no preference yet, so an optional key in thesettingsJSON column costs no migration, travels with templates and duplicates for free, and is frozen with the rest ofsettings(configLock). It is the model for the next type that needs a per-evaluation setting: an optional key insettings, a…Ofreader with the default, and an entry ingradeDefaults. - The row is shown only when it matters. The "Categorize scoring" row of
the advanced options appears only while the evaluation (or the template)
holds a
categorizeitem — the item list is already on the screen, so the test is one line. docs/spec/08 keeps what a teacher does not need out of sight, and most evaluations hold no categorize question. Themcqrow stays always shown: multiple choice is in nearly every evaluation, and hiding it is a separate decision. While negative marking is on, both policy rows say it replaces them.
3. "The order counts" means the exact rank, cascade accepted¶
With ordered: true, a target is right only in its column AND at its exact
rank. A card missing near the top of a column therefore shifts every card
under it, and all of them become wrong. A longest-common-subsequence or
pairwise-inversion score would be kinder, but it is a rule a student cannot
check by looking at the board; the exact rank is. The cascade is accepted on
purpose, and the player says the order counts before the student answers
(ordered is published).
4. Negative marking extends to categorize¶
ADR-026 made negative marking a setting of the evaluation that applies to
"every choice question". Placing a card is a choice among k columns, so
the setting now covers categorize too (NEGATIVE_MARKING_TYPES in
@quiz/domain/evaluationConfig), and it overrides the policy as it does for
mcq:
f = (t − (x + p) / (k − 1)) / T, not floored, in [−1, 1]
A target is +1 in its column and −1/(k − 1) in another one, 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: no answer costs nothing. The total is
floored at 0 by attemptTotal, a manual correction may go down to −max
(overridePointsRange), the student is told in the waiting room and on the
question (toStudent publishes negativeMarking: true and nothing else of
the scoring). The waiting room and the setting's description now name both
types.
5. What the student gets¶
toStudent keeps the prompt, the columns WITHOUT their cards (the key),
every card (distractors included — which ones are distractors is the key
too), ordered, and the negative-marking flag. The policy never leaves. The
cards, and the columns when shuffleColumns is on, are shuffled per student
from (attempt.seed, item.id, purpose) on two streams (decision D19); the
column shuffle is off by default because the order of the columns often
means something ("before / after").
The board (editor and player alike) moves a card two ways: drag and drop (pointer, or the keyboard: Space, arrows, Space) and click-then-click (select a card, then a column or the tray), which is what works on a phone and with a screen reader. The editor writes the key on the same board the student answers on: a card left in the tray is a distractor.
When the key is not published, studentDetails drops each card's
expected and expectedRank, and the counts T and D: which cards are
distractors, or how many there are, is the key. The verdict (right) of a
card the student PLACED stays — it is feedback on their own answer, like
cloze's per-blank verdict; a card left in the tray carries no verdict, since
"right to leave out" would name a distractor. Dropping T and D is
defence in depth, not a secret kept: under per_item the published
fraction, t and p still let a determined student compute D. What is
protected is WHICH cards are distractors, never how many. The dashboard cell is "placed/total",
figures only. The type is not pollable, and has no drill rating nor
aggregate.
Consequences¶
- One more type in both registries, the leak test and the MCP guide; one
optional key in the evaluation settings. Existing evaluations read
per_itemand are otherwise unchanged; no stored grading moves. - The negative-marking wording changed in the evaluation's advanced options, the waiting room and the help page: "multiple-choice and categorize questions" instead of "choice questions".
- No per-teacher preference seeds
categorizePolicyat creation, unlikemcqPolicy(users.mcq_policy): every evaluation starts atper_item. A preference would be a column onusersand a row on the settings page; it is left as a possible follow-up if teachers ask for it.
Alternatives considered¶
- A
matchingtype (one card, one slot). A special case of this one (one card per column); the board generalises it without a second editor. - Reusing the
mcqpolicy names. Rejected above: same names, different formulas. - The key as a map card → column. Equivalent, but it cannot express the order inside a column without a second field; the list per column carries both.
- A partial-credit order score (longest common subsequence, inversions). Rejected in §3: kinder, but not checkable by the student.
- Letting an empty answer earn the distractors. Rejected in §2: it pays for doing nothing.
- A column
categorize_policyonevaluations. Needs a migration for a value that has a natural default; the settings JSON already carriesnegativeMarkingthe same way.