Repository navigation
Releases: astrapi69/learn-content-engine
Release list
v0.36.0
Schema 1.19: one additive exercise field, case_sensitive. E-FREETEXT-DISJOINT now
compares without case unless an exercise declares it, so a lesson that passed
0.35.0 can fail; measured over the eleven content repositories, two
exercises do, and they declare the field with the re-pin.
Case is not an error unless the exercise declares it (engine#242)
Schema 1.19 adds case_sensitive to free_text exercises (boolean,
default false, optional). An exercise that teaches case, such as
capitalisation, declares true, and a consumer then grades it in case;
without it, case is not an error. The rules follow the declaration:
E-FREETEXT-DISJOINT compares accept and distractors after trimming,
in case only where case_sensitive is true; params.shared names each
answer once, as accept writes it. The QTI adapter writes the flag as
caseSensitive on every mapEntry (also for a single answer, so it
survives) and reads it back when every mapEntry carries
caseSensitive="true"; an imported accept lists each answer once.
Why: an author could not say that case matters. The reference app grades
free text without case, so the two exercises in adaptive-learner-content
that teach capitalisation graded the lower-case answer correct, while
0.35.0's rule compared in case to keep them valid. Principle (maintainer,
2026-10-01): case is not an error unless the exercise says so, which is
how Moodle's short answer and QTI's caseSensitive treat it.
A changed comparison can turn valid content red. Measured with this build
over the 632 lessons of the eleven content repositories: exactly those two
exercises (ex-free-i-capital, ex-free-nationality); they declare
case_sensitive: true when their repository pins this release.
v0.35.0
No schema change: x-schema-version stays 1.18. One new error,
E-FREETEXT-DISJOINT, so a lesson that passed 0.34.0 can fail; measured
over the eleven content repositories, none does.
A free_text answer must not also be a distractor (engine#237)
New error E-FREETEXT-DISJOINT: an entry in a free_text exercise's
accept that is also in its distractors, compared after trimming and
case-sensitively. params.shared lists the shared answers, each once.
validateLesson and validateLessonRules (the /rules entry) both report
it. It is the free_text counterpart of E-CLOZE-MS-DISJOINT.
Why: the schema calls distractors the renderer's fallback pool of wrong
options, so a shared entry offers a correct answer as a wrong one. The rule
lived only in the content template's advisory audit, outside CI, the last
content rule there without an engine counterpart. Case-sensitive because two
exercises in adaptive-learner-content teach capitalisation with a distractor
that differs only in case (I am Anna against i am Anna).
A new error can turn valid content red. Measured with this build over the
632 lessons on origin/main of the eleven content repositories: 0 hits. An
overlap seeded into a real lesson is found.
v0.34.0
No schema change: x-schema-version stays 1.18. New API: the engine evaluates
parametric exercises (resolveExerciseVariables, evaluateExpression). No
new rule, so nothing a consumer validates changes.
The engine evaluates parametric exercises (engine#220)
resolveExerciseVariables(exercise, { random, values }) turns a parametric
exercise (schema 1.14) into a concrete instance: it samples every sampled
variable with the given random source, evaluates every computed one in
declaration order, rounds each to its display precision and substitutes every
reference to a declared variable in the exercise's string fields. It returns
the exercise, the values (to persist and replay) and the tolerance of every
accepted answer that is one reference to a variable with tolerance.
evaluateExpression(expression, values) is exported on its own. See
variables.
Why: the engine validated the contract and left evaluation to the consumer,
so the reference app carried a second parser for the same expression
language (adaptive-learner#3109). The engine now evaluates on the parser that
validates: one grammar, one implementation. The contract is the app's, so it
can switch without changing its call sites; its own test cases are ported
unchanged and pass. Two differences follow from the single grammar: an
expression the validator rejects (.5, a unary +) does not evaluate either,
and a reference with spaces ({{ a }}), which the validator reads as a, is
substituted (the app left it literal). The grade lookup for evaluation
follows with its first consumer.
v0.33.0
Schema 1.18, a description-only change (what bridge lifts; two line breaks
restored). New checks: language tags as E-LANG-TAG (0 hits across the ten
content repositories) and four warnings for language pairs, set titles and
card-back scripts; isSlugId counts characters like the schema.
isSlugId counts characters, as the schema does (engine#205)
isSlugId (the /rules entry) checked the 120 limit with String.length,
which counts UTF-16 code units, while the schema's SlugId counts characters
(code points, as JSON Schema's maxLength does). An id of 61 to 120 letters
outside the Basic Multilingual Plane passed the schema and failed isSlugId.
Decided in engine#205: 120 means characters; isSlugId now counts code
points. No content is affected (the longest lesson id is 69 bytes, Latin).
The byte limit of a file name (ids become lessons/<id>.json) is documented
as a known limit under slug ids.
Language tags and set metadata are engine rules (engine#190)
The language-pair and set-metadata checks move from the content template's
validator into the engine, in the version decided on 2026-09-25:
E-LANG-TAG: atarget_languageorsource_language, on a set or a
lesson, is not a well-formed BCP 47 tag (en_US). Checked with
Intl.getCanonicalLocales, no table and no new dependency.W-LANG-TAG-CANONICAL: well-formed but not canonical (deu,EN,iw);
the message names the canonical form. Three-letter primary subtags without a
two-letter code (gsw,yue,fil) are valid, which the template's
two-letter rule rejected.W-LANG-PAIR-SAME: alanguageset whose source and target are one
language (the template: an error).W-SET-TITLE-NATIVE: alanguageset withouttitle_native(the
template: an error).W-CARD-BACK-SCRIPT: card backs with letters but none in the script of a
non-Latin source language, one warning per lesson. The script comes from
CLDR likely subtags, so every language is covered, not the template's six.
validateLessonandvalidateLessonRulestake the set's source language as
options.sourceLanguage; a lesson's ownsource_languagewins.
See language tags. Measured with this
build over the 53 sets and 631 lessons of the ten content repositories: 0
findings for every rule; seeded faults in a real lesson and a real manifest
are found.
A bridge lesson has no exercise-type minimum either (engine#185, schema 1.18)
purpose: "bridge" lifted only the exercise minimum, so a bridge lesson of
theory alone, or with exercises of one type, still fell short on
E-QUALITY-TYPES. Decided (owner, 2026-09-25): a lesson without an
assessment intent needs neither count nor variety, so bridge now lifts both.
The theory minimum and the per-exercise minimums still apply. This only
relaxes: nothing that passed before falls short now.
Schema 1.18 changes no field: the description of purpose says what bridge
lifts, and two descriptions get back the line breaks the em dash rewrite of
1.16 had swallowed (the root Lesson and InlineExample, engine#182). The
manifest schema, quality-rules.json and grading-presets.json move their
version in lockstep.
v0.32.0
No schema change: x-schema-version stays 1.17. Three new errors
(E-CARD-ID-DUP, E-STEP-ID-DUP, E-EXERCISE-ID-DUP) can turn content red
that carries a duplicate id; measured over the ten content repositories there
is none. The structural layer no longer reads files, so no schema file lands
in a consumer's build and validateLesson runs in a browser.
Card, step and exercise ids are unique within a lesson (engine#202)
Three new errors check what the schema's descriptions have always said:
E-CARD-ID-DUP, E-STEP-ID-DUP and E-EXERCISE-ID-DUP, one error per
duplicated id, with the id and its 1-based positions in params. Card ids,
step ids and exercise ids are three separate namespaces: a step and its own
exercise may share an id, as most content does. validateLesson and
validateLessonRules (the /rules entry) both report them.
Why: no engine rule checked it, while the reference app rejects such a lesson
at error level. A lesson with two cards of one id passed every content
repository's gate, and a from_cards matching then silently lost the earlier
card (cards are looked up by id, the later one wins). The template's advisory
audit checked it outside CI only.
A new error can turn valid content red. Measured with this build over the 631
lessons on origin/main of the ten content repositories: 0 hits; a seeded
duplicate in a real lesson is found in all three namespaces.
No schema file in a consumer's build, and validateLesson runs in a browser (engine#203)
The structural layer read schema/*.json at run time through
new URL(../schema/${fileName}, import.meta.url) and node:fs. Vite turns
such a URL into a lookup over every file in schema/ and copied all of them
into every build that imported the package root, parse-only builds included,
and a browser could not run validateLesson at all (no file system). The two
schemas are now a generated module, src/schemas.generated.ts (make sync-types writes it, sync-types-check guards it), without their annotation
keywords (description, title, $comment), which never change what a schema
accepts; a test compiles both forms and compares their verdicts on every
conformance fixture and on negative probes. The JSON files stay the authored
source and still ship.
Measured with Vite 8.3.0 (the reference app's version), one import per build:
| Import | JS before | JS after | Schema files before | after |
|---|---|---|---|---|
parseLesson from the root |
30,482 B | 30,482 B | 146,976 B (3 files) | none |
| the root, nothing used | 670 B | 670 B | 146,976 B (3 files) | none |
validateLesson from the root |
151,294 B | 158,704 B | 146,976 B (3 files) | none |
validateLessonRules from /rules |
19,334 B | 19,334 B | none | none |
The node:fs / node:url build warnings are gone (2 before, 0 after), and the
built validateLesson now validates in a browser-like run, where it threw a
TypeError before.
v0.31.0
Schema 1.17: one additive lesson field, purpose. Every manifest and lesson
valid under 0.30.0 is valid under 0.31.0; validateLesson reports nothing new.
The new check is a separate call a consumer opts into.
Quality minimums in the engine, keyed to what a lesson is for (engine#185)
validateLessonQuality(lesson) checks a shape-valid lesson against the quality
minimums of schema/quality-rules.json (exported as QUALITY_MINIMUMS): at
least five exercises of at least two types, a theory step, two accepted answers
per free_text, three pairs per matching. Shortfalls come back as errors
with five new ids, E-QUALITY-EXERCISES, E-QUALITY-TYPES, E-QUALITY-THEORY,
E-QUALITY-FREETEXT-ACCEPTS and E-QUALITY-MATCHING-PAIRS, each with count
and min in its params. It is also on the learn-content-engine/rules entry.
See quality minimums.
The minimums are a publication threshold, not validity: validateLesson
never reports them, so a consumer that generates short lessons (the reference
app's adaptive lessons) still accepts its own output, and each consumer gives
a shortfall the weight of its gate.
Schema 1.17 adds the optional lesson field purpose: practice (the default,
every minimum), bridge (no exercise minimum) and quiz (no exercise-type
minimum). Content without it validates unchanged; the manifest schema moves
its x-schema-version in lockstep and changes nothing else.
Why: the minimums existed in three versions (the content template's gate,
alc-books' copy, the app's share check) with different exemptions and a
different count for matching with from_cards, so a set could pass its
repository gate and fail to be shared. The one version here replaces the
template's multiple-choice exemption and alc-books' bridge lessons recognised
by their id with the declared purpose, and counts the pairs from_cards
derives. The distractor requirement the three versions put on free_text and
picture_choice has no counterpart. Measured over the 631 lessons of the ten
content repositories on 2026-09-25: 8 fall short today (seven bridge lessons
in alc-books, one quiz in alc-traffic-knowledge), none once those eight
declare their purpose.
v0.30.0
No schema change: x-schema-version stays 1.16, and every manifest and lesson
valid under 0.29.0 is valid under 0.30.0. What moves is the shape of a
validation issue (an optional params) and three issue paths.
Issue parameters: the values a message names, as data (engine#201)
A validation issue gains an optional params: the values its message
interpolates, unformatted (E-MATCH-DUP-LEFT carries { term, positions },
E-CARD-REF carries { cardId }). 27 of the 59 rule ids put a runtime value
into their message; all but E-SCHEMA now also pass it as params (its message
and parameters are ajv's, deliberately not engine API). W-PROMPT-DUP, whose
two messages are constant, carries its variant (field), so 27 ids carry
params in all; E-VAR-KIND carries its variant (reason) next to the name. A
new test fails when a message can carry a value without params, and when the
params table in the rule catalog disagrees with the code
(src/issue-params.test.ts). The
keys per id are in the rule catalog, issue parameters.
Messages are unchanged.
Why: a consumer that words problems itself, like the reference app with its
own message catalog in eleven languages, could only get the repeated term or
the missing card id out of the English message text (adaptive-learner#3222,
#3247).
No two issues the engine's own rules report are indistinguishable any more
(E-SCHEMA and extension issues aside), which changes three paths and one
count:
E-CARD-REFpoints at the entry,/card_ids/{i}(was/card_idsfor every
unknown id of an exercise).E-TILES-ORDERINGpoints at the entry,/accept_orderings/{i}(was the
exercise).W-VAR-UNUSEDpoints at the variable,/variables/{i}(was/variables).- A reference repeated within one field (
{{b}} {{b}},{{a + 1}} {{a + 1}})
is reported once, not once per occurrence (E-VAR-UNDEFINED,E-VAR-REF).
A problem that is a relation between several elements keeps its path and is
told apart by its params (two E-MATCH-DUP-LEFT groups in one exercise, two
duplicated stable_ids). Measured before the change: no consumer depends on
the changed part of these paths. The content template maps warnings to an
exercise by the prefix /steps/N/exercise, which the new paths keep, and the
app reads no issue paths yet. The ten content repositories carry none of these
issues, so they need no re-pin for this release.
Correction to 0.28.0: something did compare a pin with the current release
The 0.28.0 entry "Docs: the pin and currency discipline" says that comparing a
pin against the current release is something "nothing performs today". That
did not hold when it was written. The reference app's Dependabot had opened
grouped /frontend update PRs that carried engine bumps since 2026-07-11
(adaptive-learner#1562, #1587, #2923), although they arrive 3 to 10 days after
a release and fail the app's pin and schema parity tests every time. And the
content template and the hub had carried engine-currency.yml, which compares
the pin with the tracked npm dist-tag, since 11:08 UTC on 2026-09-23, about 90
minutes before 0.28.0 was published. docs/architecture.md ("Pinning and
currency") carries the measured account.
Docs
docs/architecture.md, "Rule ownership": a unification can let the worse
version win (the hint-length case, measured: the engine's version reported 44
warnings, all false; the template's copy none). Moving a rule now has three
conditions: every version measured per repo over the real content, precision
and completeness recorded separately (and where a false hit went), and the
severity checked at the new place. The app holds three versions of the
engine's rules, its backend's Pydantic models among them (engine#197, #198,
#199, #206).- "Pinning and currency": the record of the currency check, the condition
under which a scheduled run can prove the create path at all, and
Dependabot's engine PRs as a signal that arrives late and red (#198, #204). - Every subpath export is documented: the
/rulesrow names all seven, and
docs/qti.mdgains an API table with all ten/qtiexports; the README
exports gate covers the subpaths, and a changelog gate fails on a heading
repeated within one release section (#200).
v0.29.0
No schema change: x-schema-version stays 1.16, and every manifest and
lesson valid under 0.28.0 is valid under 0.29.0. What moves are two author
lints, a new package subpath and a new data file.
W-DOMAIN-UNKNOWN also on a lesson's own domain (engine#183)
The lint checked only the domain of a manifest's set entries. A lesson may
carry its own domain (absent or null inherits the set's), and the real
case sat exactly there: two exported book sets with "domain": "imported" on
every lesson. validateLesson now reports the same warning at /domain,
through the helper the manifest check uses, so both say the same thing. No
value is special-cased: the engine does not know the origin markers of
individual consumers. Measured over the ten content repos before shipping:
no lesson draws it today.
One W-HINT-LENGTH rule (engine#186)
The hint-length rule existed twice, as this warning and as an error in the
content template's validator, and the two disagreed. The engine's version
matched a number word and a length noun anywhere in the hint, without word
boundaries: over the ten content repos it reported 44 warnings, all false
("Achte" read as "acht", "bestimmten" as "ten", "Fragezeichen" and
"characteristic" as length nouns). It also missed the article and
single-character forms ("mit einem Buchstaben", "ein einzelnes Zeichen", "a
single character"), eleven and twelve, the "-buchstabig" adjectives, and
every blank hint.
The rule is now a count directly before a length noun, with Unicode-aware
boundaries, plus the reversed colon form ("Buchstaben: 4", plural counts only)
and the "-buchstabig" adjectives. It checks the exercise hint and every
blanks[].hint, each at its own path; card hints stay unchecked. It stays a
warning. Measured with the build over the ten content repos: 44 warnings
before, 0 after.
learn-content-engine/rules: the semantic rules without ajv or node:fs (engine#191)
A browser consumer that has already shape-checked its input can now call the
engine's rules instead of re-implementing them: validateLessonRules,
validateManifestRules, isSlugId (the schema's $defs/SlugId),
SLUG_ID_PATTERN and SLUG_ID_MAX_LENGTH. validateLesson and
validateManifest check the shape with ajv and then call exactly these
functions, so the two cannot disagree; a test pins the parity on every
conformance fixture. The entry imports neither ajv nor node:*, and a test
walks its module graph to keep it that way. Measured with esbuild (minified,
browser): the entry is 21,590 bytes (8,320 gzip); validateLesson alone is
144,637 bytes, 114,312 of them ajv, and it reads the schema with node:fs.
Internally, src/validate.ts is cut along its three layers: issues.ts
(issue types and helpers), rules.ts (semantic rules and author lints),
validate.ts (the structural layer). The public API of the package root is
unchanged.
schema/grading-presets.json: a sourced catalog of grading scales
Exported as learn-content-engine/schema/grading-presets.json: grading scales
an author can copy into a set's evaluation block instead of typing a table.
23 presets (2 base forms, 12 class A following a primary source, 9 class B
naming their convention), 31 templates that carry a scale's grades and pass
mark but no thresholds, and 3 scales that cannot be expressed as percentages
(ECTS, GCSE/A-level, the German state law examination), each with the reason.
Every entry names its sources. A preset is copied: a later correction does
not reach sets that already carry it. label is the grade as written,
label_native the official word. Where an official lower bound is not a whole
percent, the row carries the whole percent below it and lists the official
value in rounded_rows. Tests hold every preset to validateManifest without
an error or a warning. See Grading presets.
Docs
docs/architecture.md: "Rule ownership: which layer owns which rule". The
engine owns every rule about the content itself; the content template's
tooling owns what the engine cannot see; the app owns render-time decisions.
With the known violations, as of 2026-09-24, and where each is tracked.- The grading presets explain why flooring a threshold is exact on a point
scale, and that it maps the national scale, not a consumer's run (a run on
its own grid can land between two reachable scale points). - Every guide that pointed at
src/validate.tsfor adding a rule now points at
src/rules.ts; README lists the two subpath entries. CONTRIBUTING.md: a release now brings every doc up to date first, with the
audit recorded in the release PR.- The project language is English: code, comments, docs, commit messages, PR
and issue texts (.claude/rules/coding-standards.md);tdd.mdis
translated without a rule change.
0.28.0 - W-CLOZE-NO-CARRIER and schema 1.16
Two changes travel together so consumers re-pin once, not twice.
W-CLOZE-NO-CARRIER (engine#178)
A cloze whose sentence is nothing but its blanks has no gap text to read around: the question sits in the prompt and the blank teaches nothing. That exercise is a question with an answer, and a native type says so directly - multiple_choice in select mode, free_text in type mode. Both exist (multiple_choice since schema v1.6), so the cloze spelling is a workaround that outlived its reason.
A warning, not an error: the content is valid and the shape can be deliberate. multiselect is exempt by design - its sentence IS the question and carries no markers.
Measured before shipping: 195 of 1406 cloze exercises across the content repos (190 select, 5 type), concentrated in three repositories, and nothing else flagged. It also caught a real defect in a set being authored at the time, before that set was merged.
Schema 1.16 (engine#180)
The authored schema files carried 22 em dashes in their field descriptions, and every consumer inherited them: both files are mirrored byte for byte into ten content repos and into the reference app, where a local fix would turn that repo's drift gate red. Fixed at the source.
No field moved. x-schema-version goes 1.15 to 1.16 in both files because it counts edits to the schema FILE, description-only ones included, while the manifest's schema_version default stays at 1.7.
Upgrading
Content repos: bump schema/engine-version.txt to 0.28.0 and refresh the mirror (python3 scripts/check_schema_drift.py --update). Nothing in existing content becomes invalid; the new lint reports, it does not block.
Full notes: CHANGELOG.md.
0.27.0 - manifest evaluation block
[0.27.0] - 2026-09-23
Manifest: an optional evaluation block on a set entry (engine#171)
A consumer decides how it scores a lesson run, and the reference app's
default (percent correct plus stars at fixed marks) is wrong for exam-like
content, where the pass mark is part of the subject: a driving-theory test
passes at a stated percentage, a certification set has a grade table. A set
entry may now declare evaluation with scheme (percent, pass_fail,
grades), pass_percent, basis (elements, one value today), a
grades table, report (compact, detailed) and a title. The block
describes ONE lesson run and never aggregates across the set's lessons; the
engine validates the declaration, while sampling, scoring and rendering
stay consumer-side. Beyond the shape, four rules: E-EVAL-GRADES-MISSING,
E-EVAL-PASS-MISSING, E-EVAL-GRADES-DUP (two rows at one threshold would
earn two grades, and which one wins would depend on the row order) and the
one warning, W-EVAL-GRADES-NO-FLOOR (a table whose lowest row starts
above 0 leaves the runs below it to a consumer-invented fallback label;
a warning because "no grade down here" can be the intent). asContentSetEntry carries the block into the
canonical entry, so a consumer reads it through the engine API.
Set level only in this version; the lesson schema stays strict, so an
evaluation inside a lesson file is rejected as before, and the name is
reserved for a planned per-lesson override. Absent block, unchanged
behaviour. Both manifest counters moved, because the block is a new
set-entry key: schema_version's default 1.6 to 1.7 and
x-schema-version 1.14 to 1.15 (the lesson schema follows in lockstep,
as it always has). See docs/lesson-format.md#evaluation.
W-PROMPT-DUP: a prompt that repeats the sentence or the step title (engine#169)
A new author lint. An exercise prompt that equals its sentence (the
cloze sentence, or the question stem in multiselect mode) or the step
title is reported once per matching field, as a warning that never
blocks. Consumers render the prompt as the heading and the sentence as the
question box (the title in the step list), so the learner reads the same
question twice; on a real device this showed up on 10 of 276 exercises in
one content repo, all cloze, spread over six lessons - a pattern that
arises while authoring, not a one-off. Compared after trimming and Unicode
NFC normalisation (a decomposed umlaut equals its precomposed form); case
is kept. No schema change, schema/quality-rules.json untouched.