Extension exercise types let a consumer add a NON-core exercise type in the
ext:<vendor>-<name> namespace (since schema 1.7) without widening the core
ExerciseType enum. The core enum stays the single, portable authority; every
extension is opt-in and honestly declared, so core content keeps its guarantee
that "validates here => loads in any consumer".
- Core is guaranteed. A lesson using only core exercise types
(
matching,picture_choice,free_text,word_tiles,cloze,multiple_choice) validates and parses exactly as before: the registry is irrelevant to it. - Extensions are opt-in and declared. A lesson that uses an
ext:type MUST list it in the top-levelrequires_extensionsarray, each entry pinned to a major (ext:<vendor>-<name>@<major>). - Missing extensions are refused loudly. A consumer that has not registered
a declared extension refuses the lesson with
E-EXT-UNSUPPORTEDinstead of silently mis-rendering it. This is what keeps portability honest: content that needs an extension either runs on a consumer that has it, or is rejected clearly everywhere else.
An extension exercise carries the ext: type and an opaque ext_payload the
core engine never interprets:
{
"id": "l1",
"title": "Ordering lesson",
"requires_extensions": ["ext:acme-ordering@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:acme-ordering",
"prompt": "Put the steps in order",
"ext_payload": { "items": ["First", "Second", "Third"] }
}
}
]
}The schema validates the namespace pattern, the declaration pattern, and that
ext_payload is an object; everything inside ext_payload is validated by the
registered extension, not the core schema.
Both validateLesson and parseLesson take an optional { extensions }
registry. Without it, behaviour is identical to core-only:
import { validateLesson, parseLesson, type ExerciseExtension } from "learn-content-engine";
const acmeOrdering: ExerciseExtension = {
type: "ext:acme-ordering",
major: 1,
validate(exercise) {
const items = (exercise.ext_payload as { items?: unknown }).items;
return Array.isArray(items) && items.length >= 2
? []
: [{ path: "/ext_payload", message: "needs >= 2 items", id: "E-EXT-ACME-ITEMS", severity: "error", docAnchor: "..." }];
},
};
validateLesson(lesson, { extensions: [acmeOrdering] }); // runs the extension validator
parseLesson(raw, context, undefined, { extensions: [acmeOrdering] }); // applies any resolve() hookThe ExerciseExtension interface (exported from the package) is the engine
half; the consumer half (renderer / grader) lives in the consumer and is
registered there. Shipping the two together is what makes "validated =>
renderable" hold. adaptive-learner is one such consumer, but nothing in the
contract is app-specific.
| Rule | When |
|---|---|
E-EXT-UNDECLARED |
An exercise uses an ext: type the lesson does not list in requires_extensions. |
E-EXT-UNSUPPORTED |
A declared extension (at its pinned major) is not in the registry - a major mismatch counts. The consumer cannot render the lesson. |
An extension's own validate may return any additional issues (its own ids).
src/examples/ext-ref-ordering/ is a deliberately trivial worked example
("put these items in the correct order") that proves the seam end-to-end. It
ships both halves in one folder: the engine-half refOrderingExtension (an
ExerciseExtension validating ext_payload.items) and the consumer-half
renderRefOrdering (a minimal renderer). It is excluded from the published
build; a real extension would ship as its own package importing the engine's
ExerciseExtension type.
src/examples/ext-ref-categorization/ works out the first adoption candidate
from the external review (tracked as adaptive-learner#1579): "sort these items
into their buckets". The payload carries the buckets with their correct items;
the consumer shuffles the combined pool and grades an item-to-bucket
assignment.
Payload rules (engine half refCategorizationExtension):
| Id | Rule |
|---|---|
E-EXT-REFCATEG-SHAPE |
ext_payload.categories must be an array of {name, items[]}. |
E-EXT-REFCATEG-MIN |
At least 2 categories (one bucket is no categorization). |
E-EXT-REFCATEG-ITEMS |
Every category carries at least 1 item. |
E-EXT-REFCATEG-EMPTY |
Items are non-empty strings. |
E-EXT-REFCATEG-DUPNAME |
Category names are unique. |
E-EXT-REFCATEG-DUPITEM |
An item appears in exactly one category (a duplicate makes grading ambiguous). |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-signale-einordnen",
"title": "Hundetraining: Signale einordnen",
"requires_extensions": ["ext:ref-categorization@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-categorization",
"prompt": "Ordne jedes Signal der richtigen Kategorie zu",
"ext_payload": {
"categories": [
{
"name": "Sichtzeichen",
"items": ["flache Hand senken", "Zeigefinger hoch", "Handflaeche zeigen"]
},
{
"name": "Hoerzeichen",
"items": ["Sitz", "Platz", "Hier"]
},
{
"name": "Koerpersprache",
"items": ["sich abwenden", "in die Hocke gehen"]
}
]
}
}
}
]
}The consumer half (renderRefCategorization + gradeRefCategorization)
renders the bucket names over the item pool and grades a learner assignment
(item -> bucket name): every authored item must land in its authored bucket.
src/examples/ext-ref-error-correction/ works out the second adoption
candidate from the external review (adaptive-learner#1579): "one token in this
sentence is wrong: mark it and correct it". The payload carries the tokenized
sentence, the wrong token's index, and the accepted corrections as an
accept ARRAY, mirroring the core free_text contract, because real
sentences often allow more than one defensible fix for the same wrong token
and a single authored string would reproduce the too-narrow-accept-list class
of false negatives (adaptive-learner#1580). accept[0] is the canonical
correction a consumer surfaces after a wrong attempt.
Payload rules (engine half refErrorCorrectionExtension):
| Id | Rule |
|---|---|
E-EXT-REFERRCORR-SHAPE |
ext_payload must carry tokens (string array), error_index (number), accept (string array). |
E-EXT-REFERRCORR-TOKENS |
At least 2 tokens. |
E-EXT-REFERRCORR-EMPTY |
Tokens are non-empty strings. |
E-EXT-REFERRCORR-INDEX |
error_index is an integer inside the token range. |
E-EXT-REFERRCORR-CORRECTION |
accept carries at least 1 non-empty correction; accept[0] is canonical. |
E-EXT-REFERRCORR-NOOP |
No accept entry equals the marked token (otherwise there is no error). |
A reference lesson on the same topic, validated by the doc gate:
{
"id": "hunde-grammatik-fehlerkorrektur",
"title": "Hundetraining: Finde den Fehler im Satz",
"requires_extensions": ["ext:ref-error-correction@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-error-correction",
"prompt": "Ein Wort ist falsch - tippe es an und korrigiere es",
"ext_payload": {
"tokens": ["Der", "Hund", "folgt", "das", "Kommando"],
"error_index": 3,
"accept": ["dem", "einem"]
}
}
},
{
"id": "s2",
"type": "exercise",
"exercise": {
"id": "e2",
"type": "ext:ref-error-correction",
"prompt": "Ein Wort ist falsch - tippe es an und korrigiere es",
"ext_payload": {
"tokens": ["Die", "Leine", "haengt", "locker", "durch", "wenn", "der", "Hund", "brav", "lauft"],
"error_index": 9,
"accept": ["laeuft"]
}
}
}
]
}The consumer half (renderRefErrorCorrection + gradeRefErrorCorrection +
canonicalCorrection) renders a numbered token row and grades the tapped
index plus the typed correction against EVERY accept entry (trim +
case-fold; a production consumer would reuse its free-text matcher for typo
tolerance) and surfaces accept[0] after a wrong attempt.
src/examples/ext-ref-reading-comprehension/ works out the shared-passage
case (engine#43): a passage (stimulus) bound to N sub-questions. This is the
one shape the flat core schema cannot express (LessonStep.exercise is
singular, there is no passage-with-questions grouping), so instead of a
core-schema change it is modelled as a SINGLE ext exercise whose ext_payload
carries the passage plus the questions. Sub-questions reuse the core question
shapes (multiple_choice / free_text); a consumer renders each with its
existing renderer. The payload is deliberately a first cut: the @major pin
lets it evolve (more sub-question types, scoring variants) without migrating
core content: exactly why the shared-passage case is an extension, not a core
type.
Payload rules (engine half refReadingComprehensionExtension):
| Id | Rule |
|---|---|
E-EXT-REFRC-SHAPE |
ext_payload must carry passage (string) and questions ([{prompt, type, options?/accept?}]). |
E-EXT-REFRC-PASSAGE |
passage is non-empty. |
E-EXT-REFRC-QUESTIONS |
At least 1 question. |
E-EXT-REFRC-PROMPT |
Every question has a non-empty prompt. |
E-EXT-REFRC-QTYPE |
Every question type is multiple_choice or free_text. |
E-EXT-REFRC-MC |
A multiple_choice question has at least 2 options and at least 1 correct. |
E-EXT-REFRC-FT |
A free_text question has a non-empty accept list. |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-textverstaendnis",
"title": "Hundetraining: Text verstehen",
"requires_extensions": ["ext:ref-reading-comprehension@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-reading-comprehension",
"prompt": "Lies den Text und beantworte die Fragen.",
"ext_payload": {
"passage": "Rex lief in den Garten und bellte den Briefträger an. Danach kam er brav zurück, als sein Halter 'Hier' rief.",
"questions": [
{
"prompt": "Wohin lief Rex?",
"type": "multiple_choice",
"options": [
{ "text": "In den Garten", "correct": true },
{ "text": "Auf die Straße" },
{ "text": "Ins Haus" }
]
},
{
"prompt": "Auf welches Hoerzeichen kam Rex zurueck?",
"type": "free_text",
"accept": ["Hier", "hier"]
}
]
}
}
}
]
}The consumer half (renderRefReadingComprehension +
gradeRefReadingComprehension) renders the passage over the numbered
questions and grades per question (multiple_choice by exact option, free_text
tolerantly).
src/examples/ext-ref-graded-quiz/ works out the school-test case (engine#46):
a self-contained scored question set. Each question carries points,
multi-select questions may award partial_credit (proportional), and an
optional pass_threshold (percent) decides pass/fail. Questions reuse the core
multiple_choice / free_text shapes.
This demonstrates that the points / partial-credit / pass-threshold concern
fits the extension tier WITHOUT a core-schema change: "points on an exercise"
is a cross-cutting concern, not an interaction type, so it is a bounded
graded-quiz payload rather than a core points field on every exercise (which
would be a full core ripple). The payload carries only the grading METADATA;
the grading POLICY (how partial credit is computed, how pass/fail is decided)
lives in the consumer half.
Payload rules (engine half refGradedQuizExtension):
| Id | Rule |
|---|---|
E-EXT-REFGQ-SHAPE |
ext_payload must carry questions ([{prompt, type, points, options?/accept?, partial_credit?}]) and an optional numeric pass_threshold. |
E-EXT-REFGQ-QUESTIONS |
At least 1 question. |
E-EXT-REFGQ-PROMPT |
Every question has a non-empty prompt. |
E-EXT-REFGQ-QTYPE |
Every question type is multiple_choice or free_text. |
E-EXT-REFGQ-MC |
A multiple_choice question has at least 2 options and at least 1 correct. |
E-EXT-REFGQ-FT |
A free_text question has a non-empty accept list. |
E-EXT-REFGQ-POINTS |
Every question has positive points. |
E-EXT-REFGQ-THRESHOLD |
pass_threshold, when present, is a percentage in 0..100. |
A reference lesson (a short graded quiz), validated by the doc gate:
{
"id": "hunde-quiz-benotet",
"title": "Hundetraining: benoteter Test",
"requires_extensions": ["ext:ref-graded-quiz@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-graded-quiz",
"prompt": "Beantworte alle Fragen. Bestanden ab 60%.",
"ext_payload": {
"pass_threshold": 60,
"questions": [
{
"prompt": "Welches Hoerzeichen ruft den Hund zurueck?",
"type": "multiple_choice",
"options": [
{ "text": "Hier", "correct": true },
{ "text": "Sitz" },
{ "text": "Platz" }
],
"points": 2
},
{
"prompt": "Wie heisst das Sichtzeichen fuer 'Platz'?",
"type": "free_text",
"accept": ["flache Hand senken", "flache Hand"],
"points": 3
},
{
"prompt": "Welche gehoeren zu den Grundkommandos?",
"type": "multiple_choice",
"options": [
{ "text": "Sitz", "correct": true },
{ "text": "Platz", "correct": true },
{ "text": "Rolle" }
],
"points": 4,
"partial_credit": true
}
]
}
}
}
]
}The consumer half (renderRefGradedQuiz + gradeRefGradedQuiz) renders the
questions with their point values and the pass threshold, and grades per
question: exact-set multiple_choice, proportional partial_credit
(max(0, correct - wrong) / total_correct), tolerant free_text, then decides
pass/fail against the threshold.
src/examples/ext-ref-dictation/ works out the audio-dictation case
(engine#68): a clip is played, the learner types what they hear. The flat core
schema has no audio-stimulus exercise type and free_text carries no media, so
instead of a core-schema change it is modelled as a SINGLE ext exercise whose
ext_payload carries the audio reference plus the accepted transcriptions.
The payload is deliberately SELF-CONTAINED: no card reference, no lookup into
another part of the lesson: everything the consumer needs sits in
ext_payload. The engine validates only the SHAPE of audio (a non-empty
string) and knows nothing about how the clip is stored, uploaded, resolved or
played; an audio player is a consumer capability, which is exactly why this is
an extension and not a core audio field rippling through every exercise type.
Payload rules (engine half refDictationExtension):
| Id | Rule |
|---|---|
E-EXT-REFDICT-SHAPE |
ext_payload must carry audio (string) and accept (string[]). |
E-EXT-REFDICT-AUDIO |
audio is non-empty. |
E-EXT-REFDICT-ACCEPT |
accept has at least 1 non-empty entry. |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-diktat",
"title": "Hundetraining: Diktat",
"requires_extensions": ["ext:ref-dictation@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-dictation",
"prompt": "Hoere zu und schreibe den Satz auf.",
"ext_payload": {
"audio": "assets/audio/hunde-diktat-s1.mp3",
"accept": [
"Der Hund kommt auf das Hoerzeichen 'Hier'.",
"Der Hund kommt auf das Hoerzeichen Hier."
]
}
}
}
]
}The consumer half (renderRefDictation + gradeRefDictation) renders the
prompt over the audio reference (a real consumer mounts its player there) and
grades the typed transcription against EVERY accept entry (trim + case-fold;
a production consumer would reuse its free-text matcher for typo tolerance).
src/examples/ext-ref-image-description/ works out the image-stimulus
free-text case: the learner looks at a picture and types a description (or the
answer to a question about it). It is the visual twin of ext:ref-dictation.
The flat core schema has no image-stimulus free-text type: images is the
picture_choice OPTION list with an exactly-one-correct contract, and
free_text carries no media. So instead of a core-schema change it is
modelled as a SINGLE ext exercise whose ext_payload carries the image
reference plus the accepted answers.
The payload is deliberately SELF-CONTAINED: no card reference, everything the
consumer needs sits in ext_payload. The engine validates only the SHAPE of
src (a non-empty string); whether that string is a relative path into the
set's assets/ directory or an inline data URI, and how the image is stored,
resolved or displayed, stays consumer-side. There is deliberately NO alt-text
field: a description of the image would leak the expected answer, so the
accessibility affordance is a consumer decision, not payload data.
Payload rules (engine half refImageDescriptionExtension):
| Id | Rule |
|---|---|
E-EXT-REFIMGDESC-SHAPE |
ext_payload must carry src (string) and accept (string[]). |
E-EXT-REFIMGDESC-SRC |
src is non-empty. |
E-EXT-REFIMGDESC-ACCEPT |
accept has at least 1 non-empty entry. |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-bildbeschreibung",
"title": "Hundetraining: Bildbeschreibung",
"requires_extensions": ["ext:ref-image-description@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-image-description",
"prompt": "Sieh dir das Bild an: Welches Koerpersignal zeigt der Hund?",
"ext_payload": {
"src": "assets/images/hund-beschwichtigung.jpg",
"accept": [
"Der Hund gaehnt zur Beschwichtigung.",
"Er gaehnt zur Beschwichtigung."
]
}
}
}
]
}The consumer half (renderRefImageDescription + gradeRefImageDescription)
renders the prompt over the image reference (a real consumer mounts its image
view there) and grades the typed answer against EVERY accept entry (trim +
case-fold; a production consumer would reuse its free-text matcher for typo
tolerance).
src/examples/ext-ref-audio-choice/ works out the audio multiple-choice case:
a gapped sentence with N audio options, one of which fills the gap ("listen to
the words, pick the one that fits"). The flat core schema has no audio-option
choice type: images is picture_choice's visual twin (an exactly-one-correct
OPTION list), multiple_choice's options carry text only, and free_text
carries no media. So instead of a core-schema change it is modelled as a
SINGLE ext exercise whose ext_payload carries the gapped sentence plus the
audio options.
The payload is deliberately SELF-CONTAINED: no card reference, everything the
consumer needs sits in ext_payload. The engine validates only the SHAPE of
sentence and each option's audio, plus an exactly-one-correct contract
mirrored from core picture_choice's own E-PIC-ONE-CORRECT rule. There is
deliberately NO label/text field on an option: a visible word next to its
audio would spoil a listening exercise the same way alt-text would spoil an
image one.
Payload rules (engine half refAudioChoiceExtension):
| Id | Rule |
|---|---|
E-EXT-REFAUDIOCHOICE-SHAPE |
ext_payload must carry sentence (string) and options (at least 2 entries, each {audio: string, is_correct?: "true"}). |
E-EXT-REFAUDIOCHOICE-SENTENCE |
sentence is non-empty and contains the gap marker ___. |
E-EXT-REFAUDIOCHOICE-AUDIO |
Every option's audio is non-empty. |
E-EXT-REFAUDIOCHOICE-CORRECT |
Exactly one option is marked is_correct: "true". |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-audio-wahl",
"title": "Hundetraining: Hoerauswahl",
"requires_extensions": ["ext:ref-audio-choice@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-audio-choice",
"prompt": "Hoere die Woerter und waehle das passende Kommando.",
"ext_payload": {
"sentence": "Der Hund soll ___.",
"options": [
{ "audio": "assets/audio/kommando-sitz.mp3", "is_correct": "true" },
{ "audio": "assets/audio/kommando-platz.mp3" }
]
}
}
}
]
}The consumer half (renderRefAudioChoice + gradeRefAudioChoice) renders the
sentence over every option's audio reference (a real consumer mounts its audio
buttons there) and grades the chosen option's audio reference against the one
marked is_correct: "true".
src/examples/ext-ref-audio-tiles/ works out the audio-to-translation case: a
spoken source-language sentence, built up as a target-language translation
from word tiles ("listen to the sentence, arrange the translation from
tiles"). Core word_tiles already covers the puzzle mechanic (tiles +
accept_orderings), but carries no audio and no source-language sentence.
Rather than pairing a core word_tiles exercise with a bare
ext_payload.audio - an untested "core fields + ext_payload coexist" pattern
nobody has exercised yet - this bundles audio + tiles into ONE self-contained
ext_payload, matching ext:ref-dictation's and ext:ref-image-description's
established shape.
The payload is deliberately SELF-CONTAINED: no card reference, everything the
consumer needs sits in ext_payload. The engine validates only the SHAPE of
audio and tiles, plus accept_orderings when present - the same
permutation rule core word_tiles enforces on its own accept_orderings
field. There is no direction field: the payload is already
direction-specific by construction (audio = source language, tiles =
target language).
Payload rules (engine half refAudioTilesExtension):
| Id | Rule |
|---|---|
E-EXT-REFAUDIOTILES-SHAPE |
ext_payload must carry audio (string) and tiles (string[]). |
E-EXT-REFAUDIOTILES-AUDIO |
audio is non-empty. |
E-EXT-REFAUDIOTILES-TILES |
tiles has at least 2 entries. |
E-EXT-REFAUDIOTILES-ORDERINGS |
Each accept_orderings entry (when present) is a permutation of [0..tiles.length - 1]. |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-audio-kacheln",
"title": "Hundetraining: Hoerkacheln",
"requires_extensions": ["ext:ref-audio-tiles@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-audio-tiles",
"prompt": "Hoere den Satz und baue die Uebersetzung aus den Kacheln.",
"ext_payload": {
"audio": "assets/audio/sit-command-en.mp3",
"tiles": ["Sitz", "bitte", "sofort"]
}
}
}
]
}The consumer half (renderRefAudioTiles + gradeRefAudioTiles) renders the
prompt over the audio reference and tile list (a real consumer mounts its
audio player and draggable tiles there) and grades the learner's tile ordering
against the canonical order, or against any accept_orderings entry when
present - mirroring core word_tiles' own "if omitted, only the canonical order
is accepted" contract.
src/examples/ext-ref-speak-and-record/ works out the speak-and-record case: a
speaker button reads a sentence, a "show" button reveals its text, a "record"
button lets the learner record themselves saying it. Unlike every other
reference extension this one is deliberately UNGRADED: there is nothing to
check a recording against, so the payload carries no accept list and the
module exposes no grade function - a consumer treats it as a self-review
activity, not a scored exercise.
The payload is deliberately SELF-CONTAINED: no card reference, everything the
consumer needs sits in ext_payload. audio is OPTIONAL: when an author has
not recorded a reference clip, the consumer falls back to on-device speech
synthesis of sentence. The engine validates only the SHAPE of sentence
(required, non-empty) and audio (optional, but a string when present); it
knows nothing about capturing, storing or playing back the learner's OWN
recording, which is entirely consumer-side.
Payload rules (engine half refSpeakAndRecordExtension):
| Id | Rule |
|---|---|
E-EXT-REFSPEAKRECORD-SHAPE |
ext_payload must carry sentence (string) and an optional audio (string). |
E-EXT-REFSPEAKRECORD-SENTENCE |
sentence is non-empty. |
A reference lesson on an existing topic (dog training), validated by the doc gate:
{
"id": "hunde-nachsprechen",
"title": "Hundetraining: Nachsprechen",
"requires_extensions": ["ext:ref-speak-and-record@1"],
"steps": [
{
"id": "s1",
"type": "exercise",
"exercise": {
"id": "e1",
"type": "ext:ref-speak-and-record",
"prompt": "Hoere den Satz, zeige ihn dir an und nimm dich selbst auf.",
"ext_payload": {
"sentence": "Sitz, bitte, sofort.",
"audio": "assets/audio/sitz-bitte-sofort.mp3"
}
}
}
]
}The consumer half (renderRefSpeakAndRecord) renders the prompt over the
sentence and, when authored, the audio reference (a real consumer mounts its
speaker/show/record buttons there). There is no grade function: capturing,
storing and reviewing the learner's own recording is entirely consumer-side,
and this extension does not claim to know when a recording is "correct".
The example extensions exist as a DECISION BASIS for adoption: nothing in the
app or the content repos references them until that decision is made
(adaptive-learner#1579 tracked the exercise-type adoptions; engine#46 tracks
the school-test direction). A production adoption would pick its own vendor
namespace (the ref vendor marks engine-repo demonstrations).
The core schema, the core ExerciseType enum, and the schema-authority process
for core types are unchanged. Extensions are a separate, versioned, opt-in tier
that sits AROUND the portable core: they never bypass the core enum, the strict
additionalProperties: false on core fields, or the RED-first process for a new
CORE type. Core content authored before 1.7 validates and parses byte-identically.