ADR-021 — Code image: a variant of code, a target in the config, stdout graded whatever the exit¶
Status¶
Accepted (2026-09-23, with packages/qt-code/src/image/).
Context¶
docs/spec/04-types-de-questions.md §4.9 described codeimage as a phase-3
extension of code: a program writing a binary PPM P6 image of 300 × 300
pixels, compared with the teacher's image under a per-channel tolerance and
configurable point thresholds, with an overlay slider in the player. The type
is now wanted for first-year C: loops and conditions that draw a checkerboard,
a frame, concentric squares — exercises where the student SEES what the
program does and how far it is from the goal.
The product owner reshaped it on the way in: the program prints plain
integers, one per cell of a small grid (3 to 128 cells per side), in one of
three palettes (bw, a pastel color16, gray256); the teacher captures the
target from a run of their reference solution; the score is the share of
matching cells. Three decisions follow, and each could have gone another way.
Decision¶
1. A variant inside packages/qt-code, not a package of its own¶
Everything about the PROGRAM is code's: the languages, the template and its
@@lock regions, the reference solution split by @@next, runtime
(ADR-015), the limits, the extra files, the source rebuilt server-side from
the stored template (invariant 14), Monaco and the stacked editor. Only the
judge differs — a picture instead of a table of cases.
So codeimage lives in packages/qt-code/src/image/ and the package exports
a second type from each entry point (codeimageServer, codeimageClient),
registered beside code in packages/registry. What both share was
EXTRACTED, not copied:
- the schema:
programFields(spread intoCodeConfigandCodeImageConfig) andprogramStudentFields(spread into both student views).CodeConfig's parsed shape is unchanged, key for key, so stored configs parse as before; - the player:
ProgramPlayer.tsx— the statement, the stack of regions, the Run button, the run's status lines — whichCodePlayerrenders with its case table andCodeImagePlayerwith its image panel; - the editor:
ProgramEditor.tsx— the statement with language and runtime, the starting code, the reference solution, the program's advanced settings — around whichCodeEditoradds its cases andCodeImageEditorthe image; - the review:
ProgramReview.tsx— the score line, the compiler's refusal, the reference solution; - the host:
apps/web/src/runner/codeRun.tsbuilds a browser request from the program half of either student view.
The student's Run on the server goes through the type's interactiveRequest
and the generic POST /attempts/:id/simulate of ADR-019. No route was added.
2. The target is stored in the config, as a compact string¶
The target is part of the question, like a case's expected: captured once
by the teacher ("Try the reference solution", then "Use as target"), stored in
config.target, versioned with the question, and published to the student on
purpose — drawing it is the exercise. Nothing runs at publication, so
publishing never depends on a runner being up (decision D14).
It is { width, height, palette, pixels }: the size and palette it was
captured under, and the pixels as a string, one lowercase hex digit per
pixel for bw and color16, two for gray256. The dimensions travel with
the pixels because the count alone cannot tell a 4 × 3 target from a 3 × 4
one; a target whose dimensions differ from the image's is stale. As a
string, a 128 × 128 grey target is 32 KiB of text instead of a 16 384-entry
JSON array several times that size, and it diffs as one line in a canonical
file. The same encoding carries the student's computed image in
the grading details, with x for an invalid or missing pixel. Publication
refuses a target that is missing, captured for another size or palette, or
holds a value outside the palette; a draft may lack it (decision D16).
Those checks are NOT in configSchema. The schema is the gate of use —
POST /questions/:id/try and /preview parse with it — and the try is the
very step that captures a target, first or after a resize. So the contract
gained an optional publicationIssues(config) hook: the API applies it on
publication (publishConfig, same config_invalid refusal) and reports it
with the draft's issues, and no read ever does. A target that does not fit
the current image — other dimensions or palette, or a wrong length
(targetPixels returns null) reads as "no target"
everywhere — grading scores 0, the player and the review show no target —
and is never indexed as if it matched.
3. Stdout is graded whatever the run's exit¶
The score is points × matching cells / total cells, and it is computed from
whatever stdout holds: a non-zero exit, a timeout, a crash or an
out-of-memory kill after half the picture still earns the matching half. A
picture is partial by nature — half a checkerboard is half right — and the
program that draws the top rows correctly before looping forever has shown
exactly that much. Only a compile failure (no program at all) scores zero by
itself; no answer and an unavailable runner are handled as for code.
The parse is ONE pure function, parseImageOutput, shared by the grader and
the player: invalid tokens and missing pixels are wrong, extra tokens are
ignored and reported. The student sees the image they will be graded on.
Consequences¶
- One package, one lockfile, one set of Monaco and runner wiring for two
types. A change to the program half (a new language, a new limit) reaches
both at once; the tests of
codestayed green through the extraction. - The output budget of a
codeimagedefaults to 128 KiB (code: 64 KiB): a 128 × 128 grey image written as"255 "is 64 KiB before its newlines. - The grading details carry the student's picture (up to 32 KiB), which the
review draws; they carry no key, so
studentDetailsredacts nothing. The numerator is namedmatching, notcorrect:correctis on the blind strip of a student's details (FORBIDDEN_DETAIL_KEYS). - The image grid is a
<canvas>, sixteen thousand cells without a DOM element each; its colours are the app's tokens read at draw time for the difference view and the lines, and fixed image colours for the pixels. - Spec §4.9 is rewritten to this design; the type is no longer phase 3.
Rejected alternatives¶
- A
packages/qt-codeimage. A package that would importqt-codefor its program half — or copy it. The first makes one type package depend on another, which the registry exists to avoid; the second is two stacks of Monaco regions, two reference cuts and two request builders that drift. - The PPM
P6protocol of the old §4.9. Binary output is awkward to print from a first-year C program and impossible to read in a terminal; a list of integers is oneprintfin every language and legible as text. - Computing the target at publication, by running the reference. It makes publishing depend on the runner, hides the picture from the teacher until after the fact, and turns a runner hiccup into an unpublishable question. Capturing it in the editor shows the teacher what the student will be asked to draw before they commit to it.
- Tolerance and point thresholds. With a palette of at most 256 exact values, a cell is right or wrong; a share of matching cells is the score a student can predict from the difference view.
- Zero for a run that did not exit cleanly. It punishes the student who drew nearly everything and then crashed exactly like the one who drew nothing, and the difference view would show a picture the grade ignores.