@intellectif/lk-core 0.18.0 → 0.20.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.
package/dist/index.js CHANGED
@@ -1,23 +1,3 @@
1
- import {
2
- DEFAULT_PASS_THRESHOLD,
3
- alignReadAloud,
4
- classifyBand,
5
- composeAssessmentScore,
6
- computePassThreshold,
7
- diffDictationChars,
8
- evaluate,
9
- gradeFromRubric,
10
- gradeReadAloud,
11
- gte,
12
- hasGrade,
13
- inspectWav,
14
- isGradeInRange,
15
- outcomeFromGrade,
16
- outcomeFromUnscorable,
17
- roundGrade,
18
- score,
19
- validateSpeechAssessment
20
- } from "./chunk-MMEQKFFQ.js";
21
1
  import {
22
2
  XAPIVerb,
23
3
  XAPI_VERB_DISPLAY,
@@ -51,6 +31,40 @@ import {
51
31
  validateItemGroup,
52
32
  writtenResponseJsonSchema
53
33
  } from "./chunk-Z7QQZ53L.js";
34
+ import {
35
+ AI_EXPLANATION_TYPES,
36
+ AI_HINT_TYPES,
37
+ AI_TEXT_MAX_LENGTH,
38
+ aiAllowedByContent,
39
+ aiExplanationRequest,
40
+ aiGradeOf,
41
+ aiHintRequest,
42
+ aiSupports,
43
+ buildAiFacts,
44
+ checkAiExplanation,
45
+ checkAiHint,
46
+ hintRevealsAnswer,
47
+ readAiTextResult
48
+ } from "./chunk-BIJCO2HM.js";
49
+ import {
50
+ DEFAULT_PASS_THRESHOLD,
51
+ alignReadAloud,
52
+ classifyBand,
53
+ composeAssessmentScore,
54
+ computePassThreshold,
55
+ diffDictationChars,
56
+ evaluate,
57
+ gradeFromRubric,
58
+ gradeReadAloud,
59
+ gte,
60
+ hasGrade,
61
+ inspectWav,
62
+ outcomeFromGrade,
63
+ outcomeFromUnscorable,
64
+ roundGrade,
65
+ score,
66
+ validateSpeechAssessment
67
+ } from "./chunk-MMEQKFFQ.js";
54
68
  import {
55
69
  ActivitySchemaError,
56
70
  AiPermissionsSchema,
@@ -82,7 +96,6 @@ import {
82
96
  MultipleChoiceOptionMediaSchema,
83
97
  MultipleChoiceOptionSchema,
84
98
  NativeControlHintSchema,
85
- PLACEHOLDER_RE,
86
99
  READ_ALOUD_MAX_DIMENSION_WEIGHT,
87
100
  READ_ALOUD_MAX_REFERENCE_LENGTH,
88
101
  READ_ALOUD_MAX_SECONDS,
@@ -139,409 +152,6 @@ import {
139
152
  writtenResponseType
140
153
  } from "./chunk-D3GP5MET.js";
141
154
 
142
- // src/ai.ts
143
- var AI_EXPLANATION_TYPES = [
144
- "multiple-choice",
145
- "fill-in-the-blanks",
146
- "gap-select",
147
- "dictation"
148
- ];
149
- var AI_HINT_TYPES = [
150
- "multiple-choice",
151
- "fill-in-the-blanks",
152
- "gap-select"
153
- ];
154
- var AI_TEXT_MAX_LENGTH = 2e3;
155
- function aiSupports(activityType, feature) {
156
- const types = feature === "hint" ? AI_HINT_TYPES : AI_EXPLANATION_TYPES;
157
- return types.includes(activityType);
158
- }
159
- function aiAllowedByContent(data, feature) {
160
- const permissions = data.ai;
161
- if (typeof permissions !== "object" || permissions === null) {
162
- return true;
163
- }
164
- const key = feature === "hint" ? "hints" : "explanations";
165
- return permissions[key] !== false;
166
- }
167
- function aiGradeOf(result) {
168
- const ratio = result.maxScore > 0 ? result.score / result.maxScore : 0;
169
- let category = "partly-correct";
170
- if (ratio >= 1 - 1e-9) {
171
- category = "correct";
172
- } else if (ratio <= 1e-9) {
173
- category = "incorrect";
174
- }
175
- return { score: result.score, maxScore: result.maxScore, passed: result.passed, category };
176
- }
177
- function placeholderOrder(passage) {
178
- return [...passage.matchAll(PLACEHOLDER_RE)].map((match) => match[1]);
179
- }
180
- function numberedPassage(passage) {
181
- let position = 0;
182
- return passage.replace(PLACEHOLDER_RE, () => {
183
- position += 1;
184
- return `[${position}]`;
185
- });
186
- }
187
- function isAnswerOption(detail, chosen) {
188
- switch (detail.outcome) {
189
- case "correct":
190
- case "incorrect-omission":
191
- return true;
192
- case "incorrect":
193
- case "correct-omission":
194
- return false;
195
- default:
196
- return chosen ? detail.correct : !detail.correct;
197
- }
198
- }
199
- var firstString = (value) => Array.isArray(value) ? value[0] : value;
200
- var listOf = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
201
- function emptyResponse(type) {
202
- switch (type) {
203
- case "multiple-choice":
204
- return { type: "multiple-choice", selectedOptionIds: [] };
205
- case "fill-in-the-blanks":
206
- return { type: "fill-in-the-blanks", answers: {} };
207
- case "gap-select":
208
- return { type: "gap-select", selections: {} };
209
- case "dictation":
210
- return { type: "dictation", text: "" };
211
- default:
212
- return null;
213
- }
214
- }
215
- var base = (data) => ({
216
- activityId: data.id,
217
- title: data.title,
218
- ...typeof data.locale === "string" ? { locale: data.locale } : {}
219
- });
220
- function buildAiFacts(data, response, details) {
221
- const byId = new Map((details ?? []).map((detail) => [detail.itemId, detail]));
222
- const full = data.redacted !== true;
223
- switch (data.type) {
224
- case "multiple-choice": {
225
- const item = data;
226
- const chosen = new Set(
227
- response?.type === "multiple-choice" ? response.selectedOptionIds : []
228
- );
229
- return {
230
- ...base(data),
231
- activityType: "multiple-choice",
232
- question: item.question,
233
- mode: item.mode,
234
- options: item.options.map((option) => {
235
- const detail = byId.get(option.id);
236
- const wasChosen = chosen.has(option.id);
237
- let correct = null;
238
- if (full && typeof option.isCorrect === "boolean") {
239
- correct = option.isCorrect;
240
- } else if (detail !== void 0) {
241
- correct = isAnswerOption(detail, wasChosen);
242
- }
243
- return {
244
- id: option.id,
245
- text: option.text,
246
- chosen: wasChosen,
247
- correct,
248
- ...typeof option.feedback === "string" ? { feedback: option.feedback } : {}
249
- };
250
- })
251
- };
252
- }
253
- case "fill-in-the-blanks": {
254
- const item = data;
255
- const answers = response?.type === "fill-in-the-blanks" ? response.answers : {};
256
- const blanks = new Map(item.blanks.map((blank) => [blank.id, blank]));
257
- return {
258
- ...base(data),
259
- activityType: "fill-in-the-blanks",
260
- passage: numberedPassage(item.passage),
261
- blanks: placeholderOrder(item.passage).map((id, index) => {
262
- const blank = blanks.get(id);
263
- const detail = byId.get(id);
264
- const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf(detail?.correctResponse);
265
- return {
266
- id,
267
- position: index + 1,
268
- typed: typeof answers[id] === "string" ? answers[id] : "",
269
- accepted,
270
- correct: detail === void 0 ? null : detail.correct,
271
- ...typeof blank?.hint === "string" ? { hint: blank.hint } : {},
272
- ...typeof blank?.feedback === "string" ? { feedback: blank.feedback } : {}
273
- };
274
- })
275
- };
276
- }
277
- case "gap-select": {
278
- const item = data;
279
- const selections = response?.type === "gap-select" ? response.selections : {};
280
- const gaps = new Map(item.gaps.map((gap) => [gap.id, gap]));
281
- const banks = new Map((item.banks ?? []).map((bank) => [bank.id, bank]));
282
- return {
283
- ...base(data),
284
- activityType: "gap-select",
285
- passage: numberedPassage(item.passage),
286
- gaps: placeholderOrder(item.passage).map((id, index) => {
287
- const gap = gaps.get(id);
288
- const choices = gap?.choices ?? (gap?.bankId === void 0 ? [] : banks.get(gap.bankId)?.choices ?? []);
289
- const textOf = (choiceId) => choiceId === void 0 ? null : choices.find((choice) => choice.id === choiceId)?.text ?? null;
290
- const detail = byId.get(id);
291
- const answerId = full && typeof gap?.correctChoiceId === "string" ? gap.correctChoiceId : firstString(detail?.correctResponse);
292
- return {
293
- id,
294
- position: index + 1,
295
- choices: choices.map((choice) => choice.text),
296
- chosen: textOf(selections[id]),
297
- answer: textOf(answerId),
298
- correct: detail === void 0 ? null : detail.correct,
299
- ...typeof gap?.feedback === "string" ? { feedback: gap.feedback } : {}
300
- };
301
- })
302
- };
303
- }
304
- case "dictation": {
305
- const item = data;
306
- const typed = response?.type === "dictation" ? response.text : "";
307
- const hasKey = full && typeof item.transcript === "string";
308
- let words = null;
309
- if (hasKey) {
310
- words = alignDictation(item, typed).words.map((word) => ({
311
- expected: word.reference,
312
- typed: word.attempt,
313
- status: word.status
314
- }));
315
- } else if (details !== null && details.length > 0) {
316
- words = details.map((detail) => {
317
- const expected = firstString(detail.correctResponse) ?? "";
318
- const attempt = firstString(detail.learnerResponse) ?? "";
319
- let status = "incorrect";
320
- if (detail.correct) {
321
- status = "correct";
322
- } else if (attempt === "") {
323
- status = "missing";
324
- } else if (expected === "") {
325
- status = "extra";
326
- }
327
- return { expected, typed: attempt, status };
328
- });
329
- }
330
- return {
331
- ...base(data),
332
- activityType: "dictation",
333
- transcript: hasKey ? item.transcript : null,
334
- typed,
335
- words
336
- };
337
- }
338
- default:
339
- return null;
340
- }
341
- }
342
- function gradeLocally(data, response) {
343
- if (data.redacted === true) {
344
- return null;
345
- }
346
- try {
347
- const result = score(data.type, data, response);
348
- return { details: result.details, grade: aiGradeOf(result) };
349
- } catch {
350
- return null;
351
- }
352
- }
353
- function aiExplanationRequest(input) {
354
- const { data, response, outcome, learnerLocale } = input;
355
- if (!aiSupports(data.type, "explanation") || !aiAllowedByContent(data, "explanation")) {
356
- return null;
357
- }
358
- let details = null;
359
- let grade = null;
360
- if (outcome?.status === "scored") {
361
- const { score: recorded, maxScore, passed } = outcome;
362
- if (!isGradeInRange(recorded, maxScore)) {
363
- return null;
364
- }
365
- details = outcome.details;
366
- grade = aiGradeOf({ score: recorded, maxScore, passed });
367
- } else if (response !== null) {
368
- const local = gradeLocally(data, response);
369
- if (local !== null) {
370
- details = local.details;
371
- grade = local.grade;
372
- }
373
- }
374
- if (grade === null) {
375
- return null;
376
- }
377
- const facts = buildAiFacts(data, response, details);
378
- if (facts === null) {
379
- return null;
380
- }
381
- return {
382
- feature: "explanation",
383
- facts,
384
- grade,
385
- ...learnerLocale !== void 0 ? { learnerLocale } : {}
386
- };
387
- }
388
- function aiHintRequest(input) {
389
- const { data, previousHints, learnerLocale } = input;
390
- if (!aiSupports(data.type, "hint") || !aiAllowedByContent(data, "hint") || data.redacted === true) {
391
- return null;
392
- }
393
- const response = input.response ?? emptyResponse(data.type);
394
- const local = response === null ? null : gradeLocally(data, response);
395
- const facts = buildAiFacts(data, response, local?.details ?? null);
396
- if (facts === null) {
397
- return null;
398
- }
399
- return {
400
- feature: "hint",
401
- facts,
402
- hintNumber: previousHints.length + 1,
403
- previousHints: [...previousHints],
404
- ...learnerLocale !== void 0 ? { learnerLocale } : {}
405
- };
406
- }
407
- var VERDICTS = ["correct", "partly-correct", "incorrect"];
408
- function provenanceOf(raw) {
409
- if (typeof raw !== "object" || raw === null) {
410
- return void 0;
411
- }
412
- const kept = {};
413
- for (const key of ["model", "promptHash", "generatedAt"]) {
414
- const value = raw[key];
415
- if (typeof value === "string" && value.length > 0 && value.length <= 200) {
416
- kept[key] = value;
417
- }
418
- }
419
- return Object.keys(kept).length > 0 ? kept : void 0;
420
- }
421
- function cleanText(text) {
422
- return text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g, "").replace(/\n{3,}/g, "\n\n").trim();
423
- }
424
- function readAiTextResult(raw) {
425
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
426
- return { ok: false, refusal: "malformed" };
427
- }
428
- const record = raw;
429
- if (typeof record.text !== "string") {
430
- return { ok: false, refusal: "malformed" };
431
- }
432
- if (record.verdict !== void 0 && !VERDICTS.includes(record.verdict)) {
433
- return { ok: false, refusal: "malformed" };
434
- }
435
- const text = cleanText(record.text);
436
- if (text === "") {
437
- return { ok: false, refusal: "empty" };
438
- }
439
- if ([...text].length > AI_TEXT_MAX_LENGTH) {
440
- return { ok: false, refusal: "too-long" };
441
- }
442
- const provenance = provenanceOf(record.provenance);
443
- return {
444
- ok: true,
445
- result: {
446
- text,
447
- ...record.verdict !== void 0 ? { verdict: record.verdict } : {},
448
- ...provenance !== void 0 ? { provenance } : {}
449
- }
450
- };
451
- }
452
- function checkAiExplanation(raw, request) {
453
- const read = readAiTextResult(raw);
454
- if (!read.ok) {
455
- return read;
456
- }
457
- if (read.result.verdict !== void 0 && read.result.verdict !== request.grade.category) {
458
- return { ok: false, refusal: "contradicts-grade" };
459
- }
460
- return read;
461
- }
462
- function checkAiHint(raw, request) {
463
- const read = readAiTextResult(raw);
464
- if (!read.ok) {
465
- return read;
466
- }
467
- if (hintRevealsAnswer(request.facts, read.result.text)) {
468
- return { ok: false, refusal: "reveals-answer" };
469
- }
470
- return read;
471
- }
472
- function fold(text) {
473
- const folded = text.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
474
- return folded === "" ? [] : folded.split(" ");
475
- }
476
- function containsWords(haystack, needle) {
477
- if (needle.length === 0 || needle.length > haystack.length) {
478
- return false;
479
- }
480
- for (let start = 0; start + needle.length <= haystack.length; start += 1) {
481
- let match = true;
482
- for (let offset = 0; offset < needle.length; offset += 1) {
483
- if (haystack[start + offset] !== needle[offset]) {
484
- match = false;
485
- break;
486
- }
487
- }
488
- if (match) {
489
- return true;
490
- }
491
- }
492
- return false;
493
- }
494
- var isShort = (words) => words.length === 1 && [...words[0]].length < 4;
495
- function neighbours(passage, position) {
496
- const marker = `[${position}]`;
497
- const at = passage.indexOf(marker);
498
- if (at === -1) {
499
- return {};
500
- }
501
- const strip = (text) => text.replace(/\[\d+\]/g, " ");
502
- const left = fold(strip(passage.slice(0, at)));
503
- const right = fold(strip(passage.slice(at + marker.length)));
504
- const before = left[left.length - 1];
505
- const after = right[0];
506
- return {
507
- ...before !== void 0 ? { before } : {},
508
- ...after !== void 0 ? { after } : {}
509
- };
510
- }
511
- function hintRevealsAnswer(facts, hint) {
512
- const words = fold(hint);
513
- const reveals = (answer, passage, position) => {
514
- const needle = fold(answer);
515
- if (needle.length === 0) {
516
- return false;
517
- }
518
- if (!isShort(needle) || passage === void 0 || position === void 0) {
519
- return containsWords(words, needle);
520
- }
521
- const { before, after } = neighbours(passage, position);
522
- if (before === void 0 && after === void 0) {
523
- return containsWords(words, needle);
524
- }
525
- return before !== void 0 && containsWords(words, [before, ...needle]) || after !== void 0 && containsWords(words, [...needle, after]);
526
- };
527
- switch (facts.activityType) {
528
- case "multiple-choice":
529
- return facts.options.some((option) => option.correct === true && reveals(option.text));
530
- case "fill-in-the-blanks":
531
- return facts.blanks.some(
532
- (blank) => (blank.accepted ?? []).some((answer) => reveals(answer, facts.passage, blank.position))
533
- );
534
- case "gap-select":
535
- return facts.gaps.some(
536
- (gap) => gap.answer !== null && reveals(gap.answer, facts.passage, gap.position)
537
- );
538
- case "dictation":
539
- return false;
540
- default:
541
- return false;
542
- }
543
- }
544
-
545
155
  // src/content-hash.ts
546
156
  function canonicalJson(value, seen = /* @__PURE__ */ new Set()) {
547
157
  if (value === null) {
@@ -598,6 +208,106 @@ function contentHash(value) {
598
208
  return fingerprint(canonicalJson(value));
599
209
  }
600
210
 
211
+ // src/delivery.ts
212
+ var OPEN_DELIVERY_POLICY = Object.freeze({
213
+ feedback: true,
214
+ solutions: true,
215
+ hints: true,
216
+ ai: Object.freeze({ hints: true, explanations: true })
217
+ });
218
+ var SETTINGS = ["feedback", "solutions", "hints"];
219
+ var AI_SETTINGS = ["hints", "explanations"];
220
+ function allows(value) {
221
+ return value === void 0 || value === null || value === true;
222
+ }
223
+ function resolveDeliveryPolicy(policy) {
224
+ if (policy === void 0 || policy === null) {
225
+ return OPEN_DELIVERY_POLICY;
226
+ }
227
+ if (typeof policy !== "object" || Array.isArray(policy)) {
228
+ return {
229
+ feedback: false,
230
+ solutions: false,
231
+ hints: false,
232
+ ai: { hints: false, explanations: false }
233
+ };
234
+ }
235
+ const given = policy;
236
+ const ai = given.ai;
237
+ const aiGiven = ai === void 0 || ai === null || ai === true ? {} : typeof ai === "object" && !Array.isArray(ai) ? ai : { hints: false, explanations: false };
238
+ return {
239
+ feedback: allows(given.feedback),
240
+ solutions: allows(given.solutions),
241
+ hints: allows(given.hints),
242
+ ai: { hints: allows(aiGiven.hints), explanations: allows(aiGiven.explanations) }
243
+ };
244
+ }
245
+ function combineDeliveryPolicies(...policies) {
246
+ const resolved = policies.map(resolveDeliveryPolicy);
247
+ const every = (read) => resolved.every(read);
248
+ return {
249
+ feedback: every((one) => one.feedback),
250
+ solutions: every((one) => one.solutions),
251
+ hints: every((one) => one.hints),
252
+ ai: {
253
+ hints: every((one) => one.ai.hints),
254
+ explanations: every((one) => one.ai.explanations)
255
+ }
256
+ };
257
+ }
258
+ function validateDeliveryPolicy(policy) {
259
+ const issues = [];
260
+ if (policy === void 0 || policy === null) {
261
+ return { success: true, data: {} };
262
+ }
263
+ if (typeof policy !== "object" || Array.isArray(policy)) {
264
+ return {
265
+ success: false,
266
+ issues: [{ path: "", message: "A delivery policy is an object of settings." }]
267
+ };
268
+ }
269
+ const given = policy;
270
+ for (const key of Object.keys(given)) {
271
+ if (key === "ai") {
272
+ continue;
273
+ }
274
+ if (!SETTINGS.includes(key)) {
275
+ issues.push({
276
+ path: key,
277
+ message: `"${key}" is not a delivery setting. The settings are feedback, solutions, hints and ai.`
278
+ });
279
+ continue;
280
+ }
281
+ const value = given[key];
282
+ if (value !== true && value !== false && value !== null && value !== void 0) {
283
+ issues.push({ path: key, message: `"${key}" must be true, false or null.` });
284
+ }
285
+ }
286
+ const ai = given.ai;
287
+ if (ai !== void 0 && ai !== null && ai !== true && ai !== false) {
288
+ if (typeof ai !== "object" || Array.isArray(ai)) {
289
+ issues.push({
290
+ path: "ai",
291
+ message: '"ai" must be true, false, or an object of hints and explanations.'
292
+ });
293
+ } else {
294
+ for (const [key, value] of Object.entries(ai)) {
295
+ if (!AI_SETTINGS.includes(key)) {
296
+ issues.push({
297
+ path: `ai.${key}`,
298
+ message: `"ai.${key}" is not an AI setting. The settings are hints and explanations.`
299
+ });
300
+ continue;
301
+ }
302
+ if (value !== true && value !== false && value !== null && value !== void 0) {
303
+ issues.push({ path: `ai.${key}`, message: `"ai.${key}" must be true, false or null.` });
304
+ }
305
+ }
306
+ }
307
+ }
308
+ return issues.length > 0 ? { success: false, issues } : { success: true, data: given };
309
+ }
310
+
601
311
  // src/shuffle.ts
602
312
  function hashSeed(input) {
603
313
  let h = 2166136261 >>> 0;
@@ -901,6 +611,7 @@ function planSlot(slot, points) {
901
611
  }
902
612
  function planAttempt(entries, options = {}) {
903
613
  const { seed, shuffleEntries, points } = options;
614
+ const delivery = frozenDelivery(options.delivery);
904
615
  const slots = flattenSequence(entries, {
905
616
  ...shuffleEntries !== void 0 ? { shuffleEntries } : {},
906
617
  ...seed !== void 0 ? { seed } : {}
@@ -915,7 +626,11 @@ function planAttempt(entries, options = {}) {
915
626
  // The plan's own fingerprint covers the slots verbatim — identity, order,
916
627
  // points and content hashes — so one stored value answers "is this still
917
628
  // the paper that was sat?" and the per-slot hashes then say what moved.
918
- planHash: contentHash(planned),
629
+ // A policy joins it when there is one: the same paper sat with hints and
630
+ // without is two different attempts. Without one, the fingerprint is the
631
+ // one every plan before policies had.
632
+ planHash: contentHash(delivery === void 0 ? planned : { slots: planned, delivery }),
633
+ ...delivery !== void 0 ? { delivery } : {},
919
634
  slots: planned,
920
635
  totalPoints
921
636
  };
@@ -930,6 +645,7 @@ function verifyAttemptPlan(plan, current) {
930
645
  const changedCueSlotIds = [];
931
646
  const changedPointsSlotIds = [];
932
647
  const reorderedSlotIds = [];
648
+ const deliveryChanged = contentHash(plan.delivery ?? null) !== contentHash(current.delivery ?? null);
933
649
  for (const slot of plan.slots) {
934
650
  const now = after.get(slot.slotId);
935
651
  if (now === void 0) {
@@ -952,16 +668,33 @@ function verifyAttemptPlan(plan, current) {
952
668
  }
953
669
  }
954
670
  return {
955
- matches: missingSlotIds.length === 0 && addedSlotIds.length === 0 && changedSlotIds.length === 0 && changedStimulusSlotIds.length === 0 && changedCueSlotIds.length === 0 && changedPointsSlotIds.length === 0 && reorderedSlotIds.length === 0,
671
+ matches: missingSlotIds.length === 0 && addedSlotIds.length === 0 && changedSlotIds.length === 0 && changedStimulusSlotIds.length === 0 && changedCueSlotIds.length === 0 && changedPointsSlotIds.length === 0 && reorderedSlotIds.length === 0 && !deliveryChanged,
956
672
  missingSlotIds,
957
673
  addedSlotIds,
958
674
  changedSlotIds,
959
675
  changedStimulusSlotIds,
960
676
  changedCueSlotIds,
961
677
  changedPointsSlotIds,
962
- reorderedSlotIds
678
+ reorderedSlotIds,
679
+ // Present only when it is true, as `rejectedSlotIds` is on a composed
680
+ // score: a drift report between two plans without a policy — every one
681
+ // written before policies existed — keeps exactly the shape it had.
682
+ ...deliveryChanged ? { deliveryChanged: true } : {}
963
683
  };
964
684
  }
685
+ function frozenDelivery(policy) {
686
+ if (policy === void 0 || policy === null) {
687
+ return void 0;
688
+ }
689
+ const checked = validateDeliveryPolicy(policy);
690
+ if (!checked.success) {
691
+ const first = checked.issues[0];
692
+ throw new Error(
693
+ `planAttempt: the delivery policy is not one to record \u2014 ${first?.message ?? "invalid"}` + (checked.issues.length > 1 ? ` (and ${checked.issues.length - 1} more)` : "")
694
+ );
695
+ }
696
+ return resolveDeliveryPolicy(checked.data);
697
+ }
965
698
  function missingOutcome(slot, policy) {
966
699
  if (typeof policy === "function") {
967
700
  return policy(slot);
@@ -1625,11 +1358,11 @@ function normalise(found) {
1625
1358
  }
1626
1359
 
1627
1360
  // src/redact.ts
1628
- function mergePolicy(base2, overrides) {
1361
+ function mergePolicy(base, overrides) {
1629
1362
  if (overrides === void 0) {
1630
- return base2;
1363
+ return base;
1631
1364
  }
1632
- const merged = { ...base2 };
1365
+ const merged = { ...base };
1633
1366
  for (const [key, override] of Object.entries(overrides)) {
1634
1367
  const current = merged[key];
1635
1368
  merged[key] = typeof override === "object" && typeof current === "object" ? { ...current, ...override } : override;
@@ -1927,6 +1660,7 @@ export {
1927
1660
  MultipleChoiceOptionMediaSchema,
1928
1661
  MultipleChoiceOptionSchema,
1929
1662
  NativeControlHintSchema,
1663
+ OPEN_DELIVERY_POLICY,
1930
1664
  READ_ALOUD_MAX_DIMENSION_WEIGHT,
1931
1665
  READ_ALOUD_MAX_REFERENCE_LENGTH,
1932
1666
  READ_ALOUD_MAX_SECONDS,
@@ -1979,6 +1713,7 @@ export {
1979
1713
  checkAiExplanation,
1980
1714
  checkAiHint,
1981
1715
  classifyBand,
1716
+ combineDeliveryPolicies,
1982
1717
  composeAssessmentScore,
1983
1718
  composeTimelineScore,
1984
1719
  computePassThreshold,
@@ -2029,6 +1764,7 @@ export {
2029
1764
  redactItemGroup,
2030
1765
  registerActivityType,
2031
1766
  registeredActivityTypes,
1767
+ resolveDeliveryPolicy,
2032
1768
  resolvePlaybackPolicy,
2033
1769
  restoreAttemptState,
2034
1770
  restoreMediaPlayLedger,
@@ -2042,6 +1778,7 @@ export {
2042
1778
  stimulusJsonSchema,
2043
1779
  stimulusMediaKey,
2044
1780
  validateActivity,
1781
+ validateDeliveryPolicy,
2045
1782
  validateDraft,
2046
1783
  validateItemGroup,
2047
1784
  validateItemGroupDraft,