Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ All notable changes to `learn-content-engine`. The format is inspired by

## [Unreleased]

### Element-level stable identity: pairs, blanks, options (engine#91 Phase 2)

`stable_id` (engine#90) closed orphaning at the exercise/card level, but not
the case that actually occurred (adaptive-learner#2161): an answer-text
correction inside a surviving exercise still moves the content-derived key
of one MATCHING pair, CLOZE blank or MULTIPLE_CHOICE option, orphaning that
element's learner row. The app's mitigation (adaptive-learner#2308, "Weg C")
covers 186 of 190 measured moved slots (adaptive-learner#2301) by diffing
ordered content-derived key lists at update time; the remaining ambiguous
slots need a real identity, not a better diff.

`Pair`, `ClozeBlank` and `MultipleChoiceOption` gain an optional `stable_id`
(`$defs/SlugId` - hyphens only, no legacy underscore grandfathering, unlike
the exercise/card field). Shares the SAME per-set uniqueness namespace as
exercise/card ids (`E-STABLE-ID-DUP`, `collectStableIds`) - one flat space,
distinct `pair-`/`blank-`/`opt-` minter prefixes for readability, not a
second namespace. The stability gate's V1-V4 rules already cover the new
kinds generically (no new rule numbers): `buildStableIdInventory` just walks
one more level.

`mint-stable-ids` mints these too. They have no `"id"` member to anchor the
insertion on (unlike exercise/card), so it lands as the object's last member
before the closing brace - the same style already used when a card/exercise
`"id"` happens to be last.

`x-schema-version` `1.11` -> `1.12` in both schemas (lesson + content-manifest
move in lockstep, established convention). Additive: content without the new
fields validates unchanged.

Scope: this ships the identity primitive and tooling only. App-side
consumption (`element-keys.ts` preferring the new field, `remap-plan.ts`
using id-based matching) is separate follow-up work, the same split as
engine#90 vs. adaptive-learner#2130/#2455.

### Prose gate: manuscript-tools ms-check over docs/ + README

The sibling library `manuscript-tools` (PyPI, pinned 0.11.0) already
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ network, storage, or UI code - you supply the bytes and keep fetch +
persistence. The bundled, strict JSON-Schema makes it a self-contained **format
reference**: you can author and validate lessons without the application the
format originated in ([Adaptive Learner](https://github.com/astrapi69/adaptive-learner)).
Tracks the lesson schema, currently **v1.11**.
Tracks the lesson schema, currently **v1.12**.

## Install

Expand Down
2 changes: 1 addition & 1 deletion docs/blog/de/one-source-many-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Drei Dinge bleiben in dem, was Sie schreiben, bewusst getrennt: der **Inhalt** (

Sie müssen dieses JSON nicht von Hand schreiben. Die Referenz-App liefert einen **Lektions-Editor** mit, der dem Wort "einfach" entwachsen ist: der klassische vierstufige Assistent (Metadaten, Karten, Übungen, Speichern & Teilen), ein Buch-Pfad, der eingefügte oder hochgeladene Lehrbuchkapitel in Wissens-Lektionen verwandelt, ein Erweiterungs-Zweig, der selbst benotete Quizze und Diktate ohne JSON autorierbar macht, und ein Bearbeitungsmodus. All das erzeugt Lektionen in demselben kanonischen Schema und kann sogar direkt aus der App einen Pull Request gegen ein Content-Repository öffnen; [Teil 3 der Serie](create-a-lesson-in-the-app.md) geht jeden Pfad durch. Was darüber hinaus **geplant** ist, ist ein reicherer, auf Lehrende zugeschnittener Editor für die schwereren Fälle (größere Sets). Beides ändert das Modell nicht: Die Quelle bleibt der Vertrag, und jeder Editor (einfach oder reich) ist nur ein weiteres Werkzeug, das ihn liest und schreibt.

> **Additiv per Politik.** Das Schema ist versioniert (aktuell 1.11) und wächst nur additiv: Neue Felder sind optional, eine letztes Jahr geschriebene Lektion validiert also heute noch. Sie werden nie gezwungen, Inhalte zu migrieren, nur weil sich das Schema bewegt hat.
> **Additiv per Politik.** Das Schema ist versioniert (aktuell 1.12) und wächst nur additiv: Neue Felder sind optional, eine letztes Jahr geschriebene Lektion validiert also heute noch. Sie werden nie gezwungen, Inhalte zu migrieren, nur weil sich das Schema bewegt hat.

## Validierung ist Rechtschreibprüfung für Test-Logik

Expand Down
4 changes: 2 additions & 2 deletions docs/blog/de/schema-first-content-engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ tags: [architecture, schema-design, typescript, content-engineering]

*Wie `learn-content-engine` ein stabiles Kern-Schema behält und trotzdem Raum für pädagogische Erfindung lässt: durch eine harte Linie zwischen dem Vertrag, den es besitzt, und den Regeln, die seine Consumer besitzen.*

`learn-content-engine` · Schema aktuell v1.11 · framework-agnostisches TypeScript
`learn-content-engine` · Schema aktuell v1.12 · framework-agnostisches TypeScript

## Das Content-Schema-Dilemma

`learn-content-engine` ist eine framework-agnostische TypeScript-Bibliothek, die Lerninhalte parst und validiert: Sprachkurse zuallererst, wobei ein `domain`-Feld (seit Engine 0.20.0 ein kontrolliertes Vokabular aus bekannten Werten plus other) dieselbe Form auch andere Wissensgebiete tragen lässt (Technik-Kurse, Führerschein-Vorbereitung, Psychologie). Sie verwandelt Rohquellen (Lektions-JSON plus eine `manifest.yaml`) in eine kanonische interne Form, und sie ist die einzige Quelle der Wahrheit für das Lektions-Schema, aktuell Version 1.11.
`learn-content-engine` ist eine framework-agnostische TypeScript-Bibliothek, die Lerninhalte parst und validiert: Sprachkurse zuallererst, wobei ein `domain`-Feld (seit Engine 0.20.0 ein kontrolliertes Vokabular aus bekannten Werten plus other) dieselbe Form auch andere Wissensgebiete tragen lässt (Technik-Kurse, Führerschein-Vorbereitung, Psychologie). Sie verwandelt Rohquellen (Lektions-JSON plus eine `manifest.yaml`) in eine kanonische interne Form, und sie ist die einzige Quelle der Wahrheit für das Lektions-Schema, aktuell Version 1.12.

Der Kern ist bewusst klein. Kein Rendering, keine Persistenz, kein Netzwerk; die einzige Laufzeit-Abhängigkeit ist ein YAML-Parser. Was er bietet, ist reine Validierung und Transformation. Dieser Minimalismus ist der Punkt, und er erzwingt eine harte Frage: *Wie entwickelt man ein Content-Schema weiter, ohne jeden Consumer zu brechen, der davon abhängt?*

Expand Down
2 changes: 1 addition & 1 deletion docs/blog/one-source-many-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Three things stay deliberately separate in what you write: the **content** (the

You don't have to write that JSON by hand. The reference app ships a **lesson creator** that has outgrown the word "simple": the classic four-step wizard (metadata, cards, exercises, save & share), a book path that turns pasted or uploaded textbook chapters into knowledge lessons, an extension branch that makes even graded quizzes and dictation authorable without JSON, and an edit mode. All of it produces lessons in this same canonical schema and can even open a pull request against a content repository directly from the app; part 3 of the series walks through every path. What is **planned** beyond it is a richer, teacher-facing editor for the heavier cases (larger sets). Neither changes the model: the source stays the contract, and every editor (simple or rich) is just one more tool that reads and writes it.

> **Additive by policy.** The schema is versioned (currently 1.11) and only grows additively: new fields are optional, so a lesson written last year still validates today. You are never forced to migrate content just because the schema moved.
> **Additive by policy.** The schema is versioned (currently 1.12) and only grows additively: new fields are optional, so a lesson written last year still validates today. You are never forced to migrate content just because the schema moved.

## Validation is spell-check for test logic

Expand Down
4 changes: 2 additions & 2 deletions docs/blog/schema-first-content-engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ tags: [architecture, schema-design, typescript, content-engineering]

*How `learn-content-engine` keeps a stable core schema and still leaves room for pedagogical invention: by drawing a hard line between the contract it owns and the rules its consumers own.*

`learn-content-engine` · schema currently v1.11 · framework-agnostic TypeScript
`learn-content-engine` · schema currently v1.12 · framework-agnostic TypeScript

## The content-schema dilemma

`learn-content-engine` is a framework-agnostic TypeScript library that parses and validates learning content: language courses foremost, though a `domain` field (a known-values-plus-other vocabulary since engine 0.20.0) lets the same shape carry other knowledge domains (tech courses, driving-test prep, psychology). It turns raw sources (lesson JSON plus a `manifest.yaml`) into a canonical internal shape, and it is the single source of truth for the lesson schema, currently version 1.11.
`learn-content-engine` is a framework-agnostic TypeScript library that parses and validates learning content: language courses foremost, though a `domain` field (a known-values-plus-other vocabulary since engine 0.20.0) lets the same shape carry other knowledge domains (tech courses, driving-test prep, psychology). It turns raw sources (lesson JSON plus a `manifest.yaml`) into a canonical internal shape, and it is the single source of truth for the lesson schema, currently version 1.12.

The core is deliberately small. No rendering, no persistence, no networking; its only runtime dependency is a YAML parser. What it offers is pure validation and transformation. That minimalism is the point, and it forces one hard question: *how do you evolve a content schema without breaking every consumer that depends on it?*

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ inherited by the set's lessons unless a lesson declares its own.

## Schema-version policy (additive)

The lesson schema is versioned (`x-schema-version`, currently `1.11`) and evolves
The lesson schema is versioned (`x-schema-version`, currently `1.12`) and evolves
**additively**: new fields are optional, so **older content stays valid under a
newer schema**. For example, v1.5 added the inline `examples` field; a v1.4
lesson without `examples` validates unchanged under v1.5. v1.6 added the native
Expand Down
50 changes: 46 additions & 4 deletions docs/lesson-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,10 +587,41 @@ Scope and limit of this stage: it closes orphaning caused by slug renames and
position shifts on the exercise and card level. It does NOT close the case
that actually occurred (adaptive-learner#2161): an answer correction inside a
surviving exercise still moves the content-derived element key and orphans
exactly that element. That remainder is reduced, not closed, and is currently
covered only by the app-side update guard (the stopgap from
adaptive-learner#2128) until engine#91 or an app-side element-key decision
closes it.
exactly that element. The app shipped a partial mitigation
(adaptive-learner#2308, "Weg C"): at update time it diffs the old and new
ordered element-key lists and offers to carry progress over when the mapping
is unambiguous (measured 186 of 190 moved slots, adaptive-learner#2301). The
remaining case - a slot the mapping cannot disambiguate - is what element-level
stable identity closes (below).

### Element-level stable identity (`pairs[].stable_id`, `blanks[].stable_id`, `options[].stable_id`)

Since schema v1.12 (additive, engine#91) a MATCHING pair, a CLOZE blank and a
MULTIPLE_CHOICE option may each carry their own `stable_id`, one level below
the exercise. Same contract as the exercise/card field above (mint once,
never changes, opaque, NOT derived from content) with two differences:

- **Stricter pattern.** These are brand-new fields with no pre-1.9 content to
grandfather, so they reference `$defs/SlugId` directly (lowercase letters
and digits in hyphen-separated runs only - no underscore, unlike the
legacy-tolerant exercise/card pattern).
- **Shared namespace.** A pair/blank/option `stable_id` lives in the SAME
per-set uniqueness space as exercise and card ids (`E-STABLE-ID-DUP` within
one lesson, `collectStableIds` across a set) - one flat namespace, not a
second one, so the minter's `pair-`/`blank-`/`opt-` prefixes are a
readability convention, not an enforcement boundary.

Optional, additive: content without it validates unchanged, and the stability
gate's V1-V4 rules (`check-stable-ids`) already cover these kinds generically
- no new rule numbers, since a pair/blank/option element is just another
`kind` in the same inventory.

This closes the SCHEMA half of engine#91: a pair/blank/option now HAS an
identity that survives an answer-text correction. Nothing consumes it yet -
the app's `element-keys.ts` (which derives its comparison keys from
`pair.left`, `blank.accept[0]`, and the sorted correct-option text) and its
`remap-plan.ts` update-guard logic would need to prefer this field when
present, tracked as follow-up app-side work, not part of this schema change.

## Manifest format

Expand Down Expand Up @@ -890,6 +921,10 @@ It compares the working tree against the merge base with `--base` (default
| `V5` | a `retired_id` left the set's `retired_ids` list (a published retirement is never un-declared; add-only, like the ids themselves) |
| `V6` | a `retired_id` is declared retired but still present in the set (a consumer resolves it as living, so the retirement would be silently ignored) |

`kind` in these rules covers `exercise`, `card`, and, since schema v1.12
(engine#91), `pair`, `blank` and `option` - the same six rules, not six more,
since a sub-element is just another kind in the same inventory.

Editing content under a constant id passes, and that is the entire point.

Two floors keep a green run meaningful, because this gate matters most while
Expand Down Expand Up @@ -979,6 +1014,13 @@ written). That property is what keeps the retrofit a non-event for learner
progress: old derived keys and new stable ids coexist in one file, so a
consumer can compute its remap locally.

Since schema v1.12 (engine#91) the same run also mints every MATCHING pair,
CLOZE blank and MULTIPLE_CHOICE option that lacks a `stable_id` (`pair-`,
`blank-`, `opt-` prefixes). These have no `"id"` member to anchor on, so the
insertion lands as the object's last member, right before its closing brace -
the same style already used when a card or exercise's `"id"` happens to be
its last member.

## Editor setup

Bind the bundled schema in your editor for autocomplete and inline errors while
Expand Down
2 changes: 1 addition & 1 deletion docs/schema-diagrams.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ false ordering sentence survived in this repo for weeks.

<!-- GENERATED:schema-diagrams BEGIN - do not edit by hand, run scripts/generate-schema-diagrams.mjs -->

<!-- schema x-schema-version: 1.11 -->
<!-- schema x-schema-version: 1.12 -->

### 1. Content structure (generated)

Expand Down
2 changes: 1 addition & 1 deletion schema/content-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -380,5 +380,5 @@
],
"title": "ContentManifest",
"type": "object",
"x-schema-version": "1.11"
"x-schema-version": "1.12"
}
41 changes: 40 additions & 1 deletion schema/lesson.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,19 @@
"default": null,
"description": "Optional placeholder text shown inside the input (``type`` mode) before the user starts typing.",
"title": "Placeholder"
},
"stable_id": {
"anyOf": [
{
"$ref": "#/$defs/SlugId"
},
{
"type": "null"
}
],
"default": null,
"title": "Stable Id",
"description": "engine#91 - schema 1.12 (additive). Element-level counterpart to the exercise/card stable_id (engine#90): identifies THIS blank for progress/SRS joins below the exercise level, so an answer-text correction (moving `accept[0]`) does not orphan its learner row. Once published it NEVER changes. Shares the SAME per-set stable_id namespace as card/exercise ids (checked the same way: collectStableIds set-wide, the schema's E-STABLE-ID-DUP rule per-document). Opaque mint-once value, NOT derived from content. Optional: content without it validates unchanged. Uses the strict $defs/SlugId shape (hyphens only) - unlike the card/exercise field, this is a brand-new field with no legacy underscore-bearing ids to grandfather."
}
},
"required": [
Expand Down Expand Up @@ -899,6 +912,19 @@
"title": "Correct",
"type": "boolean"
},
"stable_id": {
"anyOf": [
{
"$ref": "#/$defs/SlugId"
},
{
"type": "null"
}
],
"default": null,
"title": "Stable Id",
"description": "engine#91 - schema 1.12 (additive). Element-level counterpart to the exercise/card stable_id (engine#90): identifies THIS option for progress/SRS joins below the exercise level. Once published it NEVER changes. Shares the SAME per-set stable_id namespace as card/exercise ids (checked the same way: collectStableIds set-wide, the schema's E-STABLE-ID-DUP rule per-document). Opaque mint-once value, NOT derived from content. Optional: content without it validates unchanged. Uses the strict $defs/SlugId shape (hyphens only) - unlike the card/exercise field, this is a brand-new field with no legacy underscore-bearing ids to grandfather."
},
"text": {
"description": "The option text shown to the learner. Unique within the exercise - the text IS the option, so a duplicate would be ambiguous.",
"maxLength": 500,
Expand Down Expand Up @@ -930,6 +956,19 @@
"minLength": 1,
"title": "Right",
"type": "string"
},
"stable_id": {
"anyOf": [
{
"$ref": "#/$defs/SlugId"
},
{
"type": "null"
}
],
"default": null,
"title": "Stable Id",
"description": "engine#91 - schema 1.12 (additive). Element-level counterpart to the exercise/card stable_id (engine#90): identifies THIS pair for progress/SRS joins below the exercise level, so an answer-text correction (moving `left`/`right`) does not orphan its learner row. Once published it NEVER changes. Shares the SAME per-set stable_id namespace as card/exercise ids (checked the same way: collectStableIds set-wide, the schema's E-STABLE-ID-DUP rule per-document). Opaque mint-once value, NOT derived from content. Optional: content without it validates unchanged. Uses the strict $defs/SlugId shape (hyphens only) - unlike the card/exercise field, this is a brand-new field with no legacy underscore-bearing ids to grandfather."
}
},
"required": [
Expand Down Expand Up @@ -1205,5 +1244,5 @@
],
"title": "Lesson",
"type": "object",
"x-schema-version": "1.11"
"x-schema-version": "1.12"
}
2 changes: 1 addition & 1 deletion schema/quality-rules.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema-version": "1.11",
"$schema-version": "1.12",
"_comment": "GENERATED from scripts/generate_lesson_schema.py (EXP-039). Do not edit. Shared quality minimums for the content quality gate (app + content repo).",
"rules": {
"minExerciseTypes": 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,5 +380,5 @@
],
"title": "ContentManifest",
"type": "object",
"x-schema-version": "1.11"
"x-schema-version": "1.12"
}
Loading
Loading