ADR-103 — Blocks: a block-programming question type, a warehouse robot graded by a pure interpreter¶
Status¶
Accepted (2026-10-11). The product owner settled grading (§2), the mandatory reference (§3), keyboard and devices (§9) and the v1 scope (§10) on 2026-10-11 and approved the rest with the plan the same day.
Scope: the blocks question type (packages/qt-blocks), its interpreter
@quiz/domain/blocks, and the Blockly dependency of the web client.
Relations: specified in docs/spec/04 §4.17;
adds blocks to the "optimised for desktop" exception of
docs/spec/00 §0.4; extends the drill's
scope of ADR-041 §3;
applies ADR-021 §1
(one concept per type), the hidden-case redaction of
ADR-096 §5 and the self-hosting rule
of ADR-015 §4.
Context¶
Introductory programming courses start before any syntax: sequence, loops,
conditions, and the habit of writing a program that works on inputs it has
not seen. Block languages (Scratch, Blockly) are the usual vehicle. Teachers
of the first weeks have no Quiz question for it: code needs a language, a
compiler and a runner, and asks a beginner to type syntax before thinking in
steps.
A prototype (blocks.html, outside the repository) settled the exercise: a
warehouse robot must reach 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. Blockly draws the blocks. The prototype interpreted
Blockly's block tree directly and counted only the moves it emitted, so a
program of nested empty repeat blocks ran 40^d iterations for free.
Decision¶
The type's rules are in docs/spec/04 §4.17;
what a program means is the header of packages/domain/src/blocks.ts. This
record keeps the decisions and their reasons.
1. A new type, blocks, in its own package¶
packages/qt-blocks, registered in both registries. It is not a variant of
code: ADR-021 made codeimage a variant because it shares everything about
the program (language, template, runner, Monaco); blocks shares none of it.
Its model is circuit: a simulation graded server-side, whose stimuli play
the role of cases. The config carries world: { kind: "warehouse" }, a
discriminated union with one member, so a second world (a turtle, a sorting
line) later needs no new type id; the world fixes the block set.
2. Grading as code (product owner)¶
Points per case, summed over the cases whose run reaches the dock, or all or
nothing under allOrNothing. The battery is per case because maps differ in
size; a turn is free, a forward move costs one, a collision stops the run.
Path length is never rewarded: a correct program is not penalised for a
longer route.
3. A reference solution is mandatory at publication (product owner)¶
A draft saves without it; publication runs it on every case, visible and hidden, and refuses a missing or failing reference. This catches an unsolvable map (a dock walled in, a battery too small) before a student meets it. The reference grades nothing.
4. One pure interpreter, with a hard budget¶
The interpreter lives in @quiz/domain/blocks, with no eval, no
new Function and no Blockly import: the CSP has no unsafe-eval, and the
server grades with the SAME function the browser animates with, so what the
student sees is what is graded.
EVERY node visit and EVERY loop iteration costs one step of a budget, not
only the emitted moves; the run ends with loop beyond stepBudget. The
budget derives from the battery and is never a teacher setting. The bound
lives in the domain itself: stepBudget and simulate clamp the battery to
0..BATTERY_MAX whatever the caller passes, so no config, however it got
stored, makes a run unbounded. The config schema refuses a battery above
BATTERY_MAX too, and every schema that accepts a program — the answer, the
starter, the reference — holds it to PROGRAM_LIMITS, measured first by the
iterative programStats: the rest of the interpreter recurses, and the
server runs the reference at publication.
5. The stored answer is our syntax tree, not Blockly's JSON¶
{ program: Stmt[] } with English node kinds and no positions; the client
converts Blockly to and from it. The workspace holds one undeletable hat
block, "when the robot starts"; only the stack under it is the program, and
detached stacks are discarded on save. Reasons: F-GRADE-06 re-grades old
answers, a Blockly major may change its serialization, and the server must
not depend on where a student dropped a block.
6. Maps are ASCII rows¶
. floor, # rack, ^ > v < the start and its heading, D the dock,
bounded by MAP_SIZE. Compact, diffable, writable through MCP; the teacher's
editor shows a clickable grid over the rows.
7. What the student gets (invariant 4)¶
Visible cases whole, hidden cases as a count and a total of points; a hidden
case's result as its verdict and a coarse reason, never its trace — the
redaction ADR-096 §5 gives code, for the same reason: a trace on a hidden
map draws the map. The exact payloads and their leak tests are in the spec.
8. The player runs everything in the browser, with no route¶
"Test on every warehouse" runs the visible cases in the browser through the
domain interpreter and animates its trace. No interactiveRequest, no
/run: there is nothing secret to run and nothing to protect with a
cooldown. The grade is the server's (ADR-015 §2).
9. Keyboard and devices (product owner)¶
Keyboard navigation of the workspace is mandatory in v1; Blockly 13 has it in
core (below). blocks joins code and diagram in the "optimised for
desktop" exception of docs/spec/00 §0.4: dragging blocks on a phone is not a
target. Block labels are UI strings (invariant 1); block kinds are English.
10. The v1 scope (product owner)¶
All four options are in: a read-only "Code" tab showing the JavaScript
Blockly generates (never executed); a teacher-chosen toolbox; a starter
program, with isAnswered false while the program is still the starter; and
blocks in the drill's DRILL_TYPES (ADR-041 §3), since its grading is
synchronous, automatic and final.
Note (PR 2, 2026-10-11). isAnswered sees the answer and not the config,
so the stored answer carries the rule: the empty program [] means "the
starter as shown". The player stores a program equal to the starter as []
and shows the starter for [] (storedProgram / shownProgram,
packages/qt-blocks/src/schema.ts). A client that sends the starter itself
is counted as answered and graded on it; we accept that, since only a
client that bypasses the player can do it and it gains nothing a student
could not get by editing one block.
11. Blockly is loaded lazily, and self-hosted¶
Blockly and its generator live in one module, blocklyBundle.ts, reached
only by a dynamic import(); nothing on the client entry point or the
server imports it. Its media are copied from node_modules/blockly/media
into the web app's public directory and sounds are off: nothing is fetched
from a third-party host (ADR-015 §4).
12. A fallback surface without Blockly¶
Without Blockly (jsdom, a chunk that failed to load) the surfaces show the
program as read-only pseudo-code. Component tests drive it, as monaco?:
boolean does in qt-code; it is not a student editing path.
13. The contract hooks¶
The hooks follow code where a hook exists for cases (aggregate on the
visible cases) and stay off otherwise: not shuffleable, not pollable, no
generator (wand) in v1. answerMisfit refuses a program that uses a kind the
toolbox does not offer, so the toolbox is a rule and not only a palette. The
spec's Answer and Dashboard paragraphs list them.
The Blockly dependency¶
blockly 13.3.0 (Apache-2.0), pinned exactly, the current stable release
(2026-09-10). Its core ships keyboard navigation (checked in the 13.3.0
package: KeyboardNavigationController, the keyboard mover, the shortcuts
that focus and navigate the workspace and the flyout), so no plugin is
needed; @blockly/keyboard-navigation, last released as 3.0.5, declares a
peer of blockly@^12.3.0 only. Its media are in the package, which §11
needs. The bundle is about 1 MB, which is why §11 keeps it in its own chunk.
Consequences¶
- Teachers get a first-weeks programming question that needs neither a runner nor a compiler, graded at once, usable in the drill.
- One interpreter decides both the animation and the grade; its budget, enforced by the domain, bounds every run, so grading needs no worker and no runner capacity (ADR-101 does not apply).
- The stored answer survives a Blockly upgrade; the conversion to and from Blockly is client code the type owns and tests.
- A second world is a new member of
world, its block set and its rules; no new type id, no migration. - Cost: a ≈1 MB dependency, lazily loaded; a canvas dictionary for block labels; a media copy step in the web build.
Alternatives considered¶
- A
language: "blocks"variant ofcode. Rejected: it would share no template, no runner, no editor and no grading path withcode. - Generate JavaScript from the blocks and run it. Rejected: the CSP has no
unsafe-eval, the server would need a JavaScript sandbox, and the budget could not count loop iterations of generated code. - Store Blockly's serialization. Rejected: positions and block ids are noise for grading, and a Blockly major may change the format under answers that must be re-gradable.
- Count only the moves, as the prototype did. Rejected: nested empty loops run exponentially long for free.
- A teacher-set step budget. Rejected: a budget is a server protection, not a pedagogical setting; deriving it from the battery keeps it generous for any program that could reach the dock.
- Run the hidden cases in the browser too. Rejected: the maps would reach the student.
- The
@blockly/keyboard-navigationplugin. Not needed: Blockly 13 has keyboard navigation in core, and the plugin declares Blockly 12 only.