@intellectif/lk-core 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/dist/ai-check.cjs +2 -2
  4. package/dist/ai-check.d.cts +1 -1
  5. package/dist/ai-check.d.ts +1 -1
  6. package/dist/ai-check.js +2 -2
  7. package/dist/{ai-Cu17iVHa.d.cts → ai-mC-5XhBc.d.cts} +212 -5
  8. package/dist/ai-mC-5XhBc.d.cts.map +1 -0
  9. package/dist/{ai-Cu17iVHa.d.ts → ai-mC-5XhBc.d.ts} +212 -5
  10. package/dist/ai-mC-5XhBc.d.ts.map +1 -0
  11. package/dist/{ai-writing-gO4GRscd.cjs → ai-writing-Bm2TUWQ-.cjs} +24 -5
  12. package/dist/ai-writing-Bm2TUWQ-.cjs.map +1 -0
  13. package/dist/{ai-writing-BgeeCOl7.js → ai-writing-D3oREPsp.js} +24 -5
  14. package/dist/ai-writing-D3oREPsp.js.map +1 -0
  15. package/dist/{index-BTZWUwOe.d.ts → index-2UQ5v6NZ.d.ts} +2 -2
  16. package/dist/{index-BTZWUwOe.d.ts.map → index-2UQ5v6NZ.d.ts.map} +1 -1
  17. package/dist/{index-B1zEN1Ur.d.ts → index-BudO4ES2.d.ts} +2 -2
  18. package/dist/{index-B1zEN1Ur.d.ts.map → index-BudO4ES2.d.ts.map} +1 -1
  19. package/dist/{index-BfUyl6ml.d.ts → index-CFKxHXAY.d.ts} +33 -4
  20. package/dist/index-CFKxHXAY.d.ts.map +1 -0
  21. package/dist/{index-DjZ1vIVg.d.cts → index-CagMlF4b.d.cts} +33 -4
  22. package/dist/index-CagMlF4b.d.cts.map +1 -0
  23. package/dist/{index-CpamCGyZ.d.cts → index-CqmNcMFM.d.cts} +2 -2
  24. package/dist/{index-CpamCGyZ.d.cts.map → index-CqmNcMFM.d.cts.map} +1 -1
  25. package/dist/{index-DYnacygI.d.cts → index-MgPVCiSJ.d.cts} +2 -2
  26. package/dist/{index-DYnacygI.d.cts.map → index-MgPVCiSJ.d.cts.map} +1 -1
  27. package/dist/index.cjs +17 -7
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +90 -13
  30. package/dist/index.d.cts.map +1 -1
  31. package/dist/index.d.ts +90 -13
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +12 -8
  34. package/dist/index.js.map +1 -1
  35. package/dist/{item-group-CnlBLM9Z.js → item-group-BIl1ZJHg.js} +55 -22
  36. package/dist/item-group-BIl1ZJHg.js.map +1 -0
  37. package/dist/{item-group-CRZNJ08j.cjs → item-group-DOk562oI.cjs} +55 -22
  38. package/dist/item-group-DOk562oI.cjs.map +1 -0
  39. package/dist/{registry-BClnM5V3.js → registry-CroAI5nc.js} +898 -8
  40. package/dist/registry-CroAI5nc.js.map +1 -0
  41. package/dist/{registry-DgLcT4rZ.cjs → registry-Dw19lNo2.cjs} +933 -7
  42. package/dist/registry-Dw19lNo2.cjs.map +1 -0
  43. package/dist/{schemas-BA8Y8z-S.cjs → schemas-CEv2otNw.cjs} +3 -3
  44. package/dist/{schemas-BA8Y8z-S.cjs.map → schemas-CEv2otNw.cjs.map} +1 -1
  45. package/dist/{schemas-VZnN68qG.js → schemas-COw7muXH.js} +3 -3
  46. package/dist/{schemas-VZnN68qG.js.map → schemas-COw7muXH.js.map} +1 -1
  47. package/dist/schemas.cjs +3 -3
  48. package/dist/schemas.d.cts +2 -2
  49. package/dist/schemas.d.ts +2 -2
  50. package/dist/schemas.js +3 -3
  51. package/dist/{scoring-BxDiwN7I.js → scoring-BxvOhHE0.js} +2 -2
  52. package/dist/{scoring-BxDiwN7I.js.map → scoring-BxvOhHE0.js.map} +1 -1
  53. package/dist/{scoring-Cfu_2qQ1.cjs → scoring-EpNBJ8Fs.cjs} +2 -2
  54. package/dist/{scoring-Cfu_2qQ1.cjs.map → scoring-EpNBJ8Fs.cjs.map} +1 -1
  55. package/dist/scoring.cjs +2 -2
  56. package/dist/scoring.d.cts +2 -2
  57. package/dist/scoring.d.ts +2 -2
  58. package/dist/scoring.js +2 -2
  59. package/dist/{xapi-BhROEuZQ.cjs → xapi-C-cQgWRX.cjs} +2 -2
  60. package/dist/{xapi-BhROEuZQ.cjs.map → xapi-C-cQgWRX.cjs.map} +1 -1
  61. package/dist/{xapi-BitXmAYB.js → xapi-DHHm7Os9.js} +2 -2
  62. package/dist/{xapi-BitXmAYB.js.map → xapi-DHHm7Os9.js.map} +1 -1
  63. package/dist/xapi.cjs +1 -1
  64. package/dist/xapi.d.cts +1 -1
  65. package/dist/xapi.d.ts +1 -1
  66. package/dist/xapi.js +1 -1
  67. package/package.json +1 -1
  68. package/vectors/scoring.json +1767 -1
  69. package/vectors/validation.json +1458 -1
  70. package/dist/ai-Cu17iVHa.d.cts.map +0 -1
  71. package/dist/ai-Cu17iVHa.d.ts.map +0 -1
  72. package/dist/ai-writing-BgeeCOl7.js.map +0 -1
  73. package/dist/ai-writing-gO4GRscd.cjs.map +0 -1
  74. package/dist/index-BfUyl6ml.d.ts.map +0 -1
  75. package/dist/index-DjZ1vIVg.d.cts.map +0 -1
  76. package/dist/item-group-CRZNJ08j.cjs.map +0 -1
  77. package/dist/item-group-CnlBLM9Z.js.map +0 -1
  78. package/dist/registry-BClnM5V3.js.map +0 -1
  79. package/dist/registry-DgLcT4rZ.cjs.map +0 -1
@@ -442,7 +442,9 @@ const BUILT_IN_ACTIVITY_TYPES = [
442
442
  "gap-select",
443
443
  "dictation",
444
444
  "read-aloud",
445
- "speaking-response"
445
+ "speaking-response",
446
+ "matching",
447
+ "mark-the-words"
446
448
  ];
447
449
  /** Whether `type` names an activity type the SDK ships. */
448
450
  function isBuiltInActivityType(type) {
@@ -1053,7 +1055,7 @@ function containsUnspacedScript(text) {
1053
1055
  }
1054
1056
  /** What is drawn onto the character before it: a match never ends just before one. */
1055
1057
  const EXTENDING_RE = new RegExp(`^[\\p{M}${JOINERS}${TAGS}]$`, "u");
1056
- const WORD = 1;
1058
+ const WORD$1 = 1;
1057
1059
  const UNSPACED = 2;
1058
1060
  const EXTENDING = 4;
1059
1061
  const KNOWN = 8;
@@ -1067,7 +1069,7 @@ const KNOWN = 8;
1067
1069
  */
1068
1070
  const FLAGS = [];
1069
1071
  function flagsOfCharacter(character) {
1070
- return KNOWN | (WORD_RE.test(character) ? WORD : 0) | (UNSPACED_CHARACTER_RE.test(character) ? UNSPACED : 0) | (EXTENDING_RE.test(character) ? EXTENDING : 0);
1072
+ return KNOWN | (WORD_RE.test(character) ? WORD$1 : 0) | (UNSPACED_CHARACTER_RE.test(character) ? UNSPACED : 0) | (EXTENDING_RE.test(character) ? EXTENDING : 0);
1071
1073
  }
1072
1074
  /** The flags of the code point that starts at UTF-16 index `index` of `text`. */
1073
1075
  function flagsAt(text, index) {
@@ -1297,12 +1299,12 @@ function isAsciiWordUnit(unit) {
1297
1299
  }
1298
1300
  function isWordAt(text, index) {
1299
1301
  const unit = text.charCodeAt(index);
1300
- return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & WORD) !== 0;
1302
+ return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & WORD$1) !== 0;
1301
1303
  }
1302
1304
  /** A word character of a script that separates its words with spaces. */
1303
1305
  function isSpacedWordAt(text, index) {
1304
1306
  const unit = text.charCodeAt(index);
1305
- return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & 3) === WORD;
1307
+ return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & 3) === WORD$1;
1306
1308
  }
1307
1309
  function isExtendingAt(text, index) {
1308
1310
  return text.charCodeAt(index) >= 128 && (flagsAt(text, index) & EXTENDING) !== 0;
@@ -2327,6 +2329,29 @@ const DRAFT_ISSUE_SEVERITY = {
2327
2329
  gs_choice_text_required: "incomplete",
2328
2330
  gs_correct_choice_required: "incomplete",
2329
2331
  gs_correct_choice_unknown: "invalid",
2332
+ mt_prompts_required: "incomplete",
2333
+ mt_prompt_id_required: "invalid",
2334
+ mt_prompt_id_duplicate: "invalid",
2335
+ mt_prompt_text_required: "incomplete",
2336
+ mt_options_too_few: "incomplete",
2337
+ mt_option_id_required: "invalid",
2338
+ mt_option_id_duplicate: "invalid",
2339
+ mt_option_text_required: "incomplete",
2340
+ mt_option_label_required: "incomplete",
2341
+ mt_option_label_duplicate: "invalid",
2342
+ mt_option_use_invalid: "invalid",
2343
+ mt_correct_option_required: "incomplete",
2344
+ mt_correct_option_unknown: "invalid",
2345
+ mt_option_reused: "invalid",
2346
+ mw_text_required: "incomplete",
2347
+ mw_text_too_long: "invalid",
2348
+ mw_targets_required: "incomplete",
2349
+ mw_target_text_required: "incomplete",
2350
+ mw_target_no_words: "invalid",
2351
+ mw_target_not_found: "invalid",
2352
+ mw_target_ambiguous: "incomplete",
2353
+ mw_target_occurrence_invalid: "invalid",
2354
+ mw_target_overlap: "invalid",
2330
2355
  wr_prompt_required: "incomplete",
2331
2356
  wr_min_words_required: "incomplete",
2332
2357
  wr_min_words_invalid: "invalid",
@@ -3086,6 +3111,13 @@ const ITEM_FINDING_SEVERITY = {
3086
3111
  gs_choices_duplicate: "warning",
3087
3112
  gs_bank_no_distractor: "warning",
3088
3113
  gs_answer_in_passage: "warning",
3114
+ mt_title_reveals_answer: "warning",
3115
+ mt_options_duplicate: "warning",
3116
+ mt_answer_in_prompt: "warning",
3117
+ mt_no_distractor: "warning",
3118
+ mw_title_reveals_answer: "warning",
3119
+ mw_targets_dense: "warning",
3120
+ mw_target_elsewhere: "advice",
3089
3121
  wr_criterion_name_duplicate: "warning",
3090
3122
  wr_levels_unreadable: "warning",
3091
3123
  ra_text_long_for_time: "warning",
@@ -3356,6 +3388,40 @@ function critiqueGapSelect(draft) {
3356
3388
  });
3357
3389
  return findings;
3358
3390
  }
3391
+ /** Whether `text` writes out `answer` as whole words — an answer of one short word never counts. */
3392
+ function writesOut$1(text, answer) {
3393
+ const needle = fold(answer);
3394
+ return needle.length > 0 && !isShort(needle) && containsWords(fold(text), needle);
3395
+ }
3396
+ function critiqueMatching(draft) {
3397
+ const findings = [];
3398
+ const prompts = listOf(draft.prompts);
3399
+ const options = listOf(draft.options);
3400
+ const optionText = (option) => isRecord(option) ? written(option.text) : void 0;
3401
+ const answerOf = (prompt) => isRecord(prompt) ? optionText(options.find((option) => isRecord(option) && option.id === prompt.correctOptionId)) : void 0;
3402
+ const title = written(draft.title);
3403
+ if (title !== void 0 && prompts.some((prompt) => {
3404
+ const answer = answerOf(prompt);
3405
+ return answer !== void 0 && writesOut$1(title, answer);
3406
+ })) findings.push(finding("mt_title_reveals_answer", ["title"], "The title, which the learner sees, contains the answer to a prompt."));
3407
+ for (const { first, second } of duplicates(options, optionText)) findings.push(finding("mt_options_duplicate", [
3408
+ "options",
3409
+ second,
3410
+ "text"
3411
+ ], `Options ${ordinal(first)} and ${ordinal(second)} read the same, so a learner cannot tell them apart.`));
3412
+ prompts.forEach((prompt, index) => {
3413
+ const text = isRecord(prompt) ? written(prompt.text) : void 0;
3414
+ const answer = answerOf(prompt);
3415
+ if (text !== void 0 && answer !== void 0 && writesOut$1(text, answer)) findings.push(finding("mt_answer_in_prompt", [
3416
+ "prompts",
3417
+ index,
3418
+ "text"
3419
+ ], `Prompt ${ordinal(index)} contains "${answer}", the option that answers it, word for word.`));
3420
+ });
3421
+ const promptCount = prompts.filter(isRecord).length;
3422
+ if (draft.optionUse === "once" && promptCount >= 2 && options.length <= promptCount) findings.push(finding("mt_no_distractor", ["options"], `Each option is used once, and there are ${options.length} options for ${promptCount} prompts, so the last prompt is answered by elimination. Add an option that answers no prompt.`));
3423
+ return findings;
3424
+ }
3359
3425
  /** Why a criterion's levels cannot be graded on, said to the author. */
3360
3426
  const LEVELS_PROBLEM = {
3361
3427
  "not-a-list": (criterion) => `Criterion ${criterion}'s levels are not a list`,
@@ -3745,6 +3811,514 @@ function pairsOneToOne(placeholders, gaps) {
3745
3811
  if (new Set(ids).size !== ids.length || placeholders.size !== ids.length) return false;
3746
3812
  return ids.every((id) => typeof id === "string" && placeholders.get(id) === 1);
3747
3813
  }
3814
+ // src/mark-the-words.ts
3815
+ /**
3816
+ * The words of a mark-the-words text, and where an author's targets fall
3817
+ * among them. A leaf module: the schema, the scorer, the draft checks and the
3818
+ * component all read it, and it imports nothing.
3819
+ *
3820
+ * A learner marks words, never characters, and a response records a word by
3821
+ * its position — so how a text is cut into words is part of every stored
3822
+ * answer. The cut is the SDK's own (`lk-words@1`), not `Intl.Segmenter`,
3823
+ * whose word boundaries change with the ICU a runtime ships: the same text
3824
+ * must be the same words in every browser and on every server, now and when
3825
+ * the answer is graded again later.
3826
+ */
3827
+ /** The name of the word cut. A change to it is a major: stored answers name words by position. */
3828
+ const MARK_THE_WORDS_TOKENIZER = "lk-words@1";
3829
+ /** The most words a mark-the-words text may have. */
3830
+ const MARK_THE_WORDS_MAX_WORDS = 1500;
3831
+ /**
3832
+ * A word: a run of letters, marks and digits, which may hold an apostrophe or
3833
+ * a hyphen between two such runs — "don't", "well-known", "1960s". Punctuation
3834
+ * and spaces are never part of one and can never be marked.
3835
+ */
3836
+ const WORD = /[\p{L}\p{M}\p{N}]+(?:['’ʼ\-‐][\p{L}\p{M}\p{N}]+)*/gu;
3837
+ /** Every markable word of `text`, in order, cut by `lk-words@1`. */
3838
+ function markableWords(text) {
3839
+ const words = [];
3840
+ for (const match of text.matchAll(WORD)) words.push({
3841
+ index: words.length,
3842
+ text: match[0],
3843
+ start: match.index,
3844
+ end: match.index + match[0].length
3845
+ });
3846
+ return words;
3847
+ }
3848
+ /** A word as compared with a target's: composed, and with one apostrophe and one hyphen. */
3849
+ function wordKey(word) {
3850
+ return word.normalize("NFC").replace(/[’ʼ]/g, "'").replace(/‐/g, "-");
3851
+ }
3852
+ /** Every place `needle` appears in `words`, as the position of its first word. */
3853
+ function placesOf(words, needle) {
3854
+ const places = [];
3855
+ for (let at = 0; at + needle.length <= words.length; at += 1) if (needle.every((word, offset) => words[at + offset] === word)) places.push(at);
3856
+ return places;
3857
+ }
3858
+ /**
3859
+ * Where a target falls among `words`. Its text must be whole words of the text,
3860
+ * in order — case, accents and punctuation as written, apart from the kind of
3861
+ * apostrophe — and must say which appearance it means when there are several.
3862
+ */
3863
+ function resolveMarkTarget(words, target) {
3864
+ const needle = markableWords(typeof target.text === "string" ? target.text : "").map((word) => wordKey(word.text));
3865
+ if (needle.length === 0) return {
3866
+ ok: false,
3867
+ problem: "no-words",
3868
+ found: 0
3869
+ };
3870
+ const places = placesOf(words.map((word) => wordKey(word.text)), needle);
3871
+ if (places.length === 0) return {
3872
+ ok: false,
3873
+ problem: "not-found",
3874
+ found: 0
3875
+ };
3876
+ const occurrence = target.occurrence;
3877
+ let at;
3878
+ if (occurrence === void 0) {
3879
+ if (places.length > 1) return {
3880
+ ok: false,
3881
+ problem: "ambiguous",
3882
+ found: places.length
3883
+ };
3884
+ at = places[0];
3885
+ } else {
3886
+ at = typeof occurrence === "number" && Number.isInteger(occurrence) && occurrence >= 1 ? places[occurrence - 1] : void 0;
3887
+ if (at === void 0) return {
3888
+ ok: false,
3889
+ problem: "no-such-occurrence",
3890
+ found: places.length
3891
+ };
3892
+ }
3893
+ return {
3894
+ ok: true,
3895
+ indices: needle.map((_, offset) => at + offset)
3896
+ };
3897
+ }
3898
+ /**
3899
+ * The word positions of each target of an item, in target order; a target
3900
+ * that falls nowhere has none. Read defensively: the scorer is reachable with
3901
+ * content an older build stored.
3902
+ */
3903
+ function markTargetIndices(data) {
3904
+ const words = markableWords(typeof data.text === "string" ? data.text : "");
3905
+ return (Array.isArray(data.targets) ? data.targets : []).map((target) => {
3906
+ if (typeof target !== "object" || target === null) return [];
3907
+ const found = resolveMarkTarget(words, target);
3908
+ return found.ok ? found.indices : [];
3909
+ });
3910
+ }
3911
+ /**
3912
+ * A text and its targets from markup that stars each target, as an H5P "Mark
3913
+ * the Words" text does: `I *went* home and *had gone* to bed.` A `\*` is a
3914
+ * literal star. Each target names its appearance only where its words appear
3915
+ * more than once in the text.
3916
+ *
3917
+ * @throws Error when a star is left open.
3918
+ */
3919
+ function markTheWordsFromMarkup(markup) {
3920
+ let text = "";
3921
+ const spans = [];
3922
+ let open = null;
3923
+ for (let at = 0; at < markup.length; at += 1) {
3924
+ const char = markup[at];
3925
+ if (char === "\\" && markup[at + 1] === "*") {
3926
+ text += "*";
3927
+ at += 1;
3928
+ } else if (char === "*") {
3929
+ if (open === null) open = text.length;
3930
+ else {
3931
+ spans.push({
3932
+ start: open,
3933
+ end: text.length
3934
+ });
3935
+ open = null;
3936
+ }
3937
+ } else text += char;
3938
+ }
3939
+ if (open !== null) throw new Error("markTheWordsFromMarkup: a * is opened and never closed.");
3940
+ const words = markableWords(text);
3941
+ const keys = words.map((word) => wordKey(word.text));
3942
+ const targets = [];
3943
+ for (const span of spans) {
3944
+ const inside = words.filter((word) => word.start >= span.start && word.end <= span.end);
3945
+ const first = inside[0];
3946
+ if (first === void 0) continue;
3947
+ const quote = text.slice(first.start, inside.at(-1).end);
3948
+ const places = placesOf(keys, inside.map((word) => wordKey(word.text)));
3949
+ targets.push(places.length > 1 ? {
3950
+ text: quote,
3951
+ occurrence: places.indexOf(first.index) + 1
3952
+ } : { text: quote });
3953
+ }
3954
+ return {
3955
+ text,
3956
+ targets
3957
+ };
3958
+ }
3959
+ // src/authoring/mark-the-words.ts
3960
+ /**
3961
+ * Draft support for `mark-the-words`.
3962
+ *
3963
+ * A new draft has an empty text and no targets, and `scoringStrategy` starts
3964
+ * at `all-or-nothing`, as on every built-in. An editor usually lets the author
3965
+ * click the words to mark; `markTheWordsFromMarkup` turns starred text into
3966
+ * the same `text` and `targets`.
3967
+ */
3968
+ const markTheWordsAuthoring = {
3969
+ createDraft: ({ newId }) => ({
3970
+ schemaVersion: "1.0",
3971
+ type: "mark-the-words",
3972
+ id: newId(),
3973
+ title: "",
3974
+ text: "",
3975
+ targets: [],
3976
+ scoringStrategy: "all-or-nothing"
3977
+ }),
3978
+ checkDraft: checkMarkTheWordsDraft,
3979
+ critique: critiqueMarkTheWords
3980
+ };
3981
+ /** The words of a draft's text, or `null` when there is no usable text to resolve targets in. */
3982
+ function wordsOf(draft) {
3983
+ if (typeof draft.text !== "string") return null;
3984
+ const words = markableWords(draft.text);
3985
+ return words.length === 0 || words.length > 1500 ? null : words;
3986
+ }
3987
+ function checkMarkTheWordsDraft(draft) {
3988
+ const issues = checkIdentity(draft, "mark-the-words");
3989
+ issues.push(...checkScoringStrategy(draft));
3990
+ const text = draft.text;
3991
+ if (isUnwritten(text)) issues.push(issue("mw_text_required", ["text"], "Write the text the learner marks words in."));
3992
+ else if (typeof text === "string") {
3993
+ const count = markableWords(text).length;
3994
+ if (count === 0) issues.push(issue("mw_text_required", ["text"], "The text has no word to mark yet. Write it out."));
3995
+ else if (count > 1500) issues.push(issue("mw_text_too_long", ["text"], `The text has ${count} words; at most ${MARK_THE_WORDS_MAX_WORDS} can be marked in. Split it.`));
3996
+ }
3997
+ const targets = isUnset(draft.targets) ? [] : draft.targets;
3998
+ if (Array.isArray(targets)) {
3999
+ if (targets.length === 0) issues.push(issue("mw_targets_required", ["targets"], "Choose at least one word to mark."));
4000
+ issues.push(...checkTargets(targets, wordsOf(draft)));
4001
+ }
4002
+ issues.push(...checkSharedOptional(draft));
4003
+ return issues;
4004
+ }
4005
+ /**
4006
+ * Every target, each at its own path, where the schema reports it. Against
4007
+ * the text only when the text is usable: an unwritten one is already
4008
+ * reported, and every target would only restate that it is not found.
4009
+ */
4010
+ function checkTargets(targets, words) {
4011
+ const issues = [];
4012
+ const owner = /* @__PURE__ */ new Map();
4013
+ for (const [index, target] of targets.entries()) {
4014
+ if (!isRecord(target)) continue;
4015
+ const ordinal = index + 1;
4016
+ const occurrence = target.occurrence;
4017
+ const occurrenceWellFormed = isUnset(occurrence) || typeof occurrence === "number" && Number.isInteger(occurrence) && occurrence >= 1;
4018
+ if (!occurrenceWellFormed) issues.push(issue("mw_target_occurrence_invalid", [
4019
+ "targets",
4020
+ index,
4021
+ "occurrence"
4022
+ ], `Target ${ordinal}'s appearance must be a whole number from 1, or left out.`));
4023
+ if (isUnwritten(target.text)) {
4024
+ issues.push(issue("mw_target_text_required", [
4025
+ "targets",
4026
+ index,
4027
+ "text"
4028
+ ], `Write the word or words target ${ordinal} marks.`));
4029
+ continue;
4030
+ }
4031
+ if (typeof target.text !== "string" || words === null || !occurrenceWellFormed) continue;
4032
+ const found = resolveMarkTarget(words, {
4033
+ text: target.text,
4034
+ ...isUnset(occurrence) ? {} : { occurrence }
4035
+ });
4036
+ if (!found.ok) {
4037
+ issues.push(targetProblem(found.problem, found.found, index, target.text));
4038
+ continue;
4039
+ }
4040
+ if (found.indices.some((word) => owner.has(word))) {
4041
+ issues.push(issue("mw_target_overlap", ["targets", index], `Target ${ordinal} shares a word with target ${owner.get(found.indices.find((word) => owner.has(word))) + 1}. A word belongs to one target at most.`));
4042
+ continue;
4043
+ }
4044
+ for (const word of found.indices) owner.set(word, index);
4045
+ }
4046
+ return issues;
4047
+ }
4048
+ function targetProblem(problem, count, index, text) {
4049
+ const ordinal = index + 1;
4050
+ switch (problem) {
4051
+ case "no-words": return issue("mw_target_no_words", [
4052
+ "targets",
4053
+ index,
4054
+ "text"
4055
+ ], `Target ${ordinal} has no word in it: punctuation alone cannot be marked.`);
4056
+ case "not-found": return issue("mw_target_not_found", [
4057
+ "targets",
4058
+ index,
4059
+ "text"
4060
+ ], `"${text}" is not in the text as whole words. Copy it from the text, with the same capitals and accents.`);
4061
+ case "ambiguous": return issue("mw_target_ambiguous", [
4062
+ "targets",
4063
+ index,
4064
+ "text"
4065
+ ], `"${text}" appears ${count} times in the text. Say which one target ${ordinal} is, with its appearance (1 to ${count}).`);
4066
+ case "no-such-occurrence": return issue("mw_target_occurrence_invalid", [
4067
+ "targets",
4068
+ index,
4069
+ "occurrence"
4070
+ ], `"${text}" appears ${count === 1 ? "once" : `${count} times`} in the text, so it has no such appearance.`);
4071
+ }
4072
+ }
4073
+ /** Whether `text` writes out `answer` as whole words — a short answer never counts. */
4074
+ function writesOut(text, answer) {
4075
+ const needle = fold(answer);
4076
+ return needle.length > 0 && !isShort(needle) && containsWords(fold(text), needle);
4077
+ }
4078
+ function critiqueMarkTheWords(draft) {
4079
+ const findings = [];
4080
+ const words = wordsOf(draft);
4081
+ const targets = Array.isArray(draft.targets) ? draft.targets : [];
4082
+ if (words === null) return findings;
4083
+ const keys = words.map((word) => wordKey(word.text));
4084
+ const resolved = targets.map((target) => {
4085
+ if (!isRecord(target) || typeof target.text !== "string") return null;
4086
+ const found = resolveMarkTarget(words, {
4087
+ text: target.text,
4088
+ ...typeof target.occurrence === "number" ? { occurrence: target.occurrence } : {}
4089
+ });
4090
+ return found.ok ? found.indices : null;
4091
+ });
4092
+ const marked = new Set(resolved.flatMap((indices) => indices ?? []));
4093
+ const title = typeof draft.title === "string" ? draft.title : "";
4094
+ if (resolved.some((indices) => indices !== null && writesOut(title, indices.map((word) => words[word].text).join(" ")))) findings.push(finding("mw_title_reveals_answer", ["title"], "The title, which the learner sees, contains a word to mark."));
4095
+ if (marked.size > 0 && marked.size * 2 > words.length) {
4096
+ const everything = Math.round((2 * marked.size - words.length) / marked.size * 100);
4097
+ findings.push(finding("mw_targets_dense", ["targets"], `${marked.size} of the text's ${words.length} words are to be marked, so a learner who marks every word scores ${everything}%. Mark fewer, or use a longer text.`));
4098
+ }
4099
+ resolved.forEach((indices, index) => {
4100
+ if (indices === null) return;
4101
+ const needle = indices.map((word) => keys[word]);
4102
+ for (let at = 0; at + needle.length <= keys.length; at += 1) if (needle.every((key, offset) => keys[at + offset] === key) && !indices.includes(at) && !marked.has(at)) {
4103
+ findings.push(finding("mw_target_elsewhere", ["targets", index], `"${needle.length === 1 ? words[indices[0]].text : indices.map((word) => words[word].text).join(" ")}" also appears where it is not to be marked. A learner who marks it there loses a mark: check that is meant.`));
4104
+ return;
4105
+ }
4106
+ });
4107
+ return findings;
4108
+ }
4109
+ // src/schemas/matching.ts
4110
+ /** Zod schema for one option of the shared list. Loose: a consumer sidecar survives validation. */
4111
+ const MatchingOptionSchema = z.looseObject({
4112
+ id: z.string().min(1),
4113
+ label: z.string().min(1).optional(),
4114
+ text: z.string().min(1)
4115
+ });
4116
+ /** Zod schema for one prompt. Whether its `correctOptionId` exists is checked on the activity. */
4117
+ const MatchingPromptSchema = z.looseObject({
4118
+ id: z.string().min(1),
4119
+ text: z.string().min(1),
4120
+ correctOptionId: z.string().min(1),
4121
+ feedback: z.string().optional()
4122
+ });
4123
+ /** Labels compared as a learner reads them: `"A"`, `" a "` and `"a"` are one key. */
4124
+ function labelKey(label) {
4125
+ return label.trim().toLowerCase();
4126
+ }
4127
+ /** Whether every element of `values` is distinct. */
4128
+ const distinct = (values) => new Set(values).size === values.length;
4129
+ /**
4130
+ * Zod schema validating the full Matching data contract.
4131
+ *
4132
+ * Five semantic guards, each an authoring error that would otherwise reach a
4133
+ * learner:
4134
+ *
4135
+ * 1. **Prompt ids are unique** — the scorer looks a match up by prompt id, so a
4136
+ * duplicate makes one prompt unscoreable.
4137
+ * 2. **Option ids are unique** — an answer key naming an id two options share
4138
+ * means two right answers nobody wrote.
4139
+ * 3. **Option labels are unique**, ignoring case and surrounding space — two
4140
+ * options both labelled "C" cannot be told apart by the key the learner
4141
+ * answers with.
4142
+ * 4. **Every `correctOptionId` names an option.** An answer key pointing at an
4143
+ * option nobody can pick is a prompt that cannot be passed.
4144
+ * 5. **Under `optionUse: 'once'`, no option answers two prompts.** Otherwise
4145
+ * the item says each option is used once while its own key uses one twice.
4146
+ */
4147
+ const MatchingDataSchema = z.looseObject({
4148
+ schemaVersion: z.literal("1.0"),
4149
+ type: z.literal("matching"),
4150
+ id: z.string().min(1),
4151
+ title: z.string().min(1),
4152
+ instructions: z.string().optional(),
4153
+ optionsTitle: z.string().optional(),
4154
+ prompts: z.array(MatchingPromptSchema).min(1),
4155
+ options: z.array(MatchingOptionSchema).min(2),
4156
+ optionUse: z.enum(["once", "many"]).optional(),
4157
+ scoringStrategy: z.enum(["all-or-nothing", "partial"]),
4158
+ shuffleOptions: z.boolean().optional(),
4159
+ media: MediaSchema.optional(),
4160
+ feedback: FeedbackSchema.optional(),
4161
+ passThreshold: z.number().min(0).max(1).optional(),
4162
+ locale: z.string().optional(),
4163
+ learningObjectives: z.array(z.string()).optional(),
4164
+ difficultyLevel: z.literal([
4165
+ 1,
4166
+ 2,
4167
+ 3,
4168
+ 4,
4169
+ 5
4170
+ ]).optional(),
4171
+ ai: AiPermissionsSchema.optional()
4172
+ }).refine((data) => distinct(data.prompts.map((prompt) => prompt.id)), {
4173
+ error: "Prompt ids must be unique.",
4174
+ path: ["prompts"]
4175
+ }).refine((data) => distinct(data.options.map((option) => option.id)), {
4176
+ error: "Option ids must be unique.",
4177
+ path: ["options"]
4178
+ }).refine((data) => distinct(data.options.map((option) => option.label).filter((label) => label !== void 0).map(labelKey)), {
4179
+ error: "Option labels must be unique.",
4180
+ path: ["options"]
4181
+ }).refine((data) => {
4182
+ const ids = new Set(data.options.map((option) => option.id));
4183
+ return data.prompts.every((prompt) => ids.has(prompt.correctOptionId));
4184
+ }, {
4185
+ error: "Each prompt needs a `correctOptionId` that is one of the options.",
4186
+ path: ["prompts"]
4187
+ }).refine((data) => data.optionUse !== "once" || distinct(data.prompts.map((prompt) => prompt.correctOptionId)), {
4188
+ error: "With `optionUse: \"once\"`, no option may be the answer to more than one prompt.",
4189
+ path: ["prompts"]
4190
+ });
4191
+ // src/authoring/matching.ts
4192
+ /** Mirrors `MatchingDataSchema`: `options.min(2)`. */
4193
+ const MIN_OPTIONS$1 = 2;
4194
+ const OPTION_USES = ["once", "many"];
4195
+ /**
4196
+ * Draft support for `matching`.
4197
+ *
4198
+ * A new draft has no prompts and no options, and `scoringStrategy` starts at
4199
+ * `all-or-nothing`, as on every built-in: partial credit is the author's to
4200
+ * give — though an exam's matching task is marked prompt by prompt, so one
4201
+ * copied from a paper wants `partial`.
4202
+ *
4203
+ * As in gap-select, no answer is pre-selected: a prompt's `correctOptionId`
4204
+ * starts unwritten, and the draft stays `incomplete` until someone says which
4205
+ * option answers each prompt.
4206
+ */
4207
+ const matchingAuthoring = {
4208
+ createDraft: ({ newId }) => ({
4209
+ schemaVersion: "1.0",
4210
+ type: "matching",
4211
+ id: newId(),
4212
+ title: "",
4213
+ prompts: [],
4214
+ options: [],
4215
+ scoringStrategy: "all-or-nothing"
4216
+ }),
4217
+ checkDraft: checkMatchingDraft,
4218
+ critique: critiqueMatching
4219
+ };
4220
+ function checkMatchingDraft(draft) {
4221
+ const issues = checkIdentity(draft, "matching");
4222
+ issues.push(...checkScoringStrategy(draft));
4223
+ if (!isUnset(draft.optionUse) && !OPTION_USES.includes(draft.optionUse)) issues.push(issue("mt_option_use_invalid", ["optionUse"], "Say whether each option is used \"once\" or \"many\" times, or leave it out for \"many\"."));
4224
+ const options = isUnset(draft.options) ? [] : draft.options;
4225
+ const optionIds = /* @__PURE__ */ new Set();
4226
+ if (Array.isArray(options)) issues.push(...checkOptions(options, optionIds));
4227
+ const prompts = isUnset(draft.prompts) ? [] : draft.prompts;
4228
+ if (Array.isArray(prompts)) issues.push(...checkPrompts(prompts, optionIds, draft.optionUse === "once"));
4229
+ issues.push(...checkSharedOptional(draft));
4230
+ return issues;
4231
+ }
4232
+ /**
4233
+ * The shared list. Its uniqueness rules are reported at `options`, where the
4234
+ * schema reports them; what is missing from one option, at that option.
4235
+ */
4236
+ function checkOptions(options, optionIds) {
4237
+ const issues = [];
4238
+ const atOptions = [];
4239
+ if (options.length < MIN_OPTIONS$1) issues.push(issue("mt_options_too_few", ["options"], `Add at least ${MIN_OPTIONS$1} options to the list the prompts are matched with.`));
4240
+ const repeatedIds = /* @__PURE__ */ new Set();
4241
+ const labels = /* @__PURE__ */ new Map();
4242
+ const repeatedLabels = /* @__PURE__ */ new Set();
4243
+ for (const [index, option] of options.entries()) {
4244
+ if (!isRecord(option)) continue;
4245
+ const ordinal = index + 1;
4246
+ if (isMissingId(option.id)) issues.push(issue("mt_option_id_required", [
4247
+ "options",
4248
+ index,
4249
+ "id"
4250
+ ], `Option ${ordinal} has no id.`));
4251
+ else if (typeof option.id === "string") {
4252
+ if (optionIds.has(option.id)) repeatedIds.add(option.id);
4253
+ optionIds.add(option.id);
4254
+ }
4255
+ if (isUnwritten(option.text)) issues.push(issue("mt_option_text_required", [
4256
+ "options",
4257
+ index,
4258
+ "text"
4259
+ ], `Write the text of option ${ordinal}.`));
4260
+ if (typeof option.label === "string" && option.label.trim() === "") issues.push(issue("mt_option_label_required", [
4261
+ "options",
4262
+ index,
4263
+ "label"
4264
+ ], `Write the label of option ${ordinal}, such as "A" or "iv", or remove it.`));
4265
+ else if (typeof option.label === "string") {
4266
+ const key = labelKey(option.label);
4267
+ if (labels.has(key)) repeatedLabels.add(labels.get(key));
4268
+ else labels.set(key, option.label.trim());
4269
+ }
4270
+ }
4271
+ for (const id of repeatedIds) atOptions.push(issue("mt_option_id_duplicate", ["options"], `More than one option has the id "${id}".`));
4272
+ for (const label of repeatedLabels) atOptions.push(issue("mt_option_label_duplicate", ["options"], `More than one option is labelled "${label}". Learners answer with the label, so each needs its own.`));
4273
+ return [...issues, ...atOptions];
4274
+ }
4275
+ /**
4276
+ * Every prompt. Its uniqueness rule and its answer key against the list are
4277
+ * reported at `prompts`, where the schema reports them; a missing id, text or
4278
+ * key, at the prompt's own field.
4279
+ */
4280
+ function checkPrompts(prompts, optionIds, once) {
4281
+ const issues = [];
4282
+ const atPrompts = [];
4283
+ if (prompts.length === 0) issues.push(issue("mt_prompts_required", ["prompts"], "Add at least one prompt to match with an option."));
4284
+ const seen = /* @__PURE__ */ new Set();
4285
+ const repeated = /* @__PURE__ */ new Set();
4286
+ const answering = /* @__PURE__ */ new Map();
4287
+ for (const [index, prompt] of prompts.entries()) {
4288
+ if (!isRecord(prompt)) continue;
4289
+ const ordinal = index + 1;
4290
+ const name = typeof prompt.id === "string" && prompt.id !== "" ? `"${prompt.id}"` : ordinal;
4291
+ if (isMissingId(prompt.id)) issues.push(issue("mt_prompt_id_required", [
4292
+ "prompts",
4293
+ index,
4294
+ "id"
4295
+ ], `Prompt ${ordinal} has no id.`));
4296
+ else if (typeof prompt.id === "string") {
4297
+ if (seen.has(prompt.id)) repeated.add(prompt.id);
4298
+ seen.add(prompt.id);
4299
+ }
4300
+ if (isUnwritten(prompt.text)) issues.push(issue("mt_prompt_text_required", [
4301
+ "prompts",
4302
+ index,
4303
+ "text"
4304
+ ], `Write the text of prompt ${ordinal}.`));
4305
+ const key = prompt.correctOptionId;
4306
+ if (isUnwritten(key)) issues.push(issue("mt_correct_option_required", [
4307
+ "prompts",
4308
+ index,
4309
+ "correctOptionId"
4310
+ ], `Choose the option that answers prompt ${name}.`));
4311
+ else if (typeof key === "string") {
4312
+ if (!optionIds.has(key)) atPrompts.push(issue("mt_correct_option_unknown", ["prompts"], `Prompt ${name} is answered by "${key}", which is not one of the options.`));
4313
+ answering.set(key, [...answering.get(key) ?? [], String(name)]);
4314
+ }
4315
+ }
4316
+ for (const id of repeated) atPrompts.push(issue("mt_prompt_id_duplicate", ["prompts"], `More than one prompt has the id "${id}".`));
4317
+ if (once) {
4318
+ for (const [optionId, names] of answering) if (names.length > 1) atPrompts.push(issue("mt_option_reused", ["prompts"], `Option "${optionId}" answers prompts ${names.join(" and ")}, but each option is used once. Choose another answer, or let options be used more than once.`));
4319
+ }
4320
+ return [...issues, ...atPrompts];
4321
+ }
3748
4322
  // src/schemas/multiple-choice.ts
3749
4323
  /**
3750
4324
  * Zod schema for a single Multiple Choice option. Loose: unknown keys are
@@ -4748,6 +5322,97 @@ const GapSelectDataSchema = z.looseObject({
4748
5322
  error: "Each gap needs unique choice ids and a `correctChoiceId` that is one of the choices it offers.",
4749
5323
  path: ["gaps"]
4750
5324
  });
5325
+ // src/schemas/mark-the-words.ts
5326
+ /** Zod schema for one target. Loose: a consumer sidecar survives validation. */
5327
+ const MarkTheWordsTargetSchema = z.looseObject({
5328
+ text: z.string().min(1),
5329
+ occurrence: z.number().int().min(1).optional(),
5330
+ feedback: z.string().optional()
5331
+ });
5332
+ /** What the schema says when a target falls on no words of the text. */
5333
+ const MARK_TARGET_MESSAGES = {
5334
+ "no-words": "A target must contain a word: punctuation alone cannot be marked.",
5335
+ "not-found": "A target must be whole words of the text, in order, written as the text writes them.",
5336
+ ambiguous: "These words appear more than once in the text: say which appearance with `occurrence`.",
5337
+ "no-such-occurrence": "The text does not have that many appearances of these words."
5338
+ };
5339
+ /**
5340
+ * Zod schema validating the full Mark the Words data contract.
5341
+ *
5342
+ * Beyond the shape, three guards, each reported at the field it is about:
5343
+ *
5344
+ * 1. **The text has a word to mark**, and at most 1500 of them.
5345
+ * 2. **Every target falls on whole words of the text** — once, or at the
5346
+ * appearance its `occurrence` names. A target that could be either of two
5347
+ * places is refused: which one is marked right would be a guess.
5348
+ * 3. **No two targets share a word.** A word marked once cannot count twice.
5349
+ */
5350
+ const MarkTheWordsDataSchema = z.looseObject({
5351
+ schemaVersion: z.literal("1.0"),
5352
+ type: z.literal("mark-the-words"),
5353
+ id: z.string().min(1),
5354
+ title: z.string().min(1),
5355
+ instructions: z.string().optional(),
5356
+ text: z.string().min(1),
5357
+ targets: z.array(MarkTheWordsTargetSchema).min(1),
5358
+ scoringStrategy: z.enum(["all-or-nothing", "partial"]),
5359
+ media: MediaSchema.optional(),
5360
+ feedback: FeedbackSchema.optional(),
5361
+ passThreshold: z.number().min(0).max(1).optional(),
5362
+ locale: z.string().optional(),
5363
+ learningObjectives: z.array(z.string()).optional(),
5364
+ difficultyLevel: z.literal([
5365
+ 1,
5366
+ 2,
5367
+ 3,
5368
+ 4,
5369
+ 5
5370
+ ]).optional(),
5371
+ ai: AiPermissionsSchema.optional()
5372
+ }).superRefine((data, ctx) => {
5373
+ const words = markableWords(data.text);
5374
+ if (words.length === 0) {
5375
+ ctx.addIssue({
5376
+ code: "custom",
5377
+ path: ["text"],
5378
+ message: "The text has no word to mark."
5379
+ });
5380
+ return;
5381
+ }
5382
+ if (words.length > 1500) {
5383
+ ctx.addIssue({
5384
+ code: "custom",
5385
+ path: ["text"],
5386
+ message: `The text may have at most ${MARK_THE_WORDS_MAX_WORDS} words; it has ${words.length}.`
5387
+ });
5388
+ return;
5389
+ }
5390
+ const owner = /* @__PURE__ */ new Map();
5391
+ data.targets.forEach((target, index) => {
5392
+ const found = resolveMarkTarget(words, target);
5393
+ if (!found.ok) {
5394
+ ctx.addIssue({
5395
+ code: "custom",
5396
+ path: [
5397
+ "targets",
5398
+ index,
5399
+ found.problem === "no-such-occurrence" ? "occurrence" : "text"
5400
+ ],
5401
+ message: MARK_TARGET_MESSAGES[found.problem]
5402
+ });
5403
+ return;
5404
+ }
5405
+ if (found.indices.some((word) => owner.has(word))) {
5406
+ ctx.addIssue({
5407
+ code: "custom",
5408
+ path: ["targets", index],
5409
+ message: "Two targets share a word: each word belongs to one target at most."
5410
+ });
5411
+ return;
5412
+ }
5413
+ for (const word of found.indices) owner.set(word, index);
5414
+ });
5415
+ });
4751
5416
  // src/schemas/written-response.ts
4752
5417
  /** Zod schema for a single rubric criterion. Loose: unknown keys preserved. */
4753
5418
  const WrittenResponseRubricCriterionSchema = z.looseObject({
@@ -4946,6 +5611,48 @@ const RedactedGapSelectDataSchema = z.strictObject({
4946
5611
  presentation: z.literal("dropdown").optional(),
4947
5612
  shuffleChoices: z.boolean().optional()
4948
5613
  });
5614
+ /** A redacted matching option: unchanged, because the learner picks from the list. */
5615
+ const RedactedMatchingOptionSchema = z.strictObject({
5616
+ id: z.string().min(1),
5617
+ label: z.string().min(1).optional(),
5618
+ text: z.string().min(1)
5619
+ });
5620
+ /** A redacted prompt: what the learner reads; `correctOptionId` and feedback are gone. */
5621
+ const RedactedMatchingPromptSchema = z.strictObject({
5622
+ id: z.string().min(1),
5623
+ text: z.string().min(1)
5624
+ });
5625
+ /**
5626
+ * Redacted Matching data. As in Gap Select, the list the learner picks from
5627
+ * survives in full and only the key is withheld: each prompt's
5628
+ * `correctOptionId`. `optionUse` survives because it changes what the learner
5629
+ * may do — an exam says "You may use any letter more than once" in its
5630
+ * instructions, and the component moves an option used once. `scoringStrategy`
5631
+ * stays answer-key, as on every type.
5632
+ */
5633
+ const RedactedMatchingDataSchema = z.strictObject({
5634
+ ...redactedBase,
5635
+ type: z.literal("matching"),
5636
+ instructions: z.string().optional(),
5637
+ optionsTitle: z.string().optional(),
5638
+ prompts: z.array(RedactedMatchingPromptSchema).min(1),
5639
+ options: z.array(RedactedMatchingOptionSchema).min(2),
5640
+ optionUse: z.enum(["once", "many"]).optional(),
5641
+ shuffleOptions: z.boolean().optional()
5642
+ });
5643
+ /**
5644
+ * Redacted Mark the Words data: the text, and nothing about which words are
5645
+ * right — not the targets, not even how many there are, since a count tells a
5646
+ * learner when to stop. `scoringStrategy` stays answer-key, as on every type:
5647
+ * whether a wrong mark costs anything is the difference between marking
5648
+ * carefully and marking everything.
5649
+ */
5650
+ const RedactedMarkTheWordsDataSchema = z.strictObject({
5651
+ ...redactedBase,
5652
+ type: z.literal("mark-the-words"),
5653
+ instructions: z.string().optional(),
5654
+ text: z.string().min(1)
5655
+ });
4949
5656
  /**
4950
5657
  * Redacted Written Response data: the prompt, word bounds and rubric are
4951
5658
  * learner-visible (a rubric tells the learner what they are graded on);
@@ -5185,6 +5892,104 @@ function scoreGapSelect(data, response) {
5185
5892
  details
5186
5893
  };
5187
5894
  }
5895
+ // src/scoring/activity-scorers/mark-the-words.ts
5896
+ /**
5897
+ * Scores a Mark the Words response.
5898
+ *
5899
+ * Each target is found when every one of its words is marked. Every marked
5900
+ * word that belongs to no target is a wrong mark, and costs one target's
5901
+ * worth: `partial` is (targets found − wrong marks) / targets, never below 0,
5902
+ * so marking every word is not a strategy. `all-or-nothing` is 1 only with
5903
+ * every target found and no wrong mark.
5904
+ *
5905
+ * A position that names no word, repeats one, or is not a whole number (a
5906
+ * tampered payload, or a text edited after the attempt) is ignored: it marks
5907
+ * nothing, right or wrong.
5908
+ *
5909
+ * Details: one per target (`t1`, `t2`… in target order) — `correct`, or
5910
+ * `incorrect` when only some of its words are marked, or `incorrect-omission`
5911
+ * when none are — with the target's word positions as `correctResponse`; then
5912
+ * one per wrong mark (`w` and the word's position), `incorrect`, with no
5913
+ * correct response. A review reads both to mark the text.
5914
+ */
5915
+ function scoreMarkTheWords(data, response) {
5916
+ const count = markableWords(data.text).length;
5917
+ const raw = Array.isArray(response.marked) ? response.marked : [];
5918
+ const marked = new Set(raw.filter((word) => typeof word === "number" && Number.isInteger(word) && word >= 0).filter((word) => word < count));
5919
+ const targets = markTargetIndices(data);
5920
+ const inTargets = new Set(targets.flat());
5921
+ const details = [];
5922
+ let found = 0;
5923
+ targets.forEach((words, index) => {
5924
+ const hit = words.filter((word) => marked.has(word));
5925
+ const correct = words.length > 0 && hit.length === words.length;
5926
+ if (correct) found += 1;
5927
+ details.push({
5928
+ itemId: `t${index + 1}`,
5929
+ outcome: correct ? "correct" : hit.length > 0 ? "incorrect" : "incorrect-omission",
5930
+ learnerResponse: hit.map(String),
5931
+ correctResponse: words.map(String),
5932
+ weight: 1
5933
+ });
5934
+ });
5935
+ const wrong = [...marked].filter((word) => !inTargets.has(word)).sort((a, b) => a - b);
5936
+ for (const word of wrong) details.push({
5937
+ itemId: `w${word}`,
5938
+ outcome: "incorrect",
5939
+ learnerResponse: [String(word)],
5940
+ correctResponse: [],
5941
+ weight: 1
5942
+ });
5943
+ const total = targets.length;
5944
+ return {
5945
+ score: total === 0 ? 0 : data.scoringStrategy === "all-or-nothing" ? found === total && wrong.length === 0 ? 1 : 0 : Math.max(0, found - wrong.length) / total,
5946
+ maxScore: 1,
5947
+ feedback: null,
5948
+ details
5949
+ };
5950
+ }
5951
+ // src/scoring/activity-scorers/matching.ts
5952
+ /**
5953
+ * Scores a Matching response.
5954
+ *
5955
+ * Each prompt is an identity comparison between the option the learner matched
5956
+ * it with and `correctOptionId`, as a gap select's gap is. A prompt with no
5957
+ * match, or matched with an id that is not an option (a tampered payload, or
5958
+ * content edited after the attempt), is scored incorrect and reported as
5959
+ * `incorrect-omission`, so an unanswered prompt stays distinguishable from a
5960
+ * wrong one downstream.
5961
+ *
5962
+ * Under `optionUse: 'once'` a response that uses one option twice is scored
5963
+ * prompt by prompt like any other: the SDK's component never produces one, and
5964
+ * a second use earns nothing the key does not give it, since the key uses each
5965
+ * option at most once.
5966
+ */
5967
+ function scoreMatching(data, response) {
5968
+ const optionIds = new Set(data.options.map((option) => option.id));
5969
+ const details = [];
5970
+ const perPromptCorrect = [];
5971
+ for (const prompt of data.prompts) {
5972
+ const raw = response.matches[prompt.id];
5973
+ const matched = typeof raw === "string" ? raw : "";
5974
+ const answered = matched !== "" && optionIds.has(matched);
5975
+ const correct = answered && matched === prompt.correctOptionId;
5976
+ perPromptCorrect.push(correct);
5977
+ details.push({
5978
+ itemId: prompt.id,
5979
+ outcome: correct ? "correct" : answered ? "incorrect" : "incorrect-omission",
5980
+ learnerResponse: [matched],
5981
+ correctResponse: [prompt.correctOptionId],
5982
+ weight: 1
5983
+ });
5984
+ }
5985
+ const correctPrompts = perPromptCorrect.filter(Boolean).length;
5986
+ return {
5987
+ score: data.scoringStrategy === "all-or-nothing" ? allOrNothingStrategy(perPromptCorrect) : partialBlankStrategy(correctPrompts, data.prompts.length),
5988
+ maxScore: 1,
5989
+ feedback: null,
5990
+ details
5991
+ };
5992
+ }
5188
5993
  // src/scoring/activity-scorers/multiple-choice.ts
5189
5994
  /**
5190
5995
  * Scores a Multiple Choice response. Pure; trusts its typed inputs (validation
@@ -5447,6 +6252,45 @@ const GAP_SELECT_FIELD_POLICY = {
5447
6252
  feedback: "answer-key"
5448
6253
  }
5449
6254
  };
6255
+ /**
6256
+ * Matching: as in Gap Select, the list the learner picks from is public, key
6257
+ * by key, and each prompt's `correctOptionId` is the one field withheld.
6258
+ * `optionUse` is public because it changes what the learner may do: the
6259
+ * component moves an option used once, and the paper says so.
6260
+ */
6261
+ const MATCHING_FIELD_POLICY = {
6262
+ ...SHARED_PUBLIC_FIELDS,
6263
+ instructions: "public",
6264
+ optionsTitle: "public",
6265
+ optionUse: "public",
6266
+ shuffleOptions: "public",
6267
+ scoringStrategy: "answer-key",
6268
+ feedback: FEEDBACK_FIELD_POLICY,
6269
+ options: {
6270
+ id: "public",
6271
+ label: "public",
6272
+ text: "public"
6273
+ },
6274
+ prompts: {
6275
+ id: "public",
6276
+ text: "public",
6277
+ correctOptionId: "answer-key",
6278
+ feedback: "answer-key"
6279
+ }
6280
+ };
6281
+ /**
6282
+ * Mark the Words: the text is what the learner marks in, so it is public; the
6283
+ * targets are the key, as one leaf — their number included, since a count
6284
+ * tells a learner when to stop marking.
6285
+ */
6286
+ const MARK_THE_WORDS_FIELD_POLICY = {
6287
+ ...SHARED_PUBLIC_FIELDS,
6288
+ instructions: "public",
6289
+ text: "public",
6290
+ targets: "answer-key",
6291
+ scoringStrategy: "answer-key",
6292
+ feedback: FEEDBACK_FIELD_POLICY
6293
+ };
5450
6294
  const FILL_IN_THE_BLANKS_FIELD_POLICY = {
5451
6295
  ...SHARED_PUBLIC_FIELDS,
5452
6296
  instructions: "public",
@@ -5627,6 +6471,50 @@ const gapSelectType = defineActivityType({
5627
6471
  authoring: gapSelectAuthoring,
5628
6472
  mediaSlots: () => MAIN_MEDIA
5629
6473
  });
6474
+ /** Built-in Matching descriptor. */
6475
+ const matchingType = defineActivityType({
6476
+ type: "matching",
6477
+ schema: MatchingDataSchema,
6478
+ scoring: {
6479
+ kind: "sync",
6480
+ score: scoreMatching
6481
+ },
6482
+ isAnswered: (response) => Object.values(response?.matches ?? {}).some((optionId) => optionId !== ""),
6483
+ fieldPolicy: MATCHING_FIELD_POLICY,
6484
+ redactedSchema: RedactedMatchingDataSchema,
6485
+ interop: {
6486
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
6487
+ xapiInteractionType: "matching",
6488
+ correctResponsesPattern: (data) => [data.prompts.map((prompt) => `${prompt.id}[.]${prompt.correctOptionId}`).join("[,]")]
6489
+ },
6490
+ interactions: ["option-matched", "submitted"],
6491
+ authoring: matchingAuthoring,
6492
+ mediaSlots: () => MAIN_MEDIA
6493
+ });
6494
+ /** Built-in Mark the Words descriptor. */
6495
+ const markTheWordsType = defineActivityType({
6496
+ type: "mark-the-words",
6497
+ schema: MarkTheWordsDataSchema,
6498
+ scoring: {
6499
+ kind: "sync",
6500
+ score: scoreMarkTheWords
6501
+ },
6502
+ isAnswered: (response) => Array.isArray(response?.marked) && response.marked.length > 0,
6503
+ fieldPolicy: MARK_THE_WORDS_FIELD_POLICY,
6504
+ redactedSchema: RedactedMarkTheWordsDataSchema,
6505
+ interop: {
6506
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
6507
+ xapiInteractionType: "choice",
6508
+ correctResponsesPattern: (data) => [markTargetIndices(data).flat().sort((a, b) => a - b).join("[,]")]
6509
+ },
6510
+ interactions: [
6511
+ "word-marked",
6512
+ "word-unmarked",
6513
+ "submitted"
6514
+ ],
6515
+ authoring: markTheWordsAuthoring,
6516
+ mediaSlots: () => MAIN_MEDIA
6517
+ });
5630
6518
  /**
5631
6519
  * Built-in Dictation descriptor. Graded synchronously: the score is the
5632
6520
  * character-level similarity of the typed text to the transcript, a pure
@@ -5803,7 +6691,9 @@ const BUILT_IN_DESCRIPTORS = {
5803
6691
  ],
5804
6692
  authoring: speakingResponseAuthoring,
5805
6693
  mediaSlots: () => MAIN_MEDIA
5806
- })
6694
+ }),
6695
+ matching: matchingType,
6696
+ "mark-the-words": markTheWordsType
5807
6697
  };
5808
6698
  // src/schemas/read-schema.ts
5809
6699
  /**
@@ -5967,6 +6857,6 @@ function getActivityTypeDescriptor(type) {
5967
6857
  function registeredActivityTypes() {
5968
6858
  return [...registry.keys()];
5969
6859
  }
5970
- export { GROUP_CAPTIONS_REVEAL_DICTATION as $, keyPositionFinding as A, SCRIPT_MAX_TURNS as At, countWords as B, READ_ALOUD_MAX_REFERENCE_LENGTH as C, passageWithoutPlaceholders as Ct, SPEECH_ASSESSMENT_MAX_WORDS as D, PLACEHOLDER_RE as Dt, SPEECH_ASSESSMENT_MAX_TEXT_LENGTH as E, FillInTheBlanksDataSchema as Et, matchText as F, isMissingId as G, checkMedia as H, LEVEL_PROBABILITY_TOLERANCE as I, isUnwritten as J, isRecord as K, levelDistribution as L, normaliseFinding as M, maxUnits as Mt, policyFor as N, MultipleChoiceDataSchema as O, MediaSchema as Ot, levenshteinDistance as P, DictationDataSchema as Q, levelKey as R, READ_ALOUD_MAX_DIMENSION_WEIGHT as S, numberedPassage as St, READ_ALOUD_MAX_TAKES as T, revealsAnswer as Tt, coveredPathsOf as U, DRAFT_ISSUE_SEVERITY as V, covers as W, pathKey as X, issue as Y, refusesEmpty as Z, GapSelectDataSchema as _, BUILT_IN_ACTIVITY_TYPES as _t, readSchema as a, STEP as at, ReadAloudDataSchema as b, containsWords as bt, dictationType as c, dictationReferenceWords as ct, multipleChoiceType as d, DICTATION_MAX_EQUIVALENCE_LENGTH as dt, groupCaptionsField as et, readAloudType as f, DICTATION_MAX_TEXT_LENGTH as ft, WrittenResponseDataSchema as g, withValidationScope as gt, defineActivityType as h, normalizeDictationText as ht, isOwnSchema as i, UnknownActivityTypeError as it, finding as j, ScriptSchema as jt, MultipleChoiceOptionMediaSchema as k, RedactedMediaSchema as kt, fillInTheBlanksType as l, DICTATION_MAX_ACCEPTED_TRANSCRIPTS as lt, guardOf as m, codePointLength as mt, registerActivityType as n, DeferredScoringError as nt, standardJsonSchemaOf as o, alignDictation as ot, writtenResponseType as p, DICTATION_MAX_TRANSCRIPT_LENGTH as pt, isUnset as q, registeredActivityTypes as r, RedactedScoringError as rt, MEDIA_FIELD_POLICY as s, alignSequences as st, getActivityTypeDescriptor as t, ActivitySchemaError as tt, gapSelectType as u, DICTATION_MAX_EQUIVALENCES as ut, SPEAKING_MEASURES as v, everyBuiltInTypeHandled as vt, READ_ALOUD_MAX_SECONDS as w, placeholderOrder as wt, CANONICAL_LOCALE_RE as x, fold as xt, SpeakingResponseDataSchema as y, isBuiltInActivityType as yt, readRubricLevels as z };
6860
+ export { isUnset as $, MARK_THE_WORDS_MAX_WORDS as A, placeholderOrder as At, levenshteinDistance as B, READ_ALOUD_MAX_REFERENCE_LENGTH as C, BUILT_IN_ACTIVITY_TYPES as Ct, SPEECH_ASSESSMENT_MAX_WORDS as D, fold as Dt, SPEECH_ASSESSMENT_MAX_TEXT_LENGTH as E, containsWords as Et, resolveMarkTarget as F, RedactedMediaSchema as Ft, readRubricLevels as G, LEVEL_PROBABILITY_TOLERANCE as H, keyPositionFinding as I, SCRIPT_MAX_TURNS as It, checkMedia as J, countWords as K, finding as L, ScriptSchema as Lt, markTargetIndices as M, FillInTheBlanksDataSchema as Mt, markTheWordsFromMarkup as N, PLACEHOLDER_RE as Nt, MultipleChoiceDataSchema as O, numberedPassage as Ot, markableWords as P, MediaSchema as Pt, isRecord as Q, normaliseFinding as R, maxUnits as Rt, READ_ALOUD_MAX_DIMENSION_WEIGHT as S, withValidationScope as St, READ_ALOUD_MAX_TAKES as T, isBuiltInActivityType as Tt, levelDistribution as U, matchText as V, levelKey as W, covers as X, coveredPathsOf as Y, isMissingId as Z, GapSelectDataSchema as _, DICTATION_MAX_EQUIVALENCE_LENGTH as _t, readSchema as a, GROUP_CAPTIONS_REVEAL_DICTATION as at, ReadAloudDataSchema as b, codePointLength as bt, dictationType as c, DeferredScoringError as ct, multipleChoiceType as d, STEP as dt, isUnwritten as et, readAloudType as f, alignDictation as ft, WrittenResponseDataSchema as g, DICTATION_MAX_EQUIVALENCES as gt, defineActivityType as h, DICTATION_MAX_ACCEPTED_TRANSCRIPTS as ht, isOwnSchema as i, DictationDataSchema as it, MARK_THE_WORDS_TOKENIZER as j, revealsAnswer as jt, MultipleChoiceOptionMediaSchema as k, passageWithoutPlaceholders as kt, fillInTheBlanksType as l, RedactedScoringError as lt, guardOf as m, dictationReferenceWords as mt, registerActivityType as n, pathKey as nt, standardJsonSchemaOf as o, groupCaptionsField as ot, writtenResponseType as p, alignSequences as pt, DRAFT_ISSUE_SEVERITY as q, registeredActivityTypes as r, refusesEmpty as rt, MEDIA_FIELD_POLICY as s, ActivitySchemaError as st, getActivityTypeDescriptor as t, issue as tt, gapSelectType as u, UnknownActivityTypeError as ut, SPEAKING_MEASURES as v, DICTATION_MAX_TEXT_LENGTH as vt, READ_ALOUD_MAX_SECONDS as w, everyBuiltInTypeHandled as wt, CANONICAL_LOCALE_RE as x, normalizeDictationText as xt, SpeakingResponseDataSchema as y, DICTATION_MAX_TRANSCRIPT_LENGTH as yt, policyFor as z };
5971
6861
 
5972
- //# sourceMappingURL=registry-BClnM5V3.js.map
6862
+ //# sourceMappingURL=registry-CroAI5nc.js.map