Skip to content

Latest commit

 

History

History
704 lines (614 loc) · 27.3 KB

File metadata and controls

704 lines (614 loc) · 27.3 KB

Extensions

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".

The portability contract

  • 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-level requires_extensions array, 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-UNSUPPORTED instead 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.

Shape

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.

Registering an extension

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() hook

The 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.

Errors

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).

Reference extension: ext:ref-ordering

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.

Example extension: ext:ref-categorization

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.

Example extension: ext:ref-error-correction

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.

Example extension: ext:ref-reading-comprehension

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).

Example extension: ext:ref-graded-quiz

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.

Example extension: ext:ref-dictation

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).

Example extension: ext:ref-image-description

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).

Example extension: ext:ref-audio-choice

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".

Example extension: ext:ref-audio-tiles

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.

Example extension: ext:ref-speak-and-record

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).

What extensions do NOT change

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.