@intellectif/lk-core 1.13.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 (75) hide show
  1. package/CHANGELOG.md +12 -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-hC5pGYeu.d.cts → ai-mC-5XhBc.d.cts} +92 -2
  8. package/dist/ai-mC-5XhBc.d.cts.map +1 -0
  9. package/dist/{ai-hC5pGYeu.d.ts → ai-mC-5XhBc.d.ts} +92 -2
  10. package/dist/ai-mC-5XhBc.d.ts.map +1 -0
  11. package/dist/{ai-writing-BDSYuifR.cjs → ai-writing-Bm2TUWQ-.cjs} +10 -4
  12. package/dist/ai-writing-Bm2TUWQ-.cjs.map +1 -0
  13. package/dist/{ai-writing-Dsmvv4T-.js → ai-writing-D3oREPsp.js} +10 -4
  14. package/dist/ai-writing-D3oREPsp.js.map +1 -0
  15. package/dist/{index-Dlei-7PX.d.ts → index-2UQ5v6NZ.d.ts} +2 -2
  16. package/dist/{index-Dlei-7PX.d.ts.map → index-2UQ5v6NZ.d.ts.map} +1 -1
  17. package/dist/{index-Du9Kx_H6.d.ts → index-BudO4ES2.d.ts} +2 -2
  18. package/dist/{index-Du9Kx_H6.d.ts.map → index-BudO4ES2.d.ts.map} +1 -1
  19. package/dist/{index-BlJU10O4.d.cts → index-CFKxHXAY.d.ts} +11 -4
  20. package/dist/{index-DVW_N4pJ.d.ts.map → index-CFKxHXAY.d.ts.map} +1 -1
  21. package/dist/{index-DVW_N4pJ.d.ts → index-CagMlF4b.d.cts} +11 -4
  22. package/dist/{index-BlJU10O4.d.cts.map → index-CagMlF4b.d.cts.map} +1 -1
  23. package/dist/{index-4x0auNOv.d.cts → index-CqmNcMFM.d.cts} +2 -2
  24. package/dist/{index-4x0auNOv.d.cts.map → index-CqmNcMFM.d.cts.map} +1 -1
  25. package/dist/{index-DohEHH10.d.cts → index-MgPVCiSJ.d.cts} +2 -2
  26. package/dist/{index-DohEHH10.d.cts.map → index-MgPVCiSJ.d.cts.map} +1 -1
  27. package/dist/index.cjs +12 -6
  28. package/dist/index.d.cts +86 -10
  29. package/dist/index.d.cts.map +1 -1
  30. package/dist/index.d.ts +86 -10
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -7
  33. package/dist/{item-group-vTcNy2JF.js → item-group-BIl1ZJHg.js} +7 -6
  34. package/dist/item-group-BIl1ZJHg.js.map +1 -0
  35. package/dist/{item-group-CqdByaLR.cjs → item-group-DOk562oI.cjs} +7 -6
  36. package/dist/item-group-DOk562oI.cjs.map +1 -0
  37. package/dist/{registry-Cn2it-y7.js → registry-CroAI5nc.js} +517 -11
  38. package/dist/registry-CroAI5nc.js.map +1 -0
  39. package/dist/{registry-DUTE1vtq.cjs → registry-Dw19lNo2.cjs} +552 -10
  40. package/dist/registry-Dw19lNo2.cjs.map +1 -0
  41. package/dist/{schemas-BrA6CFWz.cjs → schemas-CEv2otNw.cjs} +3 -3
  42. package/dist/{schemas-BrA6CFWz.cjs.map → schemas-CEv2otNw.cjs.map} +1 -1
  43. package/dist/{schemas-DlxeS7Lu.js → schemas-COw7muXH.js} +3 -3
  44. package/dist/{schemas-DlxeS7Lu.js.map → schemas-COw7muXH.js.map} +1 -1
  45. package/dist/schemas.cjs +3 -3
  46. package/dist/schemas.d.cts +2 -2
  47. package/dist/schemas.d.ts +2 -2
  48. package/dist/schemas.js +3 -3
  49. package/dist/{scoring-weQ4SzwB.js → scoring-BxvOhHE0.js} +2 -2
  50. package/dist/{scoring-weQ4SzwB.js.map → scoring-BxvOhHE0.js.map} +1 -1
  51. package/dist/{scoring-IjLi2fYF.cjs → scoring-EpNBJ8Fs.cjs} +2 -2
  52. package/dist/{scoring-IjLi2fYF.cjs.map → scoring-EpNBJ8Fs.cjs.map} +1 -1
  53. package/dist/scoring.cjs +2 -2
  54. package/dist/scoring.d.cts +2 -2
  55. package/dist/scoring.d.ts +2 -2
  56. package/dist/scoring.js +2 -2
  57. package/dist/{xapi-BqEbpWag.cjs → xapi-C-cQgWRX.cjs} +2 -2
  58. package/dist/{xapi-BqEbpWag.cjs.map → xapi-C-cQgWRX.cjs.map} +1 -1
  59. package/dist/{xapi-DbRVH_RB.js → xapi-DHHm7Os9.js} +2 -2
  60. package/dist/{xapi-DbRVH_RB.js.map → xapi-DHHm7Os9.js.map} +1 -1
  61. package/dist/xapi.cjs +1 -1
  62. package/dist/xapi.d.cts +1 -1
  63. package/dist/xapi.d.ts +1 -1
  64. package/dist/xapi.js +1 -1
  65. package/package.json +1 -1
  66. package/vectors/scoring.json +833 -0
  67. package/vectors/validation.json +631 -0
  68. package/dist/ai-hC5pGYeu.d.cts.map +0 -1
  69. package/dist/ai-hC5pGYeu.d.ts.map +0 -1
  70. package/dist/ai-writing-BDSYuifR.cjs.map +0 -1
  71. package/dist/ai-writing-Dsmvv4T-.js.map +0 -1
  72. package/dist/item-group-CqdByaLR.cjs.map +0 -1
  73. package/dist/item-group-vTcNy2JF.js.map +0 -1
  74. package/dist/registry-Cn2it-y7.js.map +0 -1
  75. package/dist/registry-DUTE1vtq.cjs.map +0 -1
@@ -465,7 +465,8 @@ const BUILT_IN_ACTIVITY_TYPES = [
465
465
  "dictation",
466
466
  "read-aloud",
467
467
  "speaking-response",
468
- "matching"
468
+ "matching",
469
+ "mark-the-words"
469
470
  ];
470
471
  /** Whether `type` names an activity type the SDK ships. */
471
472
  function isBuiltInActivityType(type) {
@@ -1076,7 +1077,7 @@ function containsUnspacedScript(text) {
1076
1077
  }
1077
1078
  /** What is drawn onto the character before it: a match never ends just before one. */
1078
1079
  const EXTENDING_RE = new RegExp(`^[\\p{M}${JOINERS}${TAGS}]$`, "u");
1079
- const WORD = 1;
1080
+ const WORD$1 = 1;
1080
1081
  const UNSPACED = 2;
1081
1082
  const EXTENDING = 4;
1082
1083
  const KNOWN = 8;
@@ -1090,7 +1091,7 @@ const KNOWN = 8;
1090
1091
  */
1091
1092
  const FLAGS = [];
1092
1093
  function flagsOfCharacter(character) {
1093
- return KNOWN | (WORD_RE.test(character) ? WORD : 0) | (UNSPACED_CHARACTER_RE.test(character) ? UNSPACED : 0) | (EXTENDING_RE.test(character) ? EXTENDING : 0);
1094
+ return KNOWN | (WORD_RE.test(character) ? WORD$1 : 0) | (UNSPACED_CHARACTER_RE.test(character) ? UNSPACED : 0) | (EXTENDING_RE.test(character) ? EXTENDING : 0);
1094
1095
  }
1095
1096
  /** The flags of the code point that starts at UTF-16 index `index` of `text`. */
1096
1097
  function flagsAt(text, index) {
@@ -1320,12 +1321,12 @@ function isAsciiWordUnit(unit) {
1320
1321
  }
1321
1322
  function isWordAt(text, index) {
1322
1323
  const unit = text.charCodeAt(index);
1323
- return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & WORD) !== 0;
1324
+ return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & WORD$1) !== 0;
1324
1325
  }
1325
1326
  /** A word character of a script that separates its words with spaces. */
1326
1327
  function isSpacedWordAt(text, index) {
1327
1328
  const unit = text.charCodeAt(index);
1328
- return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & 3) === WORD;
1329
+ return unit < 128 ? isAsciiWordUnit(unit) : (flagsAt(text, index) & 3) === WORD$1;
1329
1330
  }
1330
1331
  function isExtendingAt(text, index) {
1331
1332
  return text.charCodeAt(index) >= 128 && (flagsAt(text, index) & EXTENDING) !== 0;
@@ -2364,6 +2365,15 @@ const DRAFT_ISSUE_SEVERITY = {
2364
2365
  mt_correct_option_required: "incomplete",
2365
2366
  mt_correct_option_unknown: "invalid",
2366
2367
  mt_option_reused: "invalid",
2368
+ mw_text_required: "incomplete",
2369
+ mw_text_too_long: "invalid",
2370
+ mw_targets_required: "incomplete",
2371
+ mw_target_text_required: "incomplete",
2372
+ mw_target_no_words: "invalid",
2373
+ mw_target_not_found: "invalid",
2374
+ mw_target_ambiguous: "incomplete",
2375
+ mw_target_occurrence_invalid: "invalid",
2376
+ mw_target_overlap: "invalid",
2367
2377
  wr_prompt_required: "incomplete",
2368
2378
  wr_min_words_required: "incomplete",
2369
2379
  wr_min_words_invalid: "invalid",
@@ -3127,6 +3137,9 @@ const ITEM_FINDING_SEVERITY = {
3127
3137
  mt_options_duplicate: "warning",
3128
3138
  mt_answer_in_prompt: "warning",
3129
3139
  mt_no_distractor: "warning",
3140
+ mw_title_reveals_answer: "warning",
3141
+ mw_targets_dense: "warning",
3142
+ mw_target_elsewhere: "advice",
3130
3143
  wr_criterion_name_duplicate: "warning",
3131
3144
  wr_levels_unreadable: "warning",
3132
3145
  ra_text_long_for_time: "warning",
@@ -3398,7 +3411,7 @@ function critiqueGapSelect(draft) {
3398
3411
  return findings;
3399
3412
  }
3400
3413
  /** Whether `text` writes out `answer` as whole words — an answer of one short word never counts. */
3401
- function writesOut(text, answer) {
3414
+ function writesOut$1(text, answer) {
3402
3415
  const needle = fold(answer);
3403
3416
  return needle.length > 0 && !isShort(needle) && containsWords(fold(text), needle);
3404
3417
  }
@@ -3411,7 +3424,7 @@ function critiqueMatching(draft) {
3411
3424
  const title = written(draft.title);
3412
3425
  if (title !== void 0 && prompts.some((prompt) => {
3413
3426
  const answer = answerOf(prompt);
3414
- return answer !== void 0 && writesOut(title, answer);
3427
+ return answer !== void 0 && writesOut$1(title, answer);
3415
3428
  })) findings.push(finding("mt_title_reveals_answer", ["title"], "The title, which the learner sees, contains the answer to a prompt."));
3416
3429
  for (const { first, second } of duplicates(options, optionText)) findings.push(finding("mt_options_duplicate", [
3417
3430
  "options",
@@ -3421,7 +3434,7 @@ function critiqueMatching(draft) {
3421
3434
  prompts.forEach((prompt, index) => {
3422
3435
  const text = isRecord(prompt) ? written(prompt.text) : void 0;
3423
3436
  const answer = answerOf(prompt);
3424
- if (text !== void 0 && answer !== void 0 && writesOut(text, answer)) findings.push(finding("mt_answer_in_prompt", [
3437
+ if (text !== void 0 && answer !== void 0 && writesOut$1(text, answer)) findings.push(finding("mt_answer_in_prompt", [
3425
3438
  "prompts",
3426
3439
  index,
3427
3440
  "text"
@@ -3820,6 +3833,301 @@ function pairsOneToOne(placeholders, gaps) {
3820
3833
  if (new Set(ids).size !== ids.length || placeholders.size !== ids.length) return false;
3821
3834
  return ids.every((id) => typeof id === "string" && placeholders.get(id) === 1);
3822
3835
  }
3836
+ // src/mark-the-words.ts
3837
+ /**
3838
+ * The words of a mark-the-words text, and where an author's targets fall
3839
+ * among them. A leaf module: the schema, the scorer, the draft checks and the
3840
+ * component all read it, and it imports nothing.
3841
+ *
3842
+ * A learner marks words, never characters, and a response records a word by
3843
+ * its position — so how a text is cut into words is part of every stored
3844
+ * answer. The cut is the SDK's own (`lk-words@1`), not `Intl.Segmenter`,
3845
+ * whose word boundaries change with the ICU a runtime ships: the same text
3846
+ * must be the same words in every browser and on every server, now and when
3847
+ * the answer is graded again later.
3848
+ */
3849
+ /** The name of the word cut. A change to it is a major: stored answers name words by position. */
3850
+ const MARK_THE_WORDS_TOKENIZER = "lk-words@1";
3851
+ /** The most words a mark-the-words text may have. */
3852
+ const MARK_THE_WORDS_MAX_WORDS = 1500;
3853
+ /**
3854
+ * A word: a run of letters, marks and digits, which may hold an apostrophe or
3855
+ * a hyphen between two such runs — "don't", "well-known", "1960s". Punctuation
3856
+ * and spaces are never part of one and can never be marked.
3857
+ */
3858
+ const WORD = /[\p{L}\p{M}\p{N}]+(?:['’ʼ\-‐][\p{L}\p{M}\p{N}]+)*/gu;
3859
+ /** Every markable word of `text`, in order, cut by `lk-words@1`. */
3860
+ function markableWords(text) {
3861
+ const words = [];
3862
+ for (const match of text.matchAll(WORD)) words.push({
3863
+ index: words.length,
3864
+ text: match[0],
3865
+ start: match.index,
3866
+ end: match.index + match[0].length
3867
+ });
3868
+ return words;
3869
+ }
3870
+ /** A word as compared with a target's: composed, and with one apostrophe and one hyphen. */
3871
+ function wordKey(word) {
3872
+ return word.normalize("NFC").replace(/[’ʼ]/g, "'").replace(/‐/g, "-");
3873
+ }
3874
+ /** Every place `needle` appears in `words`, as the position of its first word. */
3875
+ function placesOf(words, needle) {
3876
+ const places = [];
3877
+ for (let at = 0; at + needle.length <= words.length; at += 1) if (needle.every((word, offset) => words[at + offset] === word)) places.push(at);
3878
+ return places;
3879
+ }
3880
+ /**
3881
+ * Where a target falls among `words`. Its text must be whole words of the text,
3882
+ * in order — case, accents and punctuation as written, apart from the kind of
3883
+ * apostrophe — and must say which appearance it means when there are several.
3884
+ */
3885
+ function resolveMarkTarget(words, target) {
3886
+ const needle = markableWords(typeof target.text === "string" ? target.text : "").map((word) => wordKey(word.text));
3887
+ if (needle.length === 0) return {
3888
+ ok: false,
3889
+ problem: "no-words",
3890
+ found: 0
3891
+ };
3892
+ const places = placesOf(words.map((word) => wordKey(word.text)), needle);
3893
+ if (places.length === 0) return {
3894
+ ok: false,
3895
+ problem: "not-found",
3896
+ found: 0
3897
+ };
3898
+ const occurrence = target.occurrence;
3899
+ let at;
3900
+ if (occurrence === void 0) {
3901
+ if (places.length > 1) return {
3902
+ ok: false,
3903
+ problem: "ambiguous",
3904
+ found: places.length
3905
+ };
3906
+ at = places[0];
3907
+ } else {
3908
+ at = typeof occurrence === "number" && Number.isInteger(occurrence) && occurrence >= 1 ? places[occurrence - 1] : void 0;
3909
+ if (at === void 0) return {
3910
+ ok: false,
3911
+ problem: "no-such-occurrence",
3912
+ found: places.length
3913
+ };
3914
+ }
3915
+ return {
3916
+ ok: true,
3917
+ indices: needle.map((_, offset) => at + offset)
3918
+ };
3919
+ }
3920
+ /**
3921
+ * The word positions of each target of an item, in target order; a target
3922
+ * that falls nowhere has none. Read defensively: the scorer is reachable with
3923
+ * content an older build stored.
3924
+ */
3925
+ function markTargetIndices(data) {
3926
+ const words = markableWords(typeof data.text === "string" ? data.text : "");
3927
+ return (Array.isArray(data.targets) ? data.targets : []).map((target) => {
3928
+ if (typeof target !== "object" || target === null) return [];
3929
+ const found = resolveMarkTarget(words, target);
3930
+ return found.ok ? found.indices : [];
3931
+ });
3932
+ }
3933
+ /**
3934
+ * A text and its targets from markup that stars each target, as an H5P "Mark
3935
+ * the Words" text does: `I *went* home and *had gone* to bed.` A `\*` is a
3936
+ * literal star. Each target names its appearance only where its words appear
3937
+ * more than once in the text.
3938
+ *
3939
+ * @throws Error when a star is left open.
3940
+ */
3941
+ function markTheWordsFromMarkup(markup) {
3942
+ let text = "";
3943
+ const spans = [];
3944
+ let open = null;
3945
+ for (let at = 0; at < markup.length; at += 1) {
3946
+ const char = markup[at];
3947
+ if (char === "\\" && markup[at + 1] === "*") {
3948
+ text += "*";
3949
+ at += 1;
3950
+ } else if (char === "*") {
3951
+ if (open === null) open = text.length;
3952
+ else {
3953
+ spans.push({
3954
+ start: open,
3955
+ end: text.length
3956
+ });
3957
+ open = null;
3958
+ }
3959
+ } else text += char;
3960
+ }
3961
+ if (open !== null) throw new Error("markTheWordsFromMarkup: a * is opened and never closed.");
3962
+ const words = markableWords(text);
3963
+ const keys = words.map((word) => wordKey(word.text));
3964
+ const targets = [];
3965
+ for (const span of spans) {
3966
+ const inside = words.filter((word) => word.start >= span.start && word.end <= span.end);
3967
+ const first = inside[0];
3968
+ if (first === void 0) continue;
3969
+ const quote = text.slice(first.start, inside.at(-1).end);
3970
+ const places = placesOf(keys, inside.map((word) => wordKey(word.text)));
3971
+ targets.push(places.length > 1 ? {
3972
+ text: quote,
3973
+ occurrence: places.indexOf(first.index) + 1
3974
+ } : { text: quote });
3975
+ }
3976
+ return {
3977
+ text,
3978
+ targets
3979
+ };
3980
+ }
3981
+ // src/authoring/mark-the-words.ts
3982
+ /**
3983
+ * Draft support for `mark-the-words`.
3984
+ *
3985
+ * A new draft has an empty text and no targets, and `scoringStrategy` starts
3986
+ * at `all-or-nothing`, as on every built-in. An editor usually lets the author
3987
+ * click the words to mark; `markTheWordsFromMarkup` turns starred text into
3988
+ * the same `text` and `targets`.
3989
+ */
3990
+ const markTheWordsAuthoring = {
3991
+ createDraft: ({ newId }) => ({
3992
+ schemaVersion: "1.0",
3993
+ type: "mark-the-words",
3994
+ id: newId(),
3995
+ title: "",
3996
+ text: "",
3997
+ targets: [],
3998
+ scoringStrategy: "all-or-nothing"
3999
+ }),
4000
+ checkDraft: checkMarkTheWordsDraft,
4001
+ critique: critiqueMarkTheWords
4002
+ };
4003
+ /** The words of a draft's text, or `null` when there is no usable text to resolve targets in. */
4004
+ function wordsOf(draft) {
4005
+ if (typeof draft.text !== "string") return null;
4006
+ const words = markableWords(draft.text);
4007
+ return words.length === 0 || words.length > 1500 ? null : words;
4008
+ }
4009
+ function checkMarkTheWordsDraft(draft) {
4010
+ const issues = checkIdentity(draft, "mark-the-words");
4011
+ issues.push(...checkScoringStrategy(draft));
4012
+ const text = draft.text;
4013
+ if (isUnwritten(text)) issues.push(issue("mw_text_required", ["text"], "Write the text the learner marks words in."));
4014
+ else if (typeof text === "string") {
4015
+ const count = markableWords(text).length;
4016
+ if (count === 0) issues.push(issue("mw_text_required", ["text"], "The text has no word to mark yet. Write it out."));
4017
+ 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.`));
4018
+ }
4019
+ const targets = isUnset(draft.targets) ? [] : draft.targets;
4020
+ if (Array.isArray(targets)) {
4021
+ if (targets.length === 0) issues.push(issue("mw_targets_required", ["targets"], "Choose at least one word to mark."));
4022
+ issues.push(...checkTargets(targets, wordsOf(draft)));
4023
+ }
4024
+ issues.push(...checkSharedOptional(draft));
4025
+ return issues;
4026
+ }
4027
+ /**
4028
+ * Every target, each at its own path, where the schema reports it. Against
4029
+ * the text only when the text is usable: an unwritten one is already
4030
+ * reported, and every target would only restate that it is not found.
4031
+ */
4032
+ function checkTargets(targets, words) {
4033
+ const issues = [];
4034
+ const owner = /* @__PURE__ */ new Map();
4035
+ for (const [index, target] of targets.entries()) {
4036
+ if (!isRecord(target)) continue;
4037
+ const ordinal = index + 1;
4038
+ const occurrence = target.occurrence;
4039
+ const occurrenceWellFormed = isUnset(occurrence) || typeof occurrence === "number" && Number.isInteger(occurrence) && occurrence >= 1;
4040
+ if (!occurrenceWellFormed) issues.push(issue("mw_target_occurrence_invalid", [
4041
+ "targets",
4042
+ index,
4043
+ "occurrence"
4044
+ ], `Target ${ordinal}'s appearance must be a whole number from 1, or left out.`));
4045
+ if (isUnwritten(target.text)) {
4046
+ issues.push(issue("mw_target_text_required", [
4047
+ "targets",
4048
+ index,
4049
+ "text"
4050
+ ], `Write the word or words target ${ordinal} marks.`));
4051
+ continue;
4052
+ }
4053
+ if (typeof target.text !== "string" || words === null || !occurrenceWellFormed) continue;
4054
+ const found = resolveMarkTarget(words, {
4055
+ text: target.text,
4056
+ ...isUnset(occurrence) ? {} : { occurrence }
4057
+ });
4058
+ if (!found.ok) {
4059
+ issues.push(targetProblem(found.problem, found.found, index, target.text));
4060
+ continue;
4061
+ }
4062
+ if (found.indices.some((word) => owner.has(word))) {
4063
+ 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.`));
4064
+ continue;
4065
+ }
4066
+ for (const word of found.indices) owner.set(word, index);
4067
+ }
4068
+ return issues;
4069
+ }
4070
+ function targetProblem(problem, count, index, text) {
4071
+ const ordinal = index + 1;
4072
+ switch (problem) {
4073
+ case "no-words": return issue("mw_target_no_words", [
4074
+ "targets",
4075
+ index,
4076
+ "text"
4077
+ ], `Target ${ordinal} has no word in it: punctuation alone cannot be marked.`);
4078
+ case "not-found": return issue("mw_target_not_found", [
4079
+ "targets",
4080
+ index,
4081
+ "text"
4082
+ ], `"${text}" is not in the text as whole words. Copy it from the text, with the same capitals and accents.`);
4083
+ case "ambiguous": return issue("mw_target_ambiguous", [
4084
+ "targets",
4085
+ index,
4086
+ "text"
4087
+ ], `"${text}" appears ${count} times in the text. Say which one target ${ordinal} is, with its appearance (1 to ${count}).`);
4088
+ case "no-such-occurrence": return issue("mw_target_occurrence_invalid", [
4089
+ "targets",
4090
+ index,
4091
+ "occurrence"
4092
+ ], `"${text}" appears ${count === 1 ? "once" : `${count} times`} in the text, so it has no such appearance.`);
4093
+ }
4094
+ }
4095
+ /** Whether `text` writes out `answer` as whole words — a short answer never counts. */
4096
+ function writesOut(text, answer) {
4097
+ const needle = fold(answer);
4098
+ return needle.length > 0 && !isShort(needle) && containsWords(fold(text), needle);
4099
+ }
4100
+ function critiqueMarkTheWords(draft) {
4101
+ const findings = [];
4102
+ const words = wordsOf(draft);
4103
+ const targets = Array.isArray(draft.targets) ? draft.targets : [];
4104
+ if (words === null) return findings;
4105
+ const keys = words.map((word) => wordKey(word.text));
4106
+ const resolved = targets.map((target) => {
4107
+ if (!isRecord(target) || typeof target.text !== "string") return null;
4108
+ const found = resolveMarkTarget(words, {
4109
+ text: target.text,
4110
+ ...typeof target.occurrence === "number" ? { occurrence: target.occurrence } : {}
4111
+ });
4112
+ return found.ok ? found.indices : null;
4113
+ });
4114
+ const marked = new Set(resolved.flatMap((indices) => indices ?? []));
4115
+ const title = typeof draft.title === "string" ? draft.title : "";
4116
+ 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."));
4117
+ if (marked.size > 0 && marked.size * 2 > words.length) {
4118
+ const everything = Math.round((2 * marked.size - words.length) / marked.size * 100);
4119
+ 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.`));
4120
+ }
4121
+ resolved.forEach((indices, index) => {
4122
+ if (indices === null) return;
4123
+ const needle = indices.map((word) => keys[word]);
4124
+ 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)) {
4125
+ 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.`));
4126
+ return;
4127
+ }
4128
+ });
4129
+ return findings;
4130
+ }
3823
4131
  // src/schemas/matching.ts
3824
4132
  /** Zod schema for one option of the shared list. Loose: a consumer sidecar survives validation. */
3825
4133
  const MatchingOptionSchema = zod_v4.looseObject({
@@ -5036,6 +5344,97 @@ const GapSelectDataSchema = zod_v4.looseObject({
5036
5344
  error: "Each gap needs unique choice ids and a `correctChoiceId` that is one of the choices it offers.",
5037
5345
  path: ["gaps"]
5038
5346
  });
5347
+ // src/schemas/mark-the-words.ts
5348
+ /** Zod schema for one target. Loose: a consumer sidecar survives validation. */
5349
+ const MarkTheWordsTargetSchema = zod_v4.looseObject({
5350
+ text: zod_v4.string().min(1),
5351
+ occurrence: zod_v4.number().int().min(1).optional(),
5352
+ feedback: zod_v4.string().optional()
5353
+ });
5354
+ /** What the schema says when a target falls on no words of the text. */
5355
+ const MARK_TARGET_MESSAGES = {
5356
+ "no-words": "A target must contain a word: punctuation alone cannot be marked.",
5357
+ "not-found": "A target must be whole words of the text, in order, written as the text writes them.",
5358
+ ambiguous: "These words appear more than once in the text: say which appearance with `occurrence`.",
5359
+ "no-such-occurrence": "The text does not have that many appearances of these words."
5360
+ };
5361
+ /**
5362
+ * Zod schema validating the full Mark the Words data contract.
5363
+ *
5364
+ * Beyond the shape, three guards, each reported at the field it is about:
5365
+ *
5366
+ * 1. **The text has a word to mark**, and at most 1500 of them.
5367
+ * 2. **Every target falls on whole words of the text** — once, or at the
5368
+ * appearance its `occurrence` names. A target that could be either of two
5369
+ * places is refused: which one is marked right would be a guess.
5370
+ * 3. **No two targets share a word.** A word marked once cannot count twice.
5371
+ */
5372
+ const MarkTheWordsDataSchema = zod_v4.looseObject({
5373
+ schemaVersion: zod_v4.literal("1.0"),
5374
+ type: zod_v4.literal("mark-the-words"),
5375
+ id: zod_v4.string().min(1),
5376
+ title: zod_v4.string().min(1),
5377
+ instructions: zod_v4.string().optional(),
5378
+ text: zod_v4.string().min(1),
5379
+ targets: zod_v4.array(MarkTheWordsTargetSchema).min(1),
5380
+ scoringStrategy: zod_v4.enum(["all-or-nothing", "partial"]),
5381
+ media: MediaSchema.optional(),
5382
+ feedback: FeedbackSchema.optional(),
5383
+ passThreshold: zod_v4.number().min(0).max(1).optional(),
5384
+ locale: zod_v4.string().optional(),
5385
+ learningObjectives: zod_v4.array(zod_v4.string()).optional(),
5386
+ difficultyLevel: zod_v4.literal([
5387
+ 1,
5388
+ 2,
5389
+ 3,
5390
+ 4,
5391
+ 5
5392
+ ]).optional(),
5393
+ ai: AiPermissionsSchema.optional()
5394
+ }).superRefine((data, ctx) => {
5395
+ const words = markableWords(data.text);
5396
+ if (words.length === 0) {
5397
+ ctx.addIssue({
5398
+ code: "custom",
5399
+ path: ["text"],
5400
+ message: "The text has no word to mark."
5401
+ });
5402
+ return;
5403
+ }
5404
+ if (words.length > 1500) {
5405
+ ctx.addIssue({
5406
+ code: "custom",
5407
+ path: ["text"],
5408
+ message: `The text may have at most ${MARK_THE_WORDS_MAX_WORDS} words; it has ${words.length}.`
5409
+ });
5410
+ return;
5411
+ }
5412
+ const owner = /* @__PURE__ */ new Map();
5413
+ data.targets.forEach((target, index) => {
5414
+ const found = resolveMarkTarget(words, target);
5415
+ if (!found.ok) {
5416
+ ctx.addIssue({
5417
+ code: "custom",
5418
+ path: [
5419
+ "targets",
5420
+ index,
5421
+ found.problem === "no-such-occurrence" ? "occurrence" : "text"
5422
+ ],
5423
+ message: MARK_TARGET_MESSAGES[found.problem]
5424
+ });
5425
+ return;
5426
+ }
5427
+ if (found.indices.some((word) => owner.has(word))) {
5428
+ ctx.addIssue({
5429
+ code: "custom",
5430
+ path: ["targets", index],
5431
+ message: "Two targets share a word: each word belongs to one target at most."
5432
+ });
5433
+ return;
5434
+ }
5435
+ for (const word of found.indices) owner.set(word, index);
5436
+ });
5437
+ });
5039
5438
  // src/schemas/written-response.ts
5040
5439
  /** Zod schema for a single rubric criterion. Loose: unknown keys preserved. */
5041
5440
  const WrittenResponseRubricCriterionSchema = zod_v4.looseObject({
@@ -5264,6 +5663,19 @@ const RedactedMatchingDataSchema = zod_v4.strictObject({
5264
5663
  shuffleOptions: zod_v4.boolean().optional()
5265
5664
  });
5266
5665
  /**
5666
+ * Redacted Mark the Words data: the text, and nothing about which words are
5667
+ * right — not the targets, not even how many there are, since a count tells a
5668
+ * learner when to stop. `scoringStrategy` stays answer-key, as on every type:
5669
+ * whether a wrong mark costs anything is the difference between marking
5670
+ * carefully and marking everything.
5671
+ */
5672
+ const RedactedMarkTheWordsDataSchema = zod_v4.strictObject({
5673
+ ...redactedBase,
5674
+ type: zod_v4.literal("mark-the-words"),
5675
+ instructions: zod_v4.string().optional(),
5676
+ text: zod_v4.string().min(1)
5677
+ });
5678
+ /**
5267
5679
  * Redacted Written Response data: the prompt, word bounds and rubric are
5268
5680
  * learner-visible (a rubric tells the learner what they are graded on);
5269
5681
  * authored pass/fail feedback is removed until the grade exists.
@@ -5502,6 +5914,62 @@ function scoreGapSelect(data, response) {
5502
5914
  details
5503
5915
  };
5504
5916
  }
5917
+ // src/scoring/activity-scorers/mark-the-words.ts
5918
+ /**
5919
+ * Scores a Mark the Words response.
5920
+ *
5921
+ * Each target is found when every one of its words is marked. Every marked
5922
+ * word that belongs to no target is a wrong mark, and costs one target's
5923
+ * worth: `partial` is (targets found − wrong marks) / targets, never below 0,
5924
+ * so marking every word is not a strategy. `all-or-nothing` is 1 only with
5925
+ * every target found and no wrong mark.
5926
+ *
5927
+ * A position that names no word, repeats one, or is not a whole number (a
5928
+ * tampered payload, or a text edited after the attempt) is ignored: it marks
5929
+ * nothing, right or wrong.
5930
+ *
5931
+ * Details: one per target (`t1`, `t2`… in target order) — `correct`, or
5932
+ * `incorrect` when only some of its words are marked, or `incorrect-omission`
5933
+ * when none are — with the target's word positions as `correctResponse`; then
5934
+ * one per wrong mark (`w` and the word's position), `incorrect`, with no
5935
+ * correct response. A review reads both to mark the text.
5936
+ */
5937
+ function scoreMarkTheWords(data, response) {
5938
+ const count = markableWords(data.text).length;
5939
+ const raw = Array.isArray(response.marked) ? response.marked : [];
5940
+ const marked = new Set(raw.filter((word) => typeof word === "number" && Number.isInteger(word) && word >= 0).filter((word) => word < count));
5941
+ const targets = markTargetIndices(data);
5942
+ const inTargets = new Set(targets.flat());
5943
+ const details = [];
5944
+ let found = 0;
5945
+ targets.forEach((words, index) => {
5946
+ const hit = words.filter((word) => marked.has(word));
5947
+ const correct = words.length > 0 && hit.length === words.length;
5948
+ if (correct) found += 1;
5949
+ details.push({
5950
+ itemId: `t${index + 1}`,
5951
+ outcome: correct ? "correct" : hit.length > 0 ? "incorrect" : "incorrect-omission",
5952
+ learnerResponse: hit.map(String),
5953
+ correctResponse: words.map(String),
5954
+ weight: 1
5955
+ });
5956
+ });
5957
+ const wrong = [...marked].filter((word) => !inTargets.has(word)).sort((a, b) => a - b);
5958
+ for (const word of wrong) details.push({
5959
+ itemId: `w${word}`,
5960
+ outcome: "incorrect",
5961
+ learnerResponse: [String(word)],
5962
+ correctResponse: [],
5963
+ weight: 1
5964
+ });
5965
+ const total = targets.length;
5966
+ return {
5967
+ score: total === 0 ? 0 : data.scoringStrategy === "all-or-nothing" ? found === total && wrong.length === 0 ? 1 : 0 : Math.max(0, found - wrong.length) / total,
5968
+ maxScore: 1,
5969
+ feedback: null,
5970
+ details
5971
+ };
5972
+ }
5505
5973
  // src/scoring/activity-scorers/matching.ts
5506
5974
  /**
5507
5975
  * Scores a Matching response.
@@ -5832,6 +6300,19 @@ const MATCHING_FIELD_POLICY = {
5832
6300
  feedback: "answer-key"
5833
6301
  }
5834
6302
  };
6303
+ /**
6304
+ * Mark the Words: the text is what the learner marks in, so it is public; the
6305
+ * targets are the key, as one leaf — their number included, since a count
6306
+ * tells a learner when to stop marking.
6307
+ */
6308
+ const MARK_THE_WORDS_FIELD_POLICY = {
6309
+ ...SHARED_PUBLIC_FIELDS,
6310
+ instructions: "public",
6311
+ text: "public",
6312
+ targets: "answer-key",
6313
+ scoringStrategy: "answer-key",
6314
+ feedback: FEEDBACK_FIELD_POLICY
6315
+ };
5835
6316
  const FILL_IN_THE_BLANKS_FIELD_POLICY = {
5836
6317
  ...SHARED_PUBLIC_FIELDS,
5837
6318
  instructions: "public",
@@ -6032,6 +6513,30 @@ const matchingType = defineActivityType({
6032
6513
  authoring: matchingAuthoring,
6033
6514
  mediaSlots: () => MAIN_MEDIA
6034
6515
  });
6516
+ /** Built-in Mark the Words descriptor. */
6517
+ const markTheWordsType = defineActivityType({
6518
+ type: "mark-the-words",
6519
+ schema: MarkTheWordsDataSchema,
6520
+ scoring: {
6521
+ kind: "sync",
6522
+ score: scoreMarkTheWords
6523
+ },
6524
+ isAnswered: (response) => Array.isArray(response?.marked) && response.marked.length > 0,
6525
+ fieldPolicy: MARK_THE_WORDS_FIELD_POLICY,
6526
+ redactedSchema: RedactedMarkTheWordsDataSchema,
6527
+ interop: {
6528
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
6529
+ xapiInteractionType: "choice",
6530
+ correctResponsesPattern: (data) => [markTargetIndices(data).flat().sort((a, b) => a - b).join("[,]")]
6531
+ },
6532
+ interactions: [
6533
+ "word-marked",
6534
+ "word-unmarked",
6535
+ "submitted"
6536
+ ],
6537
+ authoring: markTheWordsAuthoring,
6538
+ mediaSlots: () => MAIN_MEDIA
6539
+ });
6035
6540
  /**
6036
6541
  * Built-in Dictation descriptor. Graded synchronously: the score is the
6037
6542
  * character-level similarity of the typed text to the transcript, a pure
@@ -6209,7 +6714,8 @@ const BUILT_IN_DESCRIPTORS = {
6209
6714
  authoring: speakingResponseAuthoring,
6210
6715
  mediaSlots: () => MAIN_MEDIA
6211
6716
  }),
6212
- matching: matchingType
6717
+ matching: matchingType,
6718
+ "mark-the-words": markTheWordsType
6213
6719
  };
6214
6720
  // src/schemas/read-schema.ts
6215
6721
  /**
@@ -6463,6 +6969,18 @@ Object.defineProperty(exports, "LEVEL_PROBABILITY_TOLERANCE", {
6463
6969
  return LEVEL_PROBABILITY_TOLERANCE;
6464
6970
  }
6465
6971
  });
6972
+ Object.defineProperty(exports, "MARK_THE_WORDS_MAX_WORDS", {
6973
+ enumerable: true,
6974
+ get: function() {
6975
+ return MARK_THE_WORDS_MAX_WORDS;
6976
+ }
6977
+ });
6978
+ Object.defineProperty(exports, "MARK_THE_WORDS_TOKENIZER", {
6979
+ enumerable: true,
6980
+ get: function() {
6981
+ return MARK_THE_WORDS_TOKENIZER;
6982
+ }
6983
+ });
6466
6984
  Object.defineProperty(exports, "MEDIA_FIELD_POLICY", {
6467
6985
  enumerable: true,
6468
6986
  get: function() {
@@ -6775,6 +7293,24 @@ Object.defineProperty(exports, "levenshteinDistance", {
6775
7293
  return levenshteinDistance;
6776
7294
  }
6777
7295
  });
7296
+ Object.defineProperty(exports, "markTargetIndices", {
7297
+ enumerable: true,
7298
+ get: function() {
7299
+ return markTargetIndices;
7300
+ }
7301
+ });
7302
+ Object.defineProperty(exports, "markTheWordsFromMarkup", {
7303
+ enumerable: true,
7304
+ get: function() {
7305
+ return markTheWordsFromMarkup;
7306
+ }
7307
+ });
7308
+ Object.defineProperty(exports, "markableWords", {
7309
+ enumerable: true,
7310
+ get: function() {
7311
+ return markableWords;
7312
+ }
7313
+ });
6778
7314
  Object.defineProperty(exports, "matchText", {
6779
7315
  enumerable: true,
6780
7316
  get: function() {
@@ -6871,6 +7407,12 @@ Object.defineProperty(exports, "registeredActivityTypes", {
6871
7407
  return registeredActivityTypes;
6872
7408
  }
6873
7409
  });
7410
+ Object.defineProperty(exports, "resolveMarkTarget", {
7411
+ enumerable: true,
7412
+ get: function() {
7413
+ return resolveMarkTarget;
7414
+ }
7415
+ });
6874
7416
  Object.defineProperty(exports, "revealsAnswer", {
6875
7417
  enumerable: true,
6876
7418
  get: function() {
@@ -6896,4 +7438,4 @@ Object.defineProperty(exports, "writtenResponseType", {
6896
7438
  }
6897
7439
  });
6898
7440
 
6899
- //# sourceMappingURL=registry-DUTE1vtq.cjs.map
7441
+ //# sourceMappingURL=registry-Dw19lNo2.cjs.map