@intellectif/lk-core 1.11.0 → 1.13.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 +25 -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.ts → ai-hC5pGYeu.d.cts} +122 -5
  8. package/dist/ai-hC5pGYeu.d.cts.map +1 -0
  9. package/dist/{ai-Cu17iVHa.d.cts → ai-hC5pGYeu.d.ts} +122 -5
  10. package/dist/ai-hC5pGYeu.d.ts.map +1 -0
  11. package/dist/{ai-writing-C7f6aKNW.cjs → ai-writing-BDSYuifR.cjs} +18 -5
  12. package/dist/ai-writing-BDSYuifR.cjs.map +1 -0
  13. package/dist/{ai-writing-Dl1bJQbL.js → ai-writing-Dsmvv4T-.js} +18 -5
  14. package/dist/ai-writing-Dsmvv4T-.js.map +1 -0
  15. package/dist/{index-CpamCGyZ.d.cts → index-4x0auNOv.d.cts} +2 -2
  16. package/dist/{index-CpamCGyZ.d.cts.map → index-4x0auNOv.d.cts.map} +1 -1
  17. package/dist/{index-DjZ1vIVg.d.cts → index-BlJU10O4.d.cts} +26 -4
  18. package/dist/index-BlJU10O4.d.cts.map +1 -0
  19. package/dist/{index-BfUyl6ml.d.ts → index-DVW_N4pJ.d.ts} +26 -4
  20. package/dist/index-DVW_N4pJ.d.ts.map +1 -0
  21. package/dist/{index-BTZWUwOe.d.ts → index-Dlei-7PX.d.ts} +2 -2
  22. package/dist/{index-BTZWUwOe.d.ts.map → index-Dlei-7PX.d.ts.map} +1 -1
  23. package/dist/{index-DYnacygI.d.cts → index-DohEHH10.d.cts} +2 -2
  24. package/dist/{index-DYnacygI.d.cts.map → index-DohEHH10.d.cts.map} +1 -1
  25. package/dist/{index-B1zEN1Ur.d.ts → index-Du9Kx_H6.d.ts} +2 -2
  26. package/dist/{index-B1zEN1Ur.d.ts.map → index-Du9Kx_H6.d.ts.map} +1 -1
  27. package/dist/index.cjs +12 -7
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +28 -14
  30. package/dist/index.d.cts.map +1 -1
  31. package/dist/index.d.ts +28 -14
  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-D7M3E7K2.cjs → item-group-CqdByaLR.cjs} +100 -26
  36. package/dist/item-group-CqdByaLR.cjs.map +1 -0
  37. package/dist/{item-group-uNAT5EX5.js → item-group-vTcNy2JF.js} +95 -27
  38. package/dist/item-group-vTcNy2JF.js.map +1 -0
  39. package/dist/{registry-kTqbi4Lb.js → registry-Cn2it-y7.js} +388 -3
  40. package/dist/registry-Cn2it-y7.js.map +1 -0
  41. package/dist/{registry-RPfZZEqi.cjs → registry-DUTE1vtq.cjs} +388 -3
  42. package/dist/registry-DUTE1vtq.cjs.map +1 -0
  43. package/dist/{schemas-C4V5wfOS.cjs → schemas-BrA6CFWz.cjs} +3 -3
  44. package/dist/{schemas-C4V5wfOS.cjs.map → schemas-BrA6CFWz.cjs.map} +1 -1
  45. package/dist/{schemas-rDgIN4Vu.js → schemas-DlxeS7Lu.js} +3 -3
  46. package/dist/{schemas-rDgIN4Vu.js.map → schemas-DlxeS7Lu.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-KpYfR0lO.cjs → scoring-IjLi2fYF.cjs} +2 -2
  52. package/dist/{scoring-KpYfR0lO.cjs.map → scoring-IjLi2fYF.cjs.map} +1 -1
  53. package/dist/{scoring-DshU9i7e.js → scoring-weQ4SzwB.js} +2 -2
  54. package/dist/{scoring-DshU9i7e.js.map → scoring-weQ4SzwB.js.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-CaM7f1sD.cjs → xapi-BqEbpWag.cjs} +2 -2
  60. package/dist/{xapi-CaM7f1sD.cjs.map → xapi-BqEbpWag.cjs.map} +1 -1
  61. package/dist/{xapi-fFq9KEXG.js → xapi-DbRVH_RB.js} +2 -2
  62. package/dist/{xapi-fFq9KEXG.js.map → xapi-DbRVH_RB.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 +934 -1
  69. package/vectors/validation.json +827 -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-C7f6aKNW.cjs.map +0 -1
  73. package/dist/ai-writing-Dl1bJQbL.js.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-D7M3E7K2.cjs.map +0 -1
  77. package/dist/item-group-uNAT5EX5.js.map +0 -1
  78. package/dist/registry-RPfZZEqi.cjs.map +0 -1
  79. package/dist/registry-kTqbi4Lb.js.map +0 -1
@@ -464,7 +464,8 @@ const BUILT_IN_ACTIVITY_TYPES = [
464
464
  "gap-select",
465
465
  "dictation",
466
466
  "read-aloud",
467
- "speaking-response"
467
+ "speaking-response",
468
+ "matching"
468
469
  ];
469
470
  /** Whether `type` names an activity type the SDK ships. */
470
471
  function isBuiltInActivityType(type) {
@@ -2304,6 +2305,7 @@ const DRAFT_ISSUE_SEVERITY = {
2304
2305
  ig_route_stimulus_kind: "invalid",
2305
2306
  ig_route_body_required: "incomplete",
2306
2307
  ig_route_with_timeline: "invalid",
2308
+ ig_route_quiz_mixed: "invalid",
2307
2309
  ig_route_shuffle: "invalid",
2308
2310
  ig_route_item_type: "invalid",
2309
2311
  ig_route_question_item_unknown: "invalid",
@@ -2348,6 +2350,20 @@ const DRAFT_ISSUE_SEVERITY = {
2348
2350
  gs_choice_text_required: "incomplete",
2349
2351
  gs_correct_choice_required: "incomplete",
2350
2352
  gs_correct_choice_unknown: "invalid",
2353
+ mt_prompts_required: "incomplete",
2354
+ mt_prompt_id_required: "invalid",
2355
+ mt_prompt_id_duplicate: "invalid",
2356
+ mt_prompt_text_required: "incomplete",
2357
+ mt_options_too_few: "incomplete",
2358
+ mt_option_id_required: "invalid",
2359
+ mt_option_id_duplicate: "invalid",
2360
+ mt_option_text_required: "incomplete",
2361
+ mt_option_label_required: "incomplete",
2362
+ mt_option_label_duplicate: "invalid",
2363
+ mt_option_use_invalid: "invalid",
2364
+ mt_correct_option_required: "incomplete",
2365
+ mt_correct_option_unknown: "invalid",
2366
+ mt_option_reused: "invalid",
2351
2367
  wr_prompt_required: "incomplete",
2352
2368
  wr_min_words_required: "incomplete",
2353
2369
  wr_min_words_invalid: "invalid",
@@ -3107,6 +3123,10 @@ const ITEM_FINDING_SEVERITY = {
3107
3123
  gs_choices_duplicate: "warning",
3108
3124
  gs_bank_no_distractor: "warning",
3109
3125
  gs_answer_in_passage: "warning",
3126
+ mt_title_reveals_answer: "warning",
3127
+ mt_options_duplicate: "warning",
3128
+ mt_answer_in_prompt: "warning",
3129
+ mt_no_distractor: "warning",
3110
3130
  wr_criterion_name_duplicate: "warning",
3111
3131
  wr_levels_unreadable: "warning",
3112
3132
  ra_text_long_for_time: "warning",
@@ -3377,6 +3397,40 @@ function critiqueGapSelect(draft) {
3377
3397
  });
3378
3398
  return findings;
3379
3399
  }
3400
+ /** Whether `text` writes out `answer` as whole words — an answer of one short word never counts. */
3401
+ function writesOut(text, answer) {
3402
+ const needle = fold(answer);
3403
+ return needle.length > 0 && !isShort(needle) && containsWords(fold(text), needle);
3404
+ }
3405
+ function critiqueMatching(draft) {
3406
+ const findings = [];
3407
+ const prompts = listOf(draft.prompts);
3408
+ const options = listOf(draft.options);
3409
+ const optionText = (option) => isRecord(option) ? written(option.text) : void 0;
3410
+ const answerOf = (prompt) => isRecord(prompt) ? optionText(options.find((option) => isRecord(option) && option.id === prompt.correctOptionId)) : void 0;
3411
+ const title = written(draft.title);
3412
+ if (title !== void 0 && prompts.some((prompt) => {
3413
+ const answer = answerOf(prompt);
3414
+ return answer !== void 0 && writesOut(title, answer);
3415
+ })) findings.push(finding("mt_title_reveals_answer", ["title"], "The title, which the learner sees, contains the answer to a prompt."));
3416
+ for (const { first, second } of duplicates(options, optionText)) findings.push(finding("mt_options_duplicate", [
3417
+ "options",
3418
+ second,
3419
+ "text"
3420
+ ], `Options ${ordinal(first)} and ${ordinal(second)} read the same, so a learner cannot tell them apart.`));
3421
+ prompts.forEach((prompt, index) => {
3422
+ const text = isRecord(prompt) ? written(prompt.text) : void 0;
3423
+ const answer = answerOf(prompt);
3424
+ if (text !== void 0 && answer !== void 0 && writesOut(text, answer)) findings.push(finding("mt_answer_in_prompt", [
3425
+ "prompts",
3426
+ index,
3427
+ "text"
3428
+ ], `Prompt ${ordinal(index)} contains "${answer}", the option that answers it, word for word.`));
3429
+ });
3430
+ const promptCount = prompts.filter(isRecord).length;
3431
+ 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.`));
3432
+ return findings;
3433
+ }
3380
3434
  /** Why a criterion's levels cannot be graded on, said to the author. */
3381
3435
  const LEVELS_PROBLEM = {
3382
3436
  "not-a-list": (criterion) => `Criterion ${criterion}'s levels are not a list`,
@@ -3766,6 +3820,219 @@ function pairsOneToOne(placeholders, gaps) {
3766
3820
  if (new Set(ids).size !== ids.length || placeholders.size !== ids.length) return false;
3767
3821
  return ids.every((id) => typeof id === "string" && placeholders.get(id) === 1);
3768
3822
  }
3823
+ // src/schemas/matching.ts
3824
+ /** Zod schema for one option of the shared list. Loose: a consumer sidecar survives validation. */
3825
+ const MatchingOptionSchema = zod_v4.looseObject({
3826
+ id: zod_v4.string().min(1),
3827
+ label: zod_v4.string().min(1).optional(),
3828
+ text: zod_v4.string().min(1)
3829
+ });
3830
+ /** Zod schema for one prompt. Whether its `correctOptionId` exists is checked on the activity. */
3831
+ const MatchingPromptSchema = zod_v4.looseObject({
3832
+ id: zod_v4.string().min(1),
3833
+ text: zod_v4.string().min(1),
3834
+ correctOptionId: zod_v4.string().min(1),
3835
+ feedback: zod_v4.string().optional()
3836
+ });
3837
+ /** Labels compared as a learner reads them: `"A"`, `" a "` and `"a"` are one key. */
3838
+ function labelKey(label) {
3839
+ return label.trim().toLowerCase();
3840
+ }
3841
+ /** Whether every element of `values` is distinct. */
3842
+ const distinct = (values) => new Set(values).size === values.length;
3843
+ /**
3844
+ * Zod schema validating the full Matching data contract.
3845
+ *
3846
+ * Five semantic guards, each an authoring error that would otherwise reach a
3847
+ * learner:
3848
+ *
3849
+ * 1. **Prompt ids are unique** — the scorer looks a match up by prompt id, so a
3850
+ * duplicate makes one prompt unscoreable.
3851
+ * 2. **Option ids are unique** — an answer key naming an id two options share
3852
+ * means two right answers nobody wrote.
3853
+ * 3. **Option labels are unique**, ignoring case and surrounding space — two
3854
+ * options both labelled "C" cannot be told apart by the key the learner
3855
+ * answers with.
3856
+ * 4. **Every `correctOptionId` names an option.** An answer key pointing at an
3857
+ * option nobody can pick is a prompt that cannot be passed.
3858
+ * 5. **Under `optionUse: 'once'`, no option answers two prompts.** Otherwise
3859
+ * the item says each option is used once while its own key uses one twice.
3860
+ */
3861
+ const MatchingDataSchema = zod_v4.looseObject({
3862
+ schemaVersion: zod_v4.literal("1.0"),
3863
+ type: zod_v4.literal("matching"),
3864
+ id: zod_v4.string().min(1),
3865
+ title: zod_v4.string().min(1),
3866
+ instructions: zod_v4.string().optional(),
3867
+ optionsTitle: zod_v4.string().optional(),
3868
+ prompts: zod_v4.array(MatchingPromptSchema).min(1),
3869
+ options: zod_v4.array(MatchingOptionSchema).min(2),
3870
+ optionUse: zod_v4.enum(["once", "many"]).optional(),
3871
+ scoringStrategy: zod_v4.enum(["all-or-nothing", "partial"]),
3872
+ shuffleOptions: zod_v4.boolean().optional(),
3873
+ media: MediaSchema.optional(),
3874
+ feedback: FeedbackSchema.optional(),
3875
+ passThreshold: zod_v4.number().min(0).max(1).optional(),
3876
+ locale: zod_v4.string().optional(),
3877
+ learningObjectives: zod_v4.array(zod_v4.string()).optional(),
3878
+ difficultyLevel: zod_v4.literal([
3879
+ 1,
3880
+ 2,
3881
+ 3,
3882
+ 4,
3883
+ 5
3884
+ ]).optional(),
3885
+ ai: AiPermissionsSchema.optional()
3886
+ }).refine((data) => distinct(data.prompts.map((prompt) => prompt.id)), {
3887
+ error: "Prompt ids must be unique.",
3888
+ path: ["prompts"]
3889
+ }).refine((data) => distinct(data.options.map((option) => option.id)), {
3890
+ error: "Option ids must be unique.",
3891
+ path: ["options"]
3892
+ }).refine((data) => distinct(data.options.map((option) => option.label).filter((label) => label !== void 0).map(labelKey)), {
3893
+ error: "Option labels must be unique.",
3894
+ path: ["options"]
3895
+ }).refine((data) => {
3896
+ const ids = new Set(data.options.map((option) => option.id));
3897
+ return data.prompts.every((prompt) => ids.has(prompt.correctOptionId));
3898
+ }, {
3899
+ error: "Each prompt needs a `correctOptionId` that is one of the options.",
3900
+ path: ["prompts"]
3901
+ }).refine((data) => data.optionUse !== "once" || distinct(data.prompts.map((prompt) => prompt.correctOptionId)), {
3902
+ error: "With `optionUse: \"once\"`, no option may be the answer to more than one prompt.",
3903
+ path: ["prompts"]
3904
+ });
3905
+ // src/authoring/matching.ts
3906
+ /** Mirrors `MatchingDataSchema`: `options.min(2)`. */
3907
+ const MIN_OPTIONS$1 = 2;
3908
+ const OPTION_USES = ["once", "many"];
3909
+ /**
3910
+ * Draft support for `matching`.
3911
+ *
3912
+ * A new draft has no prompts and no options, and `scoringStrategy` starts at
3913
+ * `all-or-nothing`, as on every built-in: partial credit is the author's to
3914
+ * give — though an exam's matching task is marked prompt by prompt, so one
3915
+ * copied from a paper wants `partial`.
3916
+ *
3917
+ * As in gap-select, no answer is pre-selected: a prompt's `correctOptionId`
3918
+ * starts unwritten, and the draft stays `incomplete` until someone says which
3919
+ * option answers each prompt.
3920
+ */
3921
+ const matchingAuthoring = {
3922
+ createDraft: ({ newId }) => ({
3923
+ schemaVersion: "1.0",
3924
+ type: "matching",
3925
+ id: newId(),
3926
+ title: "",
3927
+ prompts: [],
3928
+ options: [],
3929
+ scoringStrategy: "all-or-nothing"
3930
+ }),
3931
+ checkDraft: checkMatchingDraft,
3932
+ critique: critiqueMatching
3933
+ };
3934
+ function checkMatchingDraft(draft) {
3935
+ const issues = checkIdentity(draft, "matching");
3936
+ issues.push(...checkScoringStrategy(draft));
3937
+ 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\"."));
3938
+ const options = isUnset(draft.options) ? [] : draft.options;
3939
+ const optionIds = /* @__PURE__ */ new Set();
3940
+ if (Array.isArray(options)) issues.push(...checkOptions(options, optionIds));
3941
+ const prompts = isUnset(draft.prompts) ? [] : draft.prompts;
3942
+ if (Array.isArray(prompts)) issues.push(...checkPrompts(prompts, optionIds, draft.optionUse === "once"));
3943
+ issues.push(...checkSharedOptional(draft));
3944
+ return issues;
3945
+ }
3946
+ /**
3947
+ * The shared list. Its uniqueness rules are reported at `options`, where the
3948
+ * schema reports them; what is missing from one option, at that option.
3949
+ */
3950
+ function checkOptions(options, optionIds) {
3951
+ const issues = [];
3952
+ const atOptions = [];
3953
+ 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.`));
3954
+ const repeatedIds = /* @__PURE__ */ new Set();
3955
+ const labels = /* @__PURE__ */ new Map();
3956
+ const repeatedLabels = /* @__PURE__ */ new Set();
3957
+ for (const [index, option] of options.entries()) {
3958
+ if (!isRecord(option)) continue;
3959
+ const ordinal = index + 1;
3960
+ if (isMissingId(option.id)) issues.push(issue("mt_option_id_required", [
3961
+ "options",
3962
+ index,
3963
+ "id"
3964
+ ], `Option ${ordinal} has no id.`));
3965
+ else if (typeof option.id === "string") {
3966
+ if (optionIds.has(option.id)) repeatedIds.add(option.id);
3967
+ optionIds.add(option.id);
3968
+ }
3969
+ if (isUnwritten(option.text)) issues.push(issue("mt_option_text_required", [
3970
+ "options",
3971
+ index,
3972
+ "text"
3973
+ ], `Write the text of option ${ordinal}.`));
3974
+ if (typeof option.label === "string" && option.label.trim() === "") issues.push(issue("mt_option_label_required", [
3975
+ "options",
3976
+ index,
3977
+ "label"
3978
+ ], `Write the label of option ${ordinal}, such as "A" or "iv", or remove it.`));
3979
+ else if (typeof option.label === "string") {
3980
+ const key = labelKey(option.label);
3981
+ if (labels.has(key)) repeatedLabels.add(labels.get(key));
3982
+ else labels.set(key, option.label.trim());
3983
+ }
3984
+ }
3985
+ for (const id of repeatedIds) atOptions.push(issue("mt_option_id_duplicate", ["options"], `More than one option has the id "${id}".`));
3986
+ 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.`));
3987
+ return [...issues, ...atOptions];
3988
+ }
3989
+ /**
3990
+ * Every prompt. Its uniqueness rule and its answer key against the list are
3991
+ * reported at `prompts`, where the schema reports them; a missing id, text or
3992
+ * key, at the prompt's own field.
3993
+ */
3994
+ function checkPrompts(prompts, optionIds, once) {
3995
+ const issues = [];
3996
+ const atPrompts = [];
3997
+ if (prompts.length === 0) issues.push(issue("mt_prompts_required", ["prompts"], "Add at least one prompt to match with an option."));
3998
+ const seen = /* @__PURE__ */ new Set();
3999
+ const repeated = /* @__PURE__ */ new Set();
4000
+ const answering = /* @__PURE__ */ new Map();
4001
+ for (const [index, prompt] of prompts.entries()) {
4002
+ if (!isRecord(prompt)) continue;
4003
+ const ordinal = index + 1;
4004
+ const name = typeof prompt.id === "string" && prompt.id !== "" ? `"${prompt.id}"` : ordinal;
4005
+ if (isMissingId(prompt.id)) issues.push(issue("mt_prompt_id_required", [
4006
+ "prompts",
4007
+ index,
4008
+ "id"
4009
+ ], `Prompt ${ordinal} has no id.`));
4010
+ else if (typeof prompt.id === "string") {
4011
+ if (seen.has(prompt.id)) repeated.add(prompt.id);
4012
+ seen.add(prompt.id);
4013
+ }
4014
+ if (isUnwritten(prompt.text)) issues.push(issue("mt_prompt_text_required", [
4015
+ "prompts",
4016
+ index,
4017
+ "text"
4018
+ ], `Write the text of prompt ${ordinal}.`));
4019
+ const key = prompt.correctOptionId;
4020
+ if (isUnwritten(key)) issues.push(issue("mt_correct_option_required", [
4021
+ "prompts",
4022
+ index,
4023
+ "correctOptionId"
4024
+ ], `Choose the option that answers prompt ${name}.`));
4025
+ else if (typeof key === "string") {
4026
+ 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.`));
4027
+ answering.set(key, [...answering.get(key) ?? [], String(name)]);
4028
+ }
4029
+ }
4030
+ for (const id of repeated) atPrompts.push(issue("mt_prompt_id_duplicate", ["prompts"], `More than one prompt has the id "${id}".`));
4031
+ if (once) {
4032
+ 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.`));
4033
+ }
4034
+ return [...issues, ...atPrompts];
4035
+ }
3769
4036
  // src/schemas/multiple-choice.ts
3770
4037
  /**
3771
4038
  * Zod schema for a single Multiple Choice option. Loose: unknown keys are
@@ -4967,6 +5234,35 @@ const RedactedGapSelectDataSchema = zod_v4.strictObject({
4967
5234
  presentation: zod_v4.literal("dropdown").optional(),
4968
5235
  shuffleChoices: zod_v4.boolean().optional()
4969
5236
  });
5237
+ /** A redacted matching option: unchanged, because the learner picks from the list. */
5238
+ const RedactedMatchingOptionSchema = zod_v4.strictObject({
5239
+ id: zod_v4.string().min(1),
5240
+ label: zod_v4.string().min(1).optional(),
5241
+ text: zod_v4.string().min(1)
5242
+ });
5243
+ /** A redacted prompt: what the learner reads; `correctOptionId` and feedback are gone. */
5244
+ const RedactedMatchingPromptSchema = zod_v4.strictObject({
5245
+ id: zod_v4.string().min(1),
5246
+ text: zod_v4.string().min(1)
5247
+ });
5248
+ /**
5249
+ * Redacted Matching data. As in Gap Select, the list the learner picks from
5250
+ * survives in full and only the key is withheld: each prompt's
5251
+ * `correctOptionId`. `optionUse` survives because it changes what the learner
5252
+ * may do — an exam says "You may use any letter more than once" in its
5253
+ * instructions, and the component moves an option used once. `scoringStrategy`
5254
+ * stays answer-key, as on every type.
5255
+ */
5256
+ const RedactedMatchingDataSchema = zod_v4.strictObject({
5257
+ ...redactedBase,
5258
+ type: zod_v4.literal("matching"),
5259
+ instructions: zod_v4.string().optional(),
5260
+ optionsTitle: zod_v4.string().optional(),
5261
+ prompts: zod_v4.array(RedactedMatchingPromptSchema).min(1),
5262
+ options: zod_v4.array(RedactedMatchingOptionSchema).min(2),
5263
+ optionUse: zod_v4.enum(["once", "many"]).optional(),
5264
+ shuffleOptions: zod_v4.boolean().optional()
5265
+ });
4970
5266
  /**
4971
5267
  * Redacted Written Response data: the prompt, word bounds and rubric are
4972
5268
  * learner-visible (a rubric tells the learner what they are graded on);
@@ -5206,6 +5502,48 @@ function scoreGapSelect(data, response) {
5206
5502
  details
5207
5503
  };
5208
5504
  }
5505
+ // src/scoring/activity-scorers/matching.ts
5506
+ /**
5507
+ * Scores a Matching response.
5508
+ *
5509
+ * Each prompt is an identity comparison between the option the learner matched
5510
+ * it with and `correctOptionId`, as a gap select's gap is. A prompt with no
5511
+ * match, or matched with an id that is not an option (a tampered payload, or
5512
+ * content edited after the attempt), is scored incorrect and reported as
5513
+ * `incorrect-omission`, so an unanswered prompt stays distinguishable from a
5514
+ * wrong one downstream.
5515
+ *
5516
+ * Under `optionUse: 'once'` a response that uses one option twice is scored
5517
+ * prompt by prompt like any other: the SDK's component never produces one, and
5518
+ * a second use earns nothing the key does not give it, since the key uses each
5519
+ * option at most once.
5520
+ */
5521
+ function scoreMatching(data, response) {
5522
+ const optionIds = new Set(data.options.map((option) => option.id));
5523
+ const details = [];
5524
+ const perPromptCorrect = [];
5525
+ for (const prompt of data.prompts) {
5526
+ const raw = response.matches[prompt.id];
5527
+ const matched = typeof raw === "string" ? raw : "";
5528
+ const answered = matched !== "" && optionIds.has(matched);
5529
+ const correct = answered && matched === prompt.correctOptionId;
5530
+ perPromptCorrect.push(correct);
5531
+ details.push({
5532
+ itemId: prompt.id,
5533
+ outcome: correct ? "correct" : answered ? "incorrect" : "incorrect-omission",
5534
+ learnerResponse: [matched],
5535
+ correctResponse: [prompt.correctOptionId],
5536
+ weight: 1
5537
+ });
5538
+ }
5539
+ const correctPrompts = perPromptCorrect.filter(Boolean).length;
5540
+ return {
5541
+ score: data.scoringStrategy === "all-or-nothing" ? allOrNothingStrategy(perPromptCorrect) : partialBlankStrategy(correctPrompts, data.prompts.length),
5542
+ maxScore: 1,
5543
+ feedback: null,
5544
+ details
5545
+ };
5546
+ }
5209
5547
  // src/scoring/activity-scorers/multiple-choice.ts
5210
5548
  /**
5211
5549
  * Scores a Multiple Choice response. Pure; trusts its typed inputs (validation
@@ -5468,6 +5806,32 @@ const GAP_SELECT_FIELD_POLICY = {
5468
5806
  feedback: "answer-key"
5469
5807
  }
5470
5808
  };
5809
+ /**
5810
+ * Matching: as in Gap Select, the list the learner picks from is public, key
5811
+ * by key, and each prompt's `correctOptionId` is the one field withheld.
5812
+ * `optionUse` is public because it changes what the learner may do: the
5813
+ * component moves an option used once, and the paper says so.
5814
+ */
5815
+ const MATCHING_FIELD_POLICY = {
5816
+ ...SHARED_PUBLIC_FIELDS,
5817
+ instructions: "public",
5818
+ optionsTitle: "public",
5819
+ optionUse: "public",
5820
+ shuffleOptions: "public",
5821
+ scoringStrategy: "answer-key",
5822
+ feedback: FEEDBACK_FIELD_POLICY,
5823
+ options: {
5824
+ id: "public",
5825
+ label: "public",
5826
+ text: "public"
5827
+ },
5828
+ prompts: {
5829
+ id: "public",
5830
+ text: "public",
5831
+ correctOptionId: "answer-key",
5832
+ feedback: "answer-key"
5833
+ }
5834
+ };
5471
5835
  const FILL_IN_THE_BLANKS_FIELD_POLICY = {
5472
5836
  ...SHARED_PUBLIC_FIELDS,
5473
5837
  instructions: "public",
@@ -5648,6 +6012,26 @@ const gapSelectType = defineActivityType({
5648
6012
  authoring: gapSelectAuthoring,
5649
6013
  mediaSlots: () => MAIN_MEDIA
5650
6014
  });
6015
+ /** Built-in Matching descriptor. */
6016
+ const matchingType = defineActivityType({
6017
+ type: "matching",
6018
+ schema: MatchingDataSchema,
6019
+ scoring: {
6020
+ kind: "sync",
6021
+ score: scoreMatching
6022
+ },
6023
+ isAnswered: (response) => Object.values(response?.matches ?? {}).some((optionId) => optionId !== ""),
6024
+ fieldPolicy: MATCHING_FIELD_POLICY,
6025
+ redactedSchema: RedactedMatchingDataSchema,
6026
+ interop: {
6027
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
6028
+ xapiInteractionType: "matching",
6029
+ correctResponsesPattern: (data) => [data.prompts.map((prompt) => `${prompt.id}[.]${prompt.correctOptionId}`).join("[,]")]
6030
+ },
6031
+ interactions: ["option-matched", "submitted"],
6032
+ authoring: matchingAuthoring,
6033
+ mediaSlots: () => MAIN_MEDIA
6034
+ });
5651
6035
  /**
5652
6036
  * Built-in Dictation descriptor. Graded synchronously: the score is the
5653
6037
  * character-level similarity of the typed text to the transcript, a pure
@@ -5824,7 +6208,8 @@ const BUILT_IN_DESCRIPTORS = {
5824
6208
  ],
5825
6209
  authoring: speakingResponseAuthoring,
5826
6210
  mediaSlots: () => MAIN_MEDIA
5827
- })
6211
+ }),
6212
+ matching: matchingType
5828
6213
  };
5829
6214
  // src/schemas/read-schema.ts
5830
6215
  /**
@@ -6511,4 +6896,4 @@ Object.defineProperty(exports, "writtenResponseType", {
6511
6896
  }
6512
6897
  });
6513
6898
 
6514
- //# sourceMappingURL=registry-RPfZZEqi.cjs.map
6899
+ //# sourceMappingURL=registry-DUTE1vtq.cjs.map