From 6f44a6f83a9e5c8e7b45d79455405aac252cbb36 Mon Sep 17 00:00:00 2001 From: Asterios Raptis Date: Tue, 11 Aug 2026 18:43:28 +0200 Subject: [PATCH] feat(stability): element-level stable identity for pairs/blanks/options (engine#91 Phase 2) stable_id (engine#90) closed orphaning at the exercise/card level, 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); the remaining ambiguous slots need a real identity, not a better diff. Schema (1.11 -> 1.12, both schemas move in lockstep): 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. Tooling, extended not duplicated: - collectStableIds (stable-ids.ts) walks pairs/blanks/options into the same per-lesson duplicate map. - buildStableIdInventory (stable-id-stability.ts) walks the same three into the same element inventory; compareStableIdInventories needed NO changes - V1-V4 are already generic over kind, so pair/blank/option ride the existing rules instead of six new ones. - mint-stable-ids scanner: pairs/blanks/options have no "id" member to anchor insertion on (unlike exercise/card), so the insertion lands as the object's last member before its closing brace - the same style already used when a card/exercise "id" happens to be last. Found and fixed a whitespace bug surfaced by that anchor change: inserting at the raw "}" offset stranded trailing whitespace before the new comma; fixed by backtracking to the last non-whitespace character, same as the id-anchored case already does implicitly. Docs: new "Element-level stable identity" section in lesson-format.md, V-rule table and minting section note the new kinds, CHANGELOG entry. Regenerated: TS types (sync-types), schema-diagrams.md (version stamp), schema-baseline fixtures (byte-frozen copies), six stale "currently 1.11" doc claims caught by today's docs-version-claims gate. Scope: 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. Addresses the engine half of #91; the issue stays open until app consumption closes the remainder (its own stated policy: not closed without follow-through). Full gate green: make release-check (sync-types-check, schema-diagrams --check, lint, typecheck, 920 tests, build), make prose-check. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 34 +++++ README.md | 2 +- docs/blog/de/one-source-many-outputs.md | 2 +- .../de/schema-first-content-engineering.md | 4 +- docs/blog/one-source-many-outputs.md | 2 +- docs/blog/schema-first-content-engineering.md | 4 +- docs/concepts.md | 2 +- docs/lesson-format.md | 50 ++++++- docs/schema-diagrams.md | 2 +- schema/content-manifest.schema.json | 2 +- schema/lesson.schema.json | 41 +++++- schema/quality-rules.json | 2 +- .../content-manifest.schema.json | 2 +- .../schema-baseline/lesson.schema.json | 41 +++++- src/mint-stable-ids.test.ts | 124 +++++++++++++++- src/mint-stable-ids.ts | 135 +++++++++++++++--- src/schema-baseline.test.ts | 2 +- src/stable-id-stability.test.ts | 58 +++++++- src/stable-id-stability.ts | 36 +++-- src/stable-ids.test.ts | 63 ++++++++ src/stable-ids.ts | 36 ++++- src/types/lesson-schema.generated.ts | 19 ++- src/validate.test.ts | 52 +++++++ 23 files changed, 654 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569d5f5..faa2b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c0b8343..92003af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/blog/de/one-source-many-outputs.md b/docs/blog/de/one-source-many-outputs.md index c56838e..bd5eccb 100644 --- a/docs/blog/de/one-source-many-outputs.md +++ b/docs/blog/de/one-source-many-outputs.md @@ -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 diff --git a/docs/blog/de/schema-first-content-engineering.md b/docs/blog/de/schema-first-content-engineering.md index b895da6..3fab0bb 100644 --- a/docs/blog/de/schema-first-content-engineering.md +++ b/docs/blog/de/schema-first-content-engineering.md @@ -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?* diff --git a/docs/blog/one-source-many-outputs.md b/docs/blog/one-source-many-outputs.md index d77db2d..02c42d4 100644 --- a/docs/blog/one-source-many-outputs.md +++ b/docs/blog/one-source-many-outputs.md @@ -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 diff --git a/docs/blog/schema-first-content-engineering.md b/docs/blog/schema-first-content-engineering.md index 5ee7c6e..4af3472 100644 --- a/docs/blog/schema-first-content-engineering.md +++ b/docs/blog/schema-first-content-engineering.md @@ -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?* diff --git a/docs/concepts.md b/docs/concepts.md index ee3728e..4384d8f 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -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 diff --git a/docs/lesson-format.md b/docs/lesson-format.md index c898fd9..8949c19 100644 --- a/docs/lesson-format.md +++ b/docs/lesson-format.md @@ -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 @@ -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 @@ -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 diff --git a/docs/schema-diagrams.md b/docs/schema-diagrams.md index 2a735b6..362d0e8 100644 --- a/docs/schema-diagrams.md +++ b/docs/schema-diagrams.md @@ -26,7 +26,7 @@ false ordering sentence survived in this repo for weeks. - + ### 1. Content structure (generated) diff --git a/schema/content-manifest.schema.json b/schema/content-manifest.schema.json index bbddda5..b3bb753 100644 --- a/schema/content-manifest.schema.json +++ b/schema/content-manifest.schema.json @@ -380,5 +380,5 @@ ], "title": "ContentManifest", "type": "object", - "x-schema-version": "1.11" + "x-schema-version": "1.12" } diff --git a/schema/lesson.schema.json b/schema/lesson.schema.json index c97637b..f232570 100644 --- a/schema/lesson.schema.json +++ b/schema/lesson.schema.json @@ -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": [ @@ -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, @@ -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": [ @@ -1205,5 +1244,5 @@ ], "title": "Lesson", "type": "object", - "x-schema-version": "1.11" + "x-schema-version": "1.12" } diff --git a/schema/quality-rules.json b/schema/quality-rules.json index 849974f..ef8e02f 100644 --- a/schema/quality-rules.json +++ b/schema/quality-rules.json @@ -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, diff --git a/src/__fixtures__/schema-baseline/content-manifest.schema.json b/src/__fixtures__/schema-baseline/content-manifest.schema.json index bbddda5..b3bb753 100644 --- a/src/__fixtures__/schema-baseline/content-manifest.schema.json +++ b/src/__fixtures__/schema-baseline/content-manifest.schema.json @@ -380,5 +380,5 @@ ], "title": "ContentManifest", "type": "object", - "x-schema-version": "1.11" + "x-schema-version": "1.12" } diff --git a/src/__fixtures__/schema-baseline/lesson.schema.json b/src/__fixtures__/schema-baseline/lesson.schema.json index c97637b..f232570 100644 --- a/src/__fixtures__/schema-baseline/lesson.schema.json +++ b/src/__fixtures__/schema-baseline/lesson.schema.json @@ -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": [ @@ -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, @@ -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": [ @@ -1205,5 +1244,5 @@ ], "title": "Lesson", "type": "object", - "x-schema-version": "1.11" + "x-schema-version": "1.12" } diff --git a/src/mint-stable-ids.test.ts b/src/mint-stable-ids.test.ts index 5246d46..0d096a0 100644 --- a/src/mint-stable-ids.test.ts +++ b/src/mint-stable-ids.test.ts @@ -20,8 +20,15 @@ const STABLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{7,63}$/; /** Deterministic minter for tests. */ const minter = (() => { let counter = 0; - return (kind: "exercise" | "card"): string => - `${kind === "card" ? "card" : "ex"}-test${String(++counter).padStart(4, "0")}`; + const prefix: Record<"exercise" | "card" | "pair" | "blank" | "option", string> = { + exercise: "ex", + card: "card", + pair: "pair", + blank: "blank", + option: "opt", + }; + return (kind: "exercise" | "card" | "pair" | "blank" | "option"): string => + `${prefix[kind]}-test${String(++counter).padStart(4, "0")}`; })(); const PRETTY = `{ @@ -263,6 +270,119 @@ describe("mintStableIds: completeness is asserted, not assumed", () => { }); }); +describe("mintStableIds: pairs/blanks/options (engine#91 Phase 2)", () => { + const PRETTY_SUB = `{ + "id": "03-sub", + "title": "Sub-elements", + "cards": [], + "steps": [ + { + "id": "s1", + "type": "exercise", + "exercise": { + "id": "m1", + "type": "matching", + "prompt": "p", + "pairs": [ + { "left": "a", "right": "b" }, + { "left": "c", "right": "d" } + ] + } + }, + { + "id": "s2", + "type": "exercise", + "exercise": { + "id": "c1", + "type": "cloze", + "cloze_mode": "type", + "prompt": "p", + "sentence": "___ ___", + "blanks": [ + { "accept": ["x"] }, + { "accept": ["y"] } + ] + } + }, + { + "id": "s3", + "type": "exercise", + "exercise": { + "id": "mc1", + "type": "multiple_choice", + "prompt": "p", + "options": [ + { "text": "a", "correct": true }, + { "text": "b" } + ] + } + } + ] +} +`; + + const INLINE_SUB = `{ + "id": "04-sub-inline", + "title": "Sub-elements inline", + "cards": [], + "steps": [ + { "id": "s1", "type": "exercise", "exercise": { "id": "m1", "type": "matching", "prompt": "p", "pairs": [{ "left": "a", "right": "b" }] } } + ] +} +`; + + it("mints every pair, blank and option alongside exercises/cards", () => { + const report = mintStableIds(PRETTY_SUB, "03-sub.json", minter); + expect(report.ok).toBe(true); + expect(report.eligible).toBe(9); + expect(report.minted).toBe(9); + const after = JSON.parse(report.newText ?? "") as { + steps: { + exercise: { pairs?: { stable_id?: string }[]; blanks?: { stable_id?: string }[]; options?: { stable_id?: string }[] }; + }[]; + }; + expect(after.steps[0]!.exercise.pairs!.every((pair) => pair.stable_id)).toBe(true); + expect(after.steps[1]!.exercise.blanks!.every((blank) => blank.stable_id)).toBe(true); + expect(after.steps[2]!.exercise.options!.every((option) => option.stable_id)).toBe(true); + }); + + it("stays add-only: everything except the new stable_ids is byte-identical modulo formatting", () => { + const report = mintStableIds(PRETTY_SUB, "03-sub.json", minter); + const strip = (text: string) => + JSON.parse( + JSON.stringify(JSON.parse(text), (key, value) => (key === "stable_id" ? undefined : value)), + ); + expect(strip(report.newText ?? "")).toEqual(strip(PRETTY_SUB)); + }); + + it("inline style: inserts inline and keeps the one-line object", () => { + const report = mintStableIds(INLINE_SUB, "04-sub-inline.json", minter); + expect(report.ok).toBe(true); + expect(report.minted).toBe(2); + const after = report.newText ?? ""; + expect(after.split("\n").length).toBe(INLINE_SUB.split("\n").length); + expect(after).toContain('"right": "b", "stable_id": "'); + }); + + it("the minted lesson passes validateLesson under schema 1.12", () => { + const report = mintStableIds(PRETTY_SUB, "03-sub.json", minter); + const checked = validateLesson(JSON.parse(report.newText ?? "")); + expect(checked.errors).toEqual([]); + expect(checked.valid).toBe(true); + }); + + it("keeps an existing sub-element stable_id verbatim and only mints the rest", () => { + const partiallyMinted = PRETTY_SUB.replace( + '{ "left": "a", "right": "b" }', + '{ "left": "a", "right": "b", "stable_id": "pair-vorhanden1" }', + ); + const report = mintStableIds(partiallyMinted, "03-sub.json", minter); + expect(report.eligible).toBe(8); + expect(report.minted).toBe(8); + expect(report.newText).toContain('"pair-vorhanden1"'); + }); +}); + describe("parseMintArgs / formatMintReports", () => { it("parses paths and the write flag; dry-run is the default", () => { const parsed = parseMintArgs(["mint-stable-ids", "a.json", "--write"]); diff --git a/src/mint-stable-ids.ts b/src/mint-stable-ids.ts index 102f4bd..5540b1a 100644 --- a/src/mint-stable-ids.ts +++ b/src/mint-stable-ids.ts @@ -49,7 +49,15 @@ export function parseMintArgs(argv: string[]): MintArgs | { error: string } { } /** Mints one id; injectable for deterministic tests. */ -export type StableIdMinter = (kind: "exercise" | "card") => string; +export type StableIdMinter = (kind: "exercise" | "card" | "pair" | "blank" | "option") => string; + +const MINT_PREFIX: Record<"exercise" | "card" | "pair" | "blank" | "option", string> = { + exercise: "ex", + card: "card", + pair: "pair", + blank: "blank", + option: "opt", +}; /** Default minter: opaque, time-seeded, unique within the process. */ export const defaultMinter: StableIdMinter = (() => { @@ -61,15 +69,21 @@ export const defaultMinter: StableIdMinter = (() => { const salt = Math.floor(Math.random() * 1296) .toString(36) .padStart(2, "0"); - return `${kind === "card" ? "card" : "ex"}-${time}${seq}${salt}`; + return `${MINT_PREFIX[kind]}-${time}${seq}${salt}`; }; })(); interface InsertionTarget { - kind: "exercise" | "card"; - /** Byte offset just after the closing quote of the `"id"` member value. */ - afterIdValue: number; - /** Indentation of the `"id"` line (for line-style insertion). */ + kind: "exercise" | "card" | "pair" | "blank" | "option"; + /** Card/exercise: byte offset just after the closing quote of the `"id"` + * member value. Pair/blank/option (no `"id"` member to anchor on): byte + * offset of the object's own closing `}` - same "insert before the + * closing brace" style already used below for a card/exercise whose `id` + * happens to be the last member. */ + anchor: "afterIdValue" | "beforeCloseBrace"; + position: number; + /** Indentation of the anchor line (for line-style insertion; unused for + * `beforeCloseBrace`, which always inserts inline like an id-last-member). */ indent: string; } @@ -83,12 +97,25 @@ interface InsertionTarget { function findTargets(raw: string): InsertionTarget[] { const lesson = JSON.parse(raw) as { cards?: { stable_id?: string }[]; - steps?: { exercise?: { stable_id?: string } }[]; + steps?: { + exercise?: { + stable_id?: string; + pairs?: { stable_id?: string }[]; + blanks?: { stable_id?: string }[]; + options?: { stable_id?: string }[]; + }; + }[]; }; const wantsCard = (lesson.cards ?? []).map((card) => !card.stable_id); const wantsExercise = (lesson.steps ?? []).map((step) => step.exercise ? !step.exercise.stable_id : false, ); + const subFlags = (list?: { stable_id?: string }[]) => (list ?? []).map((entry) => !entry?.stable_id); + const wantsSub = (lesson.steps ?? []).map((step) => ({ + pair: subFlags(step.exercise?.pairs), + blank: subFlags(step.exercise?.blanks), + option: subFlags(step.exercise?.options), + })); const targets: InsertionTarget[] = []; type Frame = { container: "object" | "array"; key: string | number | null; index: number }; @@ -97,12 +124,15 @@ function findTargets(raw: string): InsertionTarget[] { let expectKey = false; let position = 0; + const currentNames = (): (string | number | null)[] => + stack + .filter((frame) => frame.container === "object" || frame.container === "array") + .map((frame) => frame.key); + const pathMatches = (): { kind: "exercise" | "card"; ordinal: number } | null => { // stack shape for a card object: [{obj root}, {key cards -> array}, {array idx}] // for an exercise object: [{obj root}, {key steps}, {array idx}, {key exercise}] - const names = stack - .filter((frame) => frame.container === "object" || frame.container === "array") - .map((frame) => frame.key); + const names = currentNames(); if (names.length === 3 && names[1] === "cards" && typeof names[2] === "number") { return { kind: "card", ordinal: names[2] }; } @@ -117,6 +147,33 @@ function findTargets(raw: string): InsertionTarget[] { return null; }; + const SUB_KEY_TO_KIND = { pairs: "pair", blanks: "blank", options: "option" } as const; + + /** Matches while the scanner is INSIDE a pair/blank/option object, i.e. + * stack shape [{root}, {steps}, {N}, {exercise}, {pairs|blanks|options}, {M}] + * - checked at the object's closing `}`, before it is popped. */ + const subElementPathMatches = (): + | { kind: "pair" | "blank" | "option"; exerciseOrdinal: number; subOrdinal: number } + | null => { + const names = currentNames(); + if ( + names.length === 6 && + names[1] === "steps" && + typeof names[2] === "number" && + names[3] === "exercise" && + typeof names[4] === "string" && + names[4] in SUB_KEY_TO_KIND && + typeof names[5] === "number" + ) { + return { + kind: SUB_KEY_TO_KIND[names[4] as keyof typeof SUB_KEY_TO_KIND], + exerciseOrdinal: names[2], + subOrdinal: names[5], + }; + } + return null; + }; + const readString = (): string => { // position sits ON the opening quote; returns content, leaves position after closing quote. let value = ""; @@ -154,7 +211,7 @@ function findTargets(raw: string): InsertionTarget[] { if (wanted) { const lineStart = raw.lastIndexOf("\n", start) + 1; const indent = /^[ \t]*/.exec(raw.slice(lineStart, start))?.[0] ?? ""; - targets.push({ kind: match.kind, afterIdValue: position, indent }); + targets.push({ kind: match.kind, anchor: "afterIdValue", position, indent }); } } } @@ -174,7 +231,20 @@ function findTargets(raw: string): InsertionTarget[] { } else if (char === "[") { stack.push({ container: "array", key: pendingKey ?? indexOfParent(stack), index: 0 }); pendingKey = null; - } else if (char === "}" || char === "]") { + } else if (char === "}") { + // Checked BEFORE the pop: subElementPathMatches() reads the frame for + // the object that is about to close, which is still on the stack here. + const subMatch = subElementPathMatches(); + if (subMatch && wantsSub[subMatch.exerciseOrdinal]?.[subMatch.kind][subMatch.subOrdinal]) { + // Anchor right after the last member's VALUE, like the id-anchored + // targets do - not at `}` itself, which would leave any whitespace + // between the value and the brace stranded before the inserted comma. + let insertAt = position; + while (insertAt > 0 && /\s/.test(raw.charAt(insertAt - 1))) insertAt -= 1; + targets.push({ kind: subMatch.kind, anchor: "beforeCloseBrace", position: insertAt, indent: "" }); + } + stack.pop(); + } else if (char === "]") { stack.pop(); } else if (char === ",") { const top = stack[stack.length - 1]; @@ -202,13 +272,30 @@ function findTargets(raw: string): InsertionTarget[] { function countEligible(raw: string): number { const lesson = JSON.parse(raw) as { cards?: { stable_id?: string }[]; - steps?: { exercise?: { stable_id?: string } }[]; + steps?: { + exercise?: { + stable_id?: string; + pairs?: { stable_id?: string }[]; + blanks?: { stable_id?: string }[]; + options?: { stable_id?: string }[]; + }; + }[]; }; const cards = (lesson.cards ?? []).filter((card) => !card?.stable_id).length; const exercises = (lesson.steps ?? []).filter( (step) => step?.exercise && !step.exercise.stable_id, ).length; - return cards + exercises; + const countUnminted = (list?: { stable_id?: string }[]) => + (list ?? []).filter((entry) => !entry?.stable_id).length; + const subElements = (lesson.steps ?? []).reduce( + (sum, step) => + sum + + countUnminted(step?.exercise?.pairs) + + countUnminted(step?.exercise?.blanks) + + countUnminted(step?.exercise?.options), + 0, + ); + return cards + exercises + subElements; } /** Strip every `stable_id` member from a parsed structure (for the proof). */ @@ -266,25 +353,33 @@ export function mintStableIds( let newText = ""; let cursor = 0; - for (const target of targets.sort((a, b) => a.afterIdValue - b.afterIdValue)) { - newText += rawJson.slice(cursor, target.afterIdValue); + for (const target of targets.sort((a, b) => a.position - b.position)) { + newText += rawJson.slice(cursor, target.position); const stableId = minter(target.kind); - const behind = rawJson.slice(target.afterIdValue); + if (target.anchor === "beforeCloseBrace") { + // Pair/blank/option: no `"id"` member to anchor on, so `position` is + // the object's own closing `}` and the insertion is always inline, + // exactly like the "id is the last member" case below. + newText += `, "stable_id": "${stableId}"`; + cursor = target.position; + continue; + } + const behind = rawJson.slice(target.position); if (/^,\s*\n/.test(behind)) { // line style: keep the id line as-is and add one full stable_id line // beneath it, indented like the id line. The original comma is consumed // and re-emitted before the insertion so the added line ends with the // comma the following member needs. newText += `,\n${target.indent}"stable_id": "${stableId}",`; - cursor = target.afterIdValue + 1; + cursor = target.position + 1; } else if (behind.startsWith(",")) { // inline style: `{ "id": "x", ... }` gains `, "stable_id": "..."` in place. newText += `, "stable_id": "${stableId}"`; - cursor = target.afterIdValue; + cursor = target.position; } else { // id is the last member: append before the closing brace. newText += `, "stable_id": "${stableId}"`; - cursor = target.afterIdValue; + cursor = target.position; } } newText += rawJson.slice(cursor); diff --git a/src/schema-baseline.test.ts b/src/schema-baseline.test.ts index 5abeca7..88255e8 100644 --- a/src/schema-baseline.test.ts +++ b/src/schema-baseline.test.ts @@ -17,7 +17,7 @@ const NEW_ID_BASE = "https://astrapi69.github.io/learn-content-engine/schema"; /** The current, deliberately-set schema version. Bumping it is a conscious * release decision (new exercise type = minor); update it together with the * frozen baseline in the same commit. */ -const EXPECTED_SCHEMA_VERSION = "1.11"; +const EXPECTED_SCHEMA_VERSION = "1.12"; const readText = (relativePath: string): string => readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); diff --git a/src/stable-id-stability.test.ts b/src/stable-id-stability.test.ts index 2e65e22..1538838 100644 --- a/src/stable-id-stability.test.ts +++ b/src/stable-id-stability.test.ts @@ -20,7 +20,13 @@ import { */ const inventory = ( - entries: [set: string, stableId: string, kind: "exercise" | "card", type: string, lesson: string][], + entries: [ + set: string, + stableId: string, + kind: "exercise" | "card" | "pair" | "blank" | "option", + type: string, + lesson: string, + ][], lessons: [set: string, filename: string][] = [], ): StableIdInventory => ({ elements: entries.map(([set, stableId, kind, type, lesson]) => ({ @@ -173,6 +179,56 @@ describe("buildStableIdInventory", () => { expect(built.elements).toEqual([]); expect(built.lessons).toHaveLength(1); }); + + it("collects pair, blank and option stable_ids too (engine#91 Phase 2)", () => { + const withSubElements = { + id: "l1", + title: "L", + steps: [ + { + id: "s1", + type: "exercise", + exercise: { + id: "e1", + type: "matching", + prompt: "p", + pairs: [{ left: "a", right: "b", stable_id: "pair-aaaa0001" }], + blanks: [{ accept: ["a"], stable_id: "blank-aaaa0001" }], + options: [{ text: "a", stable_id: "opt-aaaa0001" }], + }, + }, + ], + }; + const built = buildStableIdInventory([{ set: "sets/de/a", filename: "01.json", lesson: withSubElements }]); + const kinds = built.elements.map((element) => element.kind).sort(); + expect(kinds).toEqual(["blank", "option", "pair"]); + expect(built.elements.find((element) => element.kind === "pair")?.stableId).toBe("pair-aaaa0001"); + expect(built.elements.find((element) => element.kind === "blank")?.stableId).toBe("blank-aaaa0001"); + expect(built.elements.find((element) => element.kind === "option")?.stableId).toBe("opt-aaaa0001"); + }); +}); + +describe("compareStableIdInventories — pair/blank/option kinds share V1-V4 generically (engine#91 Phase 2)", () => { + const pairInventory = inventory([["sets/de/a", "pair-aaaa0001", "pair", "matching", "01.json"]]); + + it("V1: a published pair stable_id disappearing undeclared is a violation", () => { + const head = inventory([], []); + const result = compareStableIdInventories(pairInventory, head); + expect(result.violations.map((violation) => violation.rule)).toEqual(["V1"]); + }); + + it("V3: the same id moving from pair to blank kind is a violation (reuse)", () => { + const head = inventory([["sets/de/a", "pair-aaaa0001", "blank", "matching", "01.json"]]); + expect(compareStableIdInventories(pairInventory, head).violations.map((v) => v.rule)).toContain("V3"); + }); + + it("V2: a duplicate shared between a pair and a blank in the same set is a violation", () => { + const head = inventory([ + ["sets/de/a", "dup-aaaa0001", "pair", "matching", "01.json"], + ["sets/de/a", "dup-aaaa0001", "blank", "matching", "01.json"], + ]); + expect(compareStableIdInventories(inventory([]), head).violations.map((v) => v.rule)).toContain("V2"); + }); }); /** diff --git a/src/stable-id-stability.ts b/src/stable-id-stability.ts index 3a2515f..f82ad3e 100644 --- a/src/stable-id-stability.ts +++ b/src/stable-id-stability.ts @@ -21,7 +21,7 @@ export interface StableIdElement { /** Repo-relative set directory, e.g. ``sets/de/psych-intro``. */ set: string; stableId: string; - kind: "exercise" | "card"; + kind: "exercise" | "card" | "pair" | "blank" | "option"; /** Exercise type, or ``"card"`` for cards (part of the reuse check). */ type: string; /** Lesson FILE name; the filename is the lesson's identity. */ @@ -75,7 +75,15 @@ export function buildStableIdInventory(lessons: LessonInput[]): StableIdInventor files.push({ set, filename }); const lesson = (raw ?? {}) as { cards?: { stable_id?: unknown }[]; - steps?: { exercise?: { type?: unknown; stable_id?: unknown } }[]; + steps?: { + exercise?: { + type?: unknown; + stable_id?: unknown; + pairs?: { stable_id?: unknown }[]; + blanks?: { stable_id?: unknown }[]; + options?: { stable_id?: unknown }[]; + }; + }[]; }; for (const card of lesson.cards ?? []) { if (typeof card?.stable_id === "string" && card.stable_id !== "") { @@ -84,14 +92,22 @@ export function buildStableIdInventory(lessons: LessonInput[]): StableIdInventor } for (const step of lesson.steps ?? []) { const exercise = step?.exercise; - if (exercise && typeof exercise.stable_id === "string" && exercise.stable_id !== "") { - elements.push({ - set, - stableId: exercise.stable_id, - kind: "exercise", - type: typeof exercise.type === "string" ? exercise.type : "?", - lesson: filename, - }); + if (!exercise) continue; + const type = typeof exercise.type === "string" ? exercise.type : "?"; + if (typeof exercise.stable_id === "string" && exercise.stable_id !== "") { + elements.push({ set, stableId: exercise.stable_id, kind: "exercise", type, lesson: filename }); + } + const subElements: ["pair" | "blank" | "option", { stable_id?: unknown }[] | undefined][] = [ + ["pair", exercise.pairs], + ["blank", exercise.blanks], + ["option", exercise.options], + ]; + for (const [kind, list] of subElements) { + for (const entry of list ?? []) { + if (typeof entry?.stable_id === "string" && entry.stable_id !== "") { + elements.push({ set, stableId: entry.stable_id, kind, type, lesson: filename }); + } + } } } } diff --git a/src/stable-ids.test.ts b/src/stable-ids.test.ts index 1b553cf..75fb559 100644 --- a/src/stable-ids.test.ts +++ b/src/stable-ids.test.ts @@ -62,3 +62,66 @@ describe("collectStableIds", () => { expect(report.duplicates).toHaveLength(1); }); }); + +/** A lesson with one exercise carrying sub-elements of every kind, each + * taking an optional stable_id - the fixture shape engine#91 Phase 2 adds. */ +const lessonWithSubElements = ( + pairIds: (string | undefined)[] = [], + blankIds: (string | undefined)[] = [], + optionIds: (string | undefined)[] = [], +) => ({ + id: "l1", + title: "l1", + steps: [ + { + id: "s0", + type: "exercise", + exercise: { + id: "e0", + type: "matching", + prompt: "p", + pairs: pairIds.map((sid, index) => ({ + left: `l${index}`, + right: `r${index}`, + ...(sid ? { stable_id: sid } : {}), + })), + blanks: blankIds.map((sid, index) => ({ + accept: [`a${index}`], + ...(sid ? { stable_id: sid } : {}), + })), + options: optionIds.map((sid, index) => ({ + text: `t${index}`, + ...(sid ? { stable_id: sid } : {}), + })), + }, + }, + ], + cards: [], +}); + +describe("collectStableIds — pairs/blanks/options (engine#91 Phase 2)", () => { + it("counts pair, blank and option stable_ids alongside exercises and cards", () => { + const report = collectStableIds([ + lessonWithSubElements(["pair-aaaa0001"], ["blank-aaaa0001"], ["opt-aaaa0001"]), + ]); + expect(report.total).toBe(3); + expect(report.duplicates).toEqual([]); + }); + + it("reports a duplicate stable_id shared between a pair and a blank (one shared namespace)", () => { + const report = collectStableIds([lessonWithSubElements(["dup-aaaa0001"], ["dup-aaaa0001"])]); + expect(report.duplicates).toHaveLength(1); + expect(report.duplicates[0]?.locations).toHaveLength(2); + }); + + it("ignores pairs/blanks/options without a stable_id (optional field)", () => { + const report = collectStableIds([lessonWithSubElements([undefined], [undefined], [undefined])]); + expect(report.total).toBe(0); + expect(report.duplicates).toEqual([]); + }); + + it("boundary: the same option stable_id twice within one exercise is a duplicate", () => { + const report = collectStableIds([lessonWithSubElements([], [], ["opt-aaaa0001", "opt-aaaa0001"])]); + expect(report.duplicates).toHaveLength(1); + }); +}); diff --git a/src/stable-ids.ts b/src/stable-ids.ts index df10229..82b289b 100644 --- a/src/stable-ids.ts +++ b/src/stable-ids.ts @@ -10,7 +10,7 @@ interface StableIdLocation { lessonId: string; - kind: "exercise" | "card"; + kind: "exercise" | "card" | "pair" | "blank" | "option"; elementId: string; } @@ -63,13 +63,35 @@ export function collectStableIds(lessons: unknown[]): StableIdReport { } const steps = Array.isArray(lesson.steps) ? lesson.steps : []; for (const stepInput of steps) { - const step = (stepInput ?? {}) as { exercise?: { id?: unknown; stable_id?: unknown } }; + const step = (stepInput ?? {}) as { + exercise?: { + id?: unknown; + stable_id?: unknown; + pairs?: unknown; + blanks?: unknown; + options?: unknown; + }; + }; if (!step.exercise) continue; - record(step.exercise.stable_id, { - lessonId, - kind: "exercise", - elementId: typeof step.exercise.id === "string" ? step.exercise.id : "?", - }); + const exerciseId = typeof step.exercise.id === "string" ? step.exercise.id : "?"; + record(step.exercise.stable_id, { lessonId, kind: "exercise", elementId: exerciseId }); + + const subElements: ["pair" | "blank" | "option", unknown][] = [ + ["pair", step.exercise.pairs], + ["blank", step.exercise.blanks], + ["option", step.exercise.options], + ]; + for (const [kind, list] of subElements) { + if (!Array.isArray(list)) continue; + list.forEach((entryInput: unknown, index: number) => { + const entry = (entryInput ?? {}) as { stable_id?: unknown }; + record(entry.stable_id, { + lessonId, + kind, + elementId: `${exerciseId}.${kind}s[${index}]`, + }); + }); + } } } diff --git a/src/types/lesson-schema.generated.ts b/src/types/lesson-schema.generated.ts index 1445cc7..9aa213f 100644 --- a/src/types/lesson-schema.generated.ts +++ b/src/types/lesson-schema.generated.ts @@ -191,6 +191,10 @@ export type Hint1 = string | null; * Optional placeholder text shown inside the input (``type`` mode) before the user starts typing. */ export type Placeholder = string | null; +/** + * 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. + */ +export type StableId1 = SlugId | null; /** * Cards this exercise drills. SRS feedback after a wrong answer schedules these cards for review. */ @@ -226,7 +230,7 @@ export type SlugId2 = string; /** * engine#90 - schema 1.9 (additive). Author-owned, version-stable identity for progress/SRS joins: once published it NEVER changes, set-wide unique (cross-lesson uniqueness is checked by the repo gate via collectStableIds; the schema sees one document). Opaque mint-once value (lowercase slug, 8-64 chars), NOT derived from content, so answer-text fixes do not move it. Optional: pre-1.9 content validates unchanged. SCOPE: this closes orphaning by slug rename or position shift on the exercise/card level; it does NOT close the element-level case (an answer correction inside a surviving exercise still moves the content-derived element key, engine#91). COMPAT NOTE (engine#105): this pattern predates $defs/SlugId and is deliberately NOT tightened - stable_ids are immutable once published, so the underscore stays allowed here even though SlugId forbids it. For NEW mints prefer the stricter SlugId shape (hyphens only); the bundled mint-stable-ids minter already emits only [a-z0-9-]. */ -export type StableId1 = string | null; +export type StableId2 = string | null; /** * PICTURE_CHOICE: list of {src, label, is_correct?} options. Exactly one entry MUST include 'is_correct': 'true'. ``src`` is a relative path inside the set's ``assets/`` directory. */ @@ -255,6 +259,10 @@ export type Options = MultipleChoiceOption[] | null; * Set to true on the correct option(s). Exactly one with ``multiple: false``; at least one with ``multiple: true``. */ export type Correct = boolean; +/** + * 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. + */ +export type StableId3 = SlugId | null; /** * The option text shown to the learner. Unique within the exercise - the text IS the option, so a duplicate would be ambiguous. */ @@ -271,6 +279,10 @@ export type Left = string; * The right-column item this pairs with. */ export type Right = string; +/** + * 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. + */ +export type StableId4 = SlugId | null; /** * The question text shown to the learner. */ @@ -517,7 +529,7 @@ export interface Exercise { from_cards?: FromCards; hint?: Hint2; id: SlugId2; - stable_id?: StableId1; + stable_id?: StableId2; images?: Images; multiple?: Multiple; options?: Options; @@ -550,6 +562,7 @@ export interface ClozeBlank { accept: Accept1; hint?: Hint1; placeholder?: Placeholder; + stable_id?: StableId1; } /** * One image option in a PICTURE_CHOICE exercise. @@ -582,6 +595,7 @@ export interface PictureImage { */ export interface MultipleChoiceOption { correct?: Correct; + stable_id?: StableId3; text: Text; } /** @@ -598,4 +612,5 @@ export interface MultipleChoiceOption { export interface Pair { left: Left; right: Right; + stable_id?: StableId4; } diff --git a/src/validate.test.ts b/src/validate.test.ts index 259c05f..d423d3e 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -373,6 +373,58 @@ describe("schema 1.9 — stable_id on exercises and cards (engine#90)", () => { }); }); +describe("schema 1.12 — stable_id on pairs/blanks/options (engine#91 Phase 2)", () => { + const matchingWith = (pairStableId?: string) => { + const lesson = clone(conf("matching")); + const pairs = ((lesson.steps as JsonObject[])[0]!.exercise as JsonObject).pairs as JsonObject[]; + if (pairStableId) pairs[0]!.stable_id = pairStableId; + return lesson; + }; + + const clozeWith = (blankStableId?: string) => { + const lesson = clone(conf("cloze_type")); + const blanks = ((lesson.steps as JsonObject[])[0]!.exercise as JsonObject).blanks as JsonObject[]; + if (blankStableId !== undefined) blanks[0]!.stable_id = blankStableId; + return lesson; + }; + + const multipleChoiceWith = (optionStableId?: string) => { + const lesson = clone(conf("multiple_choice_single")); + const options = ((lesson.steps as JsonObject[])[0]!.exercise as JsonObject).options as JsonObject[]; + if (optionStableId) options[0]!.stable_id = optionStableId; + return lesson; + }; + + it("accepts a well-formed stable_id on a matching pair, a cloze blank and a multiple_choice option", () => { + expect(validateLesson(matchingWith("pair-m5k2p8qa")).valid).toBe(true); + expect(validateLesson(clozeWith("blank-m5k2p8qa")).valid).toBe(true); + expect(validateLesson(multipleChoiceWith("opt-m5k2p8qa")).valid).toBe(true); + }); + + it("stays optional: pairs/blanks/options without stable_id validate unchanged", () => { + expect(validateLesson(matchingWith()).valid).toBe(true); + expect(validateLesson(clozeWith()).valid).toBe(true); + expect(validateLesson(multipleChoiceWith()).valid).toBe(true); + }); + + it("rejects a malformed sub-element stable_id (underscore - the strict SlugId shape, hyphens only)", () => { + expect(validateLesson(matchingWith("pair_m5k2p8qa")).valid).toBe(false); + }); + + it("boundary: rejects an empty-string sub-element stable_id", () => { + expect(validateLesson(clozeWith("")).valid).toBe(false); + }); + + it("rejects a duplicate stable_id shared between a pair and a blank in one lesson (E-STABLE-ID-DUP)", () => { + const lesson = matchingWith("dup-m5k2p8qa"); + const pairs = ((lesson.steps as JsonObject[])[0]!.exercise as JsonObject).pairs as JsonObject[]; + pairs[1]!.stable_id = "dup-m5k2p8qa"; + const checked = validateLesson(lesson); + expect(checked.valid).toBe(false); + expect(checked.errors.some((issue) => issue.id === "E-STABLE-ID-DUP")).toBe(true); + }); +}); + describe("schema 1.9 — attribution and review_status on the set entry (engine#90/#94)", () => { const manifestWith = (setExtras: Record) => ({ schema_version: "1.2",