@intellectif/lk-core 1.2.0 → 1.3.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 (103) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/dist/ai-B222dtHH.d.cts +2154 -0
  4. package/dist/ai-B222dtHH.d.cts.map +1 -0
  5. package/dist/ai-B222dtHH.d.ts +2154 -0
  6. package/dist/ai-B222dtHH.d.ts.map +1 -0
  7. package/dist/ai-check.cjs +749 -617
  8. package/dist/ai-check.cjs.map +1 -1
  9. package/dist/ai-check.d.cts +82 -80
  10. package/dist/ai-check.d.cts.map +1 -0
  11. package/dist/ai-check.d.ts +82 -80
  12. package/dist/ai-check.d.ts.map +1 -0
  13. package/dist/ai-check.js +747 -618
  14. package/dist/ai-check.js.map +1 -1
  15. package/dist/ai-writing-CCBjTOM3.js +2496 -0
  16. package/dist/ai-writing-CCBjTOM3.js.map +1 -0
  17. package/dist/ai-writing-DCVkv4Zj.cjs +2735 -0
  18. package/dist/ai-writing-DCVkv4Zj.cjs.map +1 -0
  19. package/dist/{index-BChYtrEe.d.ts → index-BWPRPIXL.d.ts} +263 -258
  20. package/dist/index-BWPRPIXL.d.ts.map +1 -0
  21. package/dist/index-CXyOV8sm.d.cts +153 -0
  22. package/dist/index-CXyOV8sm.d.cts.map +1 -0
  23. package/dist/index-CegM8o15.d.ts +153 -0
  24. package/dist/index-CegM8o15.d.ts.map +1 -0
  25. package/dist/index-Dyu66R4-.d.cts +1071 -0
  26. package/dist/index-Dyu66R4-.d.cts.map +1 -0
  27. package/dist/{index-VXt_v3YI.d.cts → index-O4_ExlAD.d.cts} +263 -258
  28. package/dist/index-O4_ExlAD.d.cts.map +1 -0
  29. package/dist/index-UGxRSopE.d.ts +1071 -0
  30. package/dist/index-UGxRSopE.d.ts.map +1 -0
  31. package/dist/index.cjs +1210 -1146
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +909 -857
  34. package/dist/index.d.cts.map +1 -0
  35. package/dist/index.d.ts +909 -857
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +1063 -1147
  38. package/dist/index.js.map +1 -1
  39. package/dist/item-group-DXiR8nOr.js +445 -0
  40. package/dist/item-group-DXiR8nOr.js.map +1 -0
  41. package/dist/item-group-DroFXgLb.cjs +505 -0
  42. package/dist/item-group-DroFXgLb.cjs.map +1 -0
  43. package/dist/registry-DVi0LR_i.cjs +5915 -0
  44. package/dist/registry-DVi0LR_i.cjs.map +1 -0
  45. package/dist/registry-Dsx9mNsS.js +5449 -0
  46. package/dist/registry-Dsx9mNsS.js.map +1 -0
  47. package/dist/schemas-Bdw0nGPo.js +152 -0
  48. package/dist/schemas-Bdw0nGPo.js.map +1 -0
  49. package/dist/schemas-DVbAOrtd.cjs +224 -0
  50. package/dist/schemas-DVbAOrtd.cjs.map +1 -0
  51. package/dist/schemas.cjs +16 -34
  52. package/dist/schemas.d.cts +2 -2
  53. package/dist/schemas.d.ts +2 -2
  54. package/dist/schemas.js +3 -34
  55. package/dist/scoring-B2_HEhMT.cjs +1748 -0
  56. package/dist/scoring-B2_HEhMT.cjs.map +1 -0
  57. package/dist/scoring-BZqUkuDF.js +1604 -0
  58. package/dist/scoring-BZqUkuDF.js.map +1 -0
  59. package/dist/scoring.cjs +37 -75
  60. package/dist/scoring.d.cts +3 -1103
  61. package/dist/scoring.d.ts +3 -1103
  62. package/dist/scoring.js +3 -75
  63. package/dist/xapi-CMTph7wG.js +332 -0
  64. package/dist/xapi-CMTph7wG.js.map +1 -0
  65. package/dist/xapi-DNzkSJ0S.cjs +362 -0
  66. package/dist/xapi-DNzkSJ0S.cjs.map +1 -0
  67. package/dist/xapi.cjs +7 -16
  68. package/dist/xapi.d.cts +2 -150
  69. package/dist/xapi.d.ts +2 -150
  70. package/dist/xapi.js +2 -16
  71. package/package.json +4 -4
  72. package/dist/activity-D705CXng.d.cts +0 -2162
  73. package/dist/activity-D705CXng.d.ts +0 -2162
  74. package/dist/chunk-46BXIGLG.cjs +0 -272
  75. package/dist/chunk-46BXIGLG.cjs.map +0 -1
  76. package/dist/chunk-4YANYTFN.cjs +0 -1256
  77. package/dist/chunk-4YANYTFN.cjs.map +0 -1
  78. package/dist/chunk-7KOWU4RO.js +0 -2207
  79. package/dist/chunk-7KOWU4RO.js.map +0 -1
  80. package/dist/chunk-7THXD7AN.cjs +0 -5057
  81. package/dist/chunk-7THXD7AN.cjs.map +0 -1
  82. package/dist/chunk-CYCTIXUN.cjs +0 -118
  83. package/dist/chunk-CYCTIXUN.cjs.map +0 -1
  84. package/dist/chunk-CYPA5BW5.js +0 -118
  85. package/dist/chunk-CYPA5BW5.js.map +0 -1
  86. package/dist/chunk-JTNDIBZO.js +0 -272
  87. package/dist/chunk-JTNDIBZO.js.map +0 -1
  88. package/dist/chunk-MV2N2WNS.js +0 -352
  89. package/dist/chunk-MV2N2WNS.js.map +0 -1
  90. package/dist/chunk-MXI26H62.cjs +0 -2207
  91. package/dist/chunk-MXI26H62.cjs.map +0 -1
  92. package/dist/chunk-QS7B42ZR.js +0 -5057
  93. package/dist/chunk-QS7B42ZR.js.map +0 -1
  94. package/dist/chunk-QX2ERL72.js +0 -1256
  95. package/dist/chunk-QX2ERL72.js.map +0 -1
  96. package/dist/chunk-ZWXQSKCX.cjs +0 -352
  97. package/dist/chunk-ZWXQSKCX.cjs.map +0 -1
  98. package/dist/schemas.cjs.map +0 -1
  99. package/dist/schemas.js.map +0 -1
  100. package/dist/scoring.cjs.map +0 -1
  101. package/dist/scoring.js.map +0 -1
  102. package/dist/xapi.cjs.map +0 -1
  103. package/dist/xapi.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,1246 +1,1310 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
-
3
-
4
-
5
-
6
-
7
- var _chunk46BXIGLGcjs = require('./chunk-46BXIGLG.cjs');
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
- var _chunkCYCTIXUNcjs = require('./chunk-CYCTIXUN.cjs');
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
- var _chunkMXI26H62cjs = require('./chunk-MXI26H62.cjs');
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
- var _chunk4YANYTFNcjs = require('./chunk-4YANYTFN.cjs');
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
- var _chunkZWXQSKCXcjs = require('./chunk-ZWXQSKCX.cjs');
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
- var _chunk7THXD7ANcjs = require('./chunk-7THXD7AN.cjs');
132
-
133
- // src/content-hash.ts
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_registry = require("./registry-DVi0LR_i.cjs");
3
+ const require_ai_writing = require("./ai-writing-DCVkv4Zj.cjs");
4
+ const require_scoring = require("./scoring-B2_HEhMT.cjs");
5
+ const require_item_group = require("./item-group-DroFXgLb.cjs");
6
+ const require_schemas = require("./schemas-DVbAOrtd.cjs");
7
+ const require_xapi = require("./xapi-DNzkSJ0S.cjs");
8
+ //#region src/content-hash.ts
9
+ /**
10
+ * Content fingerprinting — "is this the same content the learner was served?"
11
+ *
12
+ * A recorded attempt outlives the content it was taken against. Papers get
13
+ * corrected, a typo is fixed, an option is reworded — and six months later a
14
+ * remark, an appeal, or a per-item analysis is run against content that is no
15
+ * longer what the learner saw. Nothing warns anybody, because the ids all still
16
+ * match. A fingerprint stored with the attempt turns that silent drift into a
17
+ * question somebody can answer.
18
+ */
19
+ /**
20
+ * Deterministic JSON: object keys sorted, arrays left in order, `undefined`
21
+ * omitted from objects (matching `JSON.stringify`) and rendered as `null`
22
+ * inside arrays.
23
+ *
24
+ * Key order is the whole point. Two objects that differ only in the order
25
+ * their keys happened to be written are the SAME content, and a fingerprint
26
+ * that disagreed would raise a false alarm every time a payload made a
27
+ * round-trip through a different serializer.
28
+ */
134
29
  function canonicalJson(value, seen = /* @__PURE__ */ new Set()) {
135
- if (value === null) {
136
- return "null";
137
- }
138
- if (typeof value === "number") {
139
- return Number.isFinite(value) ? JSON.stringify(value === 0 ? 0 : value) : `"#${String(value)}"`;
140
- }
141
- if (typeof value === "string" || typeof value === "boolean") {
142
- return JSON.stringify(value);
143
- }
144
- if (typeof value === "bigint") {
145
- return `"#${value.toString()}n"`;
146
- }
147
- if (typeof value === "object") {
148
- if (seen.has(value)) {
149
- throw new Error(
150
- "canonicalJson: the value contains a circular reference and cannot be fingerprinted."
151
- );
152
- }
153
- seen.add(value);
154
- try {
155
- if (Array.isArray(value)) {
156
- return `[${value.map((element) => element === void 0 ? "null" : canonicalJson(element, seen)).join(",")}]`;
157
- }
158
- const source = value;
159
- const parts = [];
160
- for (const key of Object.keys(source).sort()) {
161
- const entry = source[key];
162
- if (entry === void 0) {
163
- continue;
164
- }
165
- parts.push(`${JSON.stringify(key)}:${canonicalJson(entry, seen)}`);
166
- }
167
- return `{${parts.join(",")}}`;
168
- } finally {
169
- seen.delete(value);
170
- }
171
- }
172
- return "null";
30
+ if (value === null) return "null";
31
+ if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value === 0 ? 0 : value) : `"#${String(value)}"`;
32
+ if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
33
+ if (typeof value === "bigint") return `"#${value.toString()}n"`;
34
+ if (typeof value === "object") {
35
+ if (seen.has(value)) throw new Error("canonicalJson: the value contains a circular reference and cannot be fingerprinted.");
36
+ seen.add(value);
37
+ try {
38
+ if (Array.isArray(value)) return `[${value.map((element) => element === void 0 ? "null" : canonicalJson(element, seen)).join(",")}]`;
39
+ const source = value;
40
+ const parts = [];
41
+ for (const key of Object.keys(source).sort()) {
42
+ const entry = source[key];
43
+ if (entry === void 0) continue;
44
+ parts.push(`${JSON.stringify(key)}:${canonicalJson(entry, seen)}`);
45
+ }
46
+ return `{${parts.join(",")}}`;
47
+ } finally {
48
+ seen.delete(value);
49
+ }
50
+ }
51
+ return "null";
173
52
  }
174
- var FNV_OFFSET = 14695981039346656037n;
175
- var FNV_PRIME = 1099511628211n;
176
- var MASK64 = (1n << 64n) - 1n;
53
+ const FNV_OFFSET = 14695981039346656037n;
54
+ const FNV_PRIME = 1099511628211n;
55
+ const MASK64 = (1n << 64n) - 1n;
56
+ /**
57
+ * FNV-1a over the UTF-8 bytes of `input`, as 16 lowercase hex digits.
58
+ *
59
+ * **This is a change-detection fingerprint, not a tamper-evident signature.**
60
+ * It is deterministic across runtimes and dependency-free, which is what makes
61
+ * it usable in a stored grade record — but an adversary who can edit content
62
+ * can also, with effort, preserve the fingerprint. If you need the stronger
63
+ * property, sign the plan with a key the content author does not hold; this
64
+ * exists to catch honest edits, which is what actually happens.
65
+ */
177
66
  function fingerprint(input) {
178
- const bytes = new TextEncoder().encode(input);
179
- let hash = FNV_OFFSET;
180
- for (const byte of bytes) {
181
- hash = (hash ^ BigInt(byte)) * FNV_PRIME & MASK64;
182
- }
183
- return hash.toString(16).padStart(16, "0");
67
+ const bytes = new TextEncoder().encode(input);
68
+ let hash = FNV_OFFSET;
69
+ for (const byte of bytes) hash = (hash ^ BigInt(byte)) * FNV_PRIME & MASK64;
70
+ return hash.toString(16).padStart(16, "0");
184
71
  }
72
+ /**
73
+ * Fingerprints any JSON-serialisable value through {@link canonicalJson}, so
74
+ * the result depends on the content and not on how it was written.
75
+ */
185
76
  function contentHash(value) {
186
- return fingerprint(canonicalJson(value));
77
+ return fingerprint(canonicalJson(value));
187
78
  }
188
-
189
- // src/delivery.ts
190
- var OPEN_DELIVERY_POLICY = Object.freeze({
191
- feedback: true,
192
- solutions: true,
193
- hints: true,
194
- ai: Object.freeze({ hints: true, explanations: true })
79
+ //#endregion
80
+ //#region src/delivery.ts
81
+ /**
82
+ * The policy that restricts nothing: every setting `true`. It is what an absent
83
+ * policy resolves to, and it is exactly the behaviour the SDK had before
84
+ * delivery policies existed.
85
+ */
86
+ const OPEN_DELIVERY_POLICY = Object.freeze({
87
+ feedback: true,
88
+ solutions: true,
89
+ hints: true,
90
+ ai: Object.freeze({
91
+ hints: true,
92
+ explanations: true
93
+ })
195
94
  });
196
- var SETTINGS = ["feedback", "solutions", "hints"];
197
- var AI_SETTINGS = ["hints", "explanations"];
95
+ const SETTINGS = [
96
+ "feedback",
97
+ "solutions",
98
+ "hints"
99
+ ];
100
+ const AI_SETTINGS = ["hints", "explanations"];
101
+ /**
102
+ * One setting as a render reads it. Absent or `null` is unset, and unset is
103
+ * `true`: JSON has no `undefined`, so a setting nobody chose arrives from a
104
+ * database as `null`.
105
+ *
106
+ * Anything else that is not exactly `true` reads as `false`. Every setting is a
107
+ * restriction, so a value that cannot be read is read as the restriction — a
108
+ * `"false"` string from a form, say, whose author meant exactly that. Reading
109
+ * it as `true` would put hints on a paper whose school had switched them off.
110
+ */
198
111
  function allows(value) {
199
- return value === void 0 || value === null || value === true;
112
+ return value === void 0 || value === null || value === true;
200
113
  }
114
+ /**
115
+ * A policy with every setting spelled out — see {@link DeliveryPolicy}.
116
+ *
117
+ * Tolerant, because it runs where a learner is waiting: nothing here throws.
118
+ * A value it cannot read restricts rather than allows (see above), and a
119
+ * policy that is not an object at all restricts everything. Check a policy
120
+ * before you store it with {@link validateDeliveryPolicy}; `planAttempt` does.
121
+ */
201
122
  function resolveDeliveryPolicy(policy) {
202
- if (policy === void 0 || policy === null) {
203
- return OPEN_DELIVERY_POLICY;
204
- }
205
- if (typeof policy !== "object" || Array.isArray(policy)) {
206
- return {
207
- feedback: false,
208
- solutions: false,
209
- hints: false,
210
- ai: { hints: false, explanations: false }
211
- };
212
- }
213
- const given = policy;
214
- const ai = given.ai;
215
- const aiGiven = ai === void 0 || ai === null || ai === true ? {} : typeof ai === "object" && !Array.isArray(ai) ? ai : { hints: false, explanations: false };
216
- return {
217
- feedback: allows(given.feedback),
218
- solutions: allows(given.solutions),
219
- hints: allows(given.hints),
220
- ai: { hints: allows(aiGiven.hints), explanations: allows(aiGiven.explanations) }
221
- };
123
+ if (policy === void 0 || policy === null) return OPEN_DELIVERY_POLICY;
124
+ if (typeof policy !== "object" || Array.isArray(policy)) return {
125
+ feedback: false,
126
+ solutions: false,
127
+ hints: false,
128
+ ai: {
129
+ hints: false,
130
+ explanations: false
131
+ }
132
+ };
133
+ const given = policy;
134
+ const ai = given.ai;
135
+ const aiGiven = ai === void 0 || ai === null || ai === true ? {} : typeof ai === "object" && !Array.isArray(ai) ? ai : {
136
+ hints: false,
137
+ explanations: false
138
+ };
139
+ return {
140
+ feedback: allows(given.feedback),
141
+ solutions: allows(given.solutions),
142
+ hints: allows(given.hints),
143
+ ai: {
144
+ hints: allows(aiGiven.hints),
145
+ explanations: allows(aiGiven.explanations)
146
+ }
147
+ };
222
148
  }
149
+ /**
150
+ * The strictest of several policies: a setting is on only where every one of
151
+ * them leaves it on. How a course's policy and an assessment's combine, and
152
+ * how a pager's policy wins over whatever a question it draws was handed.
153
+ */
223
154
  function combineDeliveryPolicies(...policies) {
224
- const resolved = policies.map(resolveDeliveryPolicy);
225
- const every = (read) => resolved.every(read);
226
- return {
227
- feedback: every((one) => one.feedback),
228
- solutions: every((one) => one.solutions),
229
- hints: every((one) => one.hints),
230
- ai: {
231
- hints: every((one) => one.ai.hints),
232
- explanations: every((one) => one.ai.explanations)
233
- }
234
- };
155
+ const resolved = policies.map(resolveDeliveryPolicy);
156
+ const every = (read) => resolved.every(read);
157
+ return {
158
+ feedback: every((one) => one.feedback),
159
+ solutions: every((one) => one.solutions),
160
+ hints: every((one) => one.hints),
161
+ ai: {
162
+ hints: every((one) => one.ai.hints),
163
+ explanations: every((one) => one.ai.explanations)
164
+ }
165
+ };
235
166
  }
167
+ /**
168
+ * Checks a policy before it is stored: each setting `true`, `false`, `null`
169
+ * or absent, and nothing else — no misspelled setting, no string standing in
170
+ * for a boolean.
171
+ *
172
+ * Stricter than {@link resolveDeliveryPolicy} on purpose. A misspelled
173
+ * restriction (`hint: false`) is silently no restriction at all, and a
174
+ * policy is a record of the conditions a paper was sat under: it is worth
175
+ * refusing before it is written down, rather than read charitably after.
176
+ */
236
177
  function validateDeliveryPolicy(policy) {
237
- const issues = [];
238
- if (policy === void 0 || policy === null) {
239
- return { success: true, data: {} };
240
- }
241
- if (typeof policy !== "object" || Array.isArray(policy)) {
242
- return {
243
- success: false,
244
- issues: [{ path: "", message: "A delivery policy is an object of settings." }]
245
- };
246
- }
247
- const given = policy;
248
- for (const key of Object.keys(given)) {
249
- if (key === "ai") {
250
- continue;
251
- }
252
- if (!SETTINGS.includes(key)) {
253
- issues.push({
254
- path: key,
255
- message: `"${key}" is not a delivery setting. The settings are feedback, solutions, hints and ai.`
256
- });
257
- continue;
258
- }
259
- const value = given[key];
260
- if (value !== true && value !== false && value !== null && value !== void 0) {
261
- issues.push({ path: key, message: `"${key}" must be true, false or null.` });
262
- }
263
- }
264
- const ai = given.ai;
265
- if (ai !== void 0 && ai !== null && ai !== true && ai !== false) {
266
- if (typeof ai !== "object" || Array.isArray(ai)) {
267
- issues.push({
268
- path: "ai",
269
- message: '"ai" must be true, false, or an object of hints and explanations.'
270
- });
271
- } else {
272
- for (const [key, value] of Object.entries(ai)) {
273
- if (!AI_SETTINGS.includes(key)) {
274
- issues.push({
275
- path: `ai.${key}`,
276
- message: `"ai.${key}" is not an AI setting. The settings are hints and explanations.`
277
- });
278
- continue;
279
- }
280
- if (value !== true && value !== false && value !== null && value !== void 0) {
281
- issues.push({ path: `ai.${key}`, message: `"ai.${key}" must be true, false or null.` });
282
- }
283
- }
284
- }
285
- }
286
- return issues.length > 0 ? { success: false, issues } : { success: true, data: given };
178
+ const issues = [];
179
+ if (policy === void 0 || policy === null) return {
180
+ success: true,
181
+ data: {}
182
+ };
183
+ if (typeof policy !== "object" || Array.isArray(policy)) return {
184
+ success: false,
185
+ issues: [{
186
+ path: "",
187
+ message: "A delivery policy is an object of settings."
188
+ }]
189
+ };
190
+ const given = policy;
191
+ for (const key of Object.keys(given)) {
192
+ if (key === "ai") continue;
193
+ if (!SETTINGS.includes(key)) {
194
+ issues.push({
195
+ path: key,
196
+ message: `"${key}" is not a delivery setting. The settings are feedback, solutions, hints and ai.`
197
+ });
198
+ continue;
199
+ }
200
+ const value = given[key];
201
+ if (value !== true && value !== false && value !== null && value !== void 0) issues.push({
202
+ path: key,
203
+ message: `"${key}" must be true, false or null.`
204
+ });
205
+ }
206
+ const ai = given.ai;
207
+ if (ai !== void 0 && ai !== null && ai !== true && ai !== false) {
208
+ if (typeof ai !== "object" || Array.isArray(ai)) issues.push({
209
+ path: "ai",
210
+ message: "\"ai\" must be true, false, or an object of hints and explanations."
211
+ });
212
+ else for (const [key, value] of Object.entries(ai)) {
213
+ if (!AI_SETTINGS.includes(key)) {
214
+ issues.push({
215
+ path: `ai.${key}`,
216
+ message: `"ai.${key}" is not an AI setting. The settings are hints and explanations.`
217
+ });
218
+ continue;
219
+ }
220
+ if (value !== true && value !== false && value !== null && value !== void 0) issues.push({
221
+ path: `ai.${key}`,
222
+ message: `"ai.${key}" must be true, false or null.`
223
+ });
224
+ }
225
+ }
226
+ return issues.length > 0 ? {
227
+ success: false,
228
+ issues
229
+ } : {
230
+ success: true,
231
+ data: given
232
+ };
287
233
  }
288
-
289
- // src/shuffle.ts
234
+ //#endregion
235
+ //#region src/shuffle.ts
236
+ /**
237
+ * Seeded, deterministic shuffling — the ONE algorithm every presentation-order
238
+ * decision in the SDK goes through: option order in a multiple-choice item,
239
+ * item order inside an item group, entry order in a sequence. A server and a
240
+ * client holding the same seed derive the same order, and an order recorded
241
+ * against an attempt can be rebuilt later from nothing but its seed.
242
+ *
243
+ * STABILITY: the hash and the generator are part of the wire contract. A
244
+ * stored attempt may hold only a seed and rely on this function to reproduce
245
+ * the order it presented; changing either would silently re-order every
246
+ * recorded attempt. Any change here is a package major, and the pinned
247
+ * permutation test exists so an accidental one fails loudly.
248
+ */
249
+ /** FNV-1a hash of a string → unsigned 32-bit integer. */
290
250
  function hashSeed(input) {
291
- let h = 2166136261 >>> 0;
292
- for (let i = 0; i < input.length; i += 1) {
293
- h ^= input.charCodeAt(i);
294
- h = Math.imul(h, 16777619) >>> 0;
295
- }
296
- return h;
251
+ let h = 2166136261;
252
+ for (let i = 0; i < input.length; i += 1) {
253
+ h ^= input.charCodeAt(i);
254
+ h = Math.imul(h, 16777619) >>> 0;
255
+ }
256
+ return h;
297
257
  }
258
+ /** LCG-driven Fisher–Yates over a 32-bit seed; returns a permutation (no loss, no duplicate). */
298
259
  function shuffleWithSeed(items, seed, version) {
299
- const out = [...items];
300
- let s = seed >>> 0 || 1;
301
- for (let i = out.length - 1; i > 0; i -= 1) {
302
- s = Math.imul(s, 1664525) + 1013904223 >>> 0;
303
- const j = version === 2 ? Math.floor(s / 4294967296 * (i + 1)) : s % (i + 1);
304
- [out[i], out[j]] = [out[j], out[i]];
305
- }
306
- return out;
260
+ const out = [...items];
261
+ let s = seed >>> 0 || 1;
262
+ for (let i = out.length - 1; i > 0; i -= 1) {
263
+ s = Math.imul(s, 1664525) + 1013904223 >>> 0;
264
+ const j = version === 2 ? Math.floor(s / 4294967296 * (i + 1)) : s % (i + 1);
265
+ [out[i], out[j]] = [out[j], out[i]];
266
+ }
267
+ return out;
307
268
  }
308
269
  function seededShuffle(items, seed, options = {}) {
309
- return shuffleWithSeed(items, hashSeed(seed), _nullishCoalesce(options.version, () => ( 1)));
270
+ return shuffleWithSeed(items, hashSeed(seed), options.version ?? 1);
310
271
  }
311
-
312
- // src/item-group.ts
272
+ //#endregion
273
+ //#region src/item-group.ts
274
+ /** Narrows a sequence entry to an item group. */
313
275
  function isItemGroup(entry) {
314
- return entry.type === "item-group";
276
+ return entry.type === "item-group";
315
277
  }
278
+ /**
279
+ * An entry's declared `slotKey`, if it has one. Read structurally rather than
280
+ * from the type, because a plain activity may carry one too — `slotKey` is a
281
+ * property of an item's PLACE in a paper, so any entry can declare it without
282
+ * every activity schema having to know about assessment assembly.
283
+ *
284
+ * A key containing `.` is rejected: `.` separates a group from its item in a
285
+ * slot id, so `"a.b"` as a group key would be indistinguishable from item `b`
286
+ * of group `a`.
287
+ */
316
288
  function keyOf(entry) {
317
- const key = entry.slotKey;
318
- if (key === void 0) {
319
- return void 0;
320
- }
321
- if (typeof key !== "string" || key.length === 0) {
322
- throw new Error(
323
- `flattenSequence: slotKey must be a non-empty string, received ${String(key)}.`
324
- );
325
- }
326
- if (key.includes(".")) {
327
- throw new Error(
328
- `flattenSequence: slotKey "${key}" contains a "." , which separates a group from its item in a slot id. Choose a key without it, or the two become indistinguishable.`
329
- );
330
- }
331
- return key;
289
+ const key = entry.slotKey;
290
+ if (key === void 0) return;
291
+ if (typeof key !== "string" || key.length === 0) throw new Error(`flattenSequence: slotKey must be a non-empty string, received ${String(key)}.`);
292
+ if (key.includes(".")) throw new Error(`flattenSequence: slotKey "${key}" contains a "." , which separates a group from its item in a slot id. Choose a key without it, or the two become indistinguishable.`);
293
+ return key;
332
294
  }
295
+ /**
296
+ * An interactive video's items in the order its quizzes open: by the quiz's
297
+ * time — stably, so quizzes at one moment keep their authored order — then by
298
+ * position within the quiz. An item no quiz places (content the schema would
299
+ * refuse) keeps its authored place after every placed one, rather than
300
+ * vanishing from the paper.
301
+ */
333
302
  function inQuizOrder(items, timeline) {
334
- const placement = /* @__PURE__ */ new Map();
335
- timeline.cues.forEach((cue, cueIndex) => {
336
- cue.itemIds.forEach((itemId, position) => {
337
- if (!placement.has(itemId)) {
338
- placement.set(itemId, { at: cue.at, cue: cueIndex, position });
339
- }
340
- });
341
- });
342
- const unplaced = { at: Number.POSITIVE_INFINITY, cue: Number.POSITIVE_INFINITY, position: 0 };
343
- return items.map((entry) => ({ entry, place: _nullishCoalesce(placement.get(entry.item.id), () => ( unplaced)) })).sort(
344
- (a, b) => a.place.at - b.place.at || a.place.cue - b.place.cue || a.place.position - b.place.position || a.entry.itemIndex - b.entry.itemIndex
345
- ).map(({ entry }) => entry);
303
+ const placement = /* @__PURE__ */ new Map();
304
+ timeline.cues.forEach((cue, cueIndex) => {
305
+ cue.itemIds.forEach((itemId, position) => {
306
+ if (!placement.has(itemId)) placement.set(itemId, {
307
+ at: cue.at,
308
+ cue: cueIndex,
309
+ position
310
+ });
311
+ });
312
+ });
313
+ const unplaced = {
314
+ at: Number.POSITIVE_INFINITY,
315
+ cue: Number.POSITIVE_INFINITY,
316
+ position: 0
317
+ };
318
+ return items.map((entry) => ({
319
+ entry,
320
+ place: placement.get(entry.item.id) ?? unplaced
321
+ })).sort((a, b) => a.place.at - b.place.at || a.place.cue - b.place.cue || a.place.position - b.place.position || a.entry.itemIndex - b.entry.itemIndex).map(({ entry }) => entry);
346
322
  }
323
+ /**
324
+ * Turns a sequence definition — loose activities and item groups, in authored
325
+ * order — into the ordered list of slots to present. Pure and deterministic:
326
+ * call it on the server to record an attempt's order, and on the client to
327
+ * render it, and the two agree.
328
+ *
329
+ * Shuffling is opt-in and seeded. Groups are shuffle-atomic: with
330
+ * `shuffleEntries` a group changes position but stays one contiguous block,
331
+ * and only a group that declares `shuffle: 'within-group'` has its items
332
+ * reordered. Every slot carries a `slotId` derived from the authored
333
+ * position, so identities survive shuffling.
334
+ *
335
+ * @throws Error when a shuffle is requested without a `seed`.
336
+ */
347
337
  function flattenSequence(entries, options = {}) {
348
- const shuffleEntries = options.shuffleEntries === true;
349
- const needsSeed = shuffleEntries || entries.some((entry) => isItemGroup(entry) && entry.shuffle === "within-group");
350
- if (needsSeed && options.seed === void 0) {
351
- throw new Error(
352
- 'flattenSequence: a seed is required when shuffling (shuffleEntries, or a group with shuffle: "within-group"). Pass the attempt id so the server and the client derive the same order.'
353
- );
354
- }
355
- const seed = _nullishCoalesce(options.seed, () => ( ""));
356
- const authored = entries.map((entry, entryIndex) => ({ entry, entryIndex }));
357
- const ordered = shuffleEntries ? seededShuffle(authored, `${seed}:entries`) : authored;
358
- const slots = [];
359
- for (const { entry, entryIndex } of ordered) {
360
- const entryKey = _nullishCoalesce(keyOf(entry), () => ( String(entryIndex)));
361
- if (!isItemGroup(entry)) {
362
- slots.push({ slotId: entryKey, index: slots.length, activity: entry });
363
- continue;
364
- }
365
- if (entry.items.length === 0) {
366
- throw new Error(
367
- `flattenSequence: item group "${entry.id}" has no items. An empty group would silently remove its stimulus and its questions from the presented sequence.`
368
- );
369
- }
370
- const timeline = entry.timeline;
371
- if (timeline !== void 0 && entry.shuffle === "within-group") {
372
- throw new Error(
373
- `flattenSequence: item group "${entry.id}" has a timeline and shuffle: "within-group". An interactive video presents its questions in the order its quizzes open.`
374
- );
375
- }
376
- const items = entry.items.map((item, itemIndex) => ({ item, itemIndex }));
377
- const presented = timeline !== void 0 ? inQuizOrder(items, timeline) : entry.shuffle === "within-group" ? seededShuffle(items, `${seed}:group:${entry.id}`) : items;
378
- const size = presented.length;
379
- presented.forEach(({ item, itemIndex }, position) => {
380
- const cue = _optionalChain([timeline, 'optionalAccess', _ => _.cues, 'access', _2 => _2.find, 'call', _3 => _3((candidate) => candidate.itemIds.includes(item.id))]);
381
- slots.push({
382
- // Slot ids stay AUTHORED — the item's index or its key — whatever the
383
- // presented order: moving a quiz must never re-map a stored answer.
384
- slotId: `${entryKey}.${_nullishCoalesce(keyOf(item), () => ( String(itemIndex)))}`,
385
- index: slots.length,
386
- activity: item,
387
- group: {
388
- id: entry.id,
389
- ...entry.title !== void 0 ? { title: entry.title } : {},
390
- stimulus: entry.stimulus,
391
- position,
392
- size,
393
- ...timeline !== void 0 ? { timeline } : {},
394
- ...cue !== void 0 ? { cue } : {}
395
- }
396
- });
397
- });
398
- }
399
- const seenSlotIds = /* @__PURE__ */ new Set();
400
- const seenEntryKeys = /* @__PURE__ */ new Set();
401
- for (const slot of slots) {
402
- if (seenSlotIds.has(slot.slotId)) {
403
- throw new Error(
404
- `flattenSequence: duplicate slot id "${slot.slotId}". Two entries declare the same slotKey, so responses stored against them could not be told apart.`
405
- );
406
- }
407
- seenSlotIds.add(slot.slotId);
408
- }
409
- for (const { entry, entryIndex } of ordered) {
410
- const entryKey = _nullishCoalesce(keyOf(entry), () => ( String(entryIndex)));
411
- if (seenEntryKeys.has(entryKey)) {
412
- throw new Error(
413
- `flattenSequence: duplicate entry key "${entryKey}". A loose activity and an item group cannot share a slotKey \u2014 their slot ids would not collide, but everything that reads the entry they belong to would treat them as one entry.`
414
- );
415
- }
416
- seenEntryKeys.add(entryKey);
417
- }
418
- return slots;
338
+ const shuffleEntries = options.shuffleEntries === true;
339
+ if ((shuffleEntries || entries.some((entry) => isItemGroup(entry) && entry.shuffle === "within-group")) && options.seed === void 0) throw new Error("flattenSequence: a seed is required when shuffling (shuffleEntries, or a group with shuffle: \"within-group\"). Pass the attempt id so the server and the client derive the same order.");
340
+ const seed = options.seed ?? "";
341
+ const authored = entries.map((entry, entryIndex) => ({
342
+ entry,
343
+ entryIndex
344
+ }));
345
+ const ordered = shuffleEntries ? seededShuffle(authored, `${seed}:entries`, { version: 1 }) : authored;
346
+ const slots = [];
347
+ for (const { entry, entryIndex } of ordered) {
348
+ const entryKey = keyOf(entry) ?? String(entryIndex);
349
+ if (!isItemGroup(entry)) {
350
+ slots.push({
351
+ slotId: entryKey,
352
+ index: slots.length,
353
+ activity: entry
354
+ });
355
+ continue;
356
+ }
357
+ if (entry.items.length === 0) throw new Error(`flattenSequence: item group "${entry.id}" has no items. An empty group would silently remove its stimulus and its questions from the presented sequence.`);
358
+ const timeline = entry.timeline;
359
+ if (timeline !== void 0 && entry.shuffle === "within-group") throw new Error(`flattenSequence: item group "${entry.id}" has a timeline and shuffle: "within-group". An interactive video presents its questions in the order its quizzes open.`);
360
+ const items = entry.items.map((item, itemIndex) => ({
361
+ item,
362
+ itemIndex
363
+ }));
364
+ const presented = timeline !== void 0 ? inQuizOrder(items, timeline) : entry.shuffle === "within-group" ? seededShuffle(items, `${seed}:group:${entry.id}`, { version: 1 }) : items;
365
+ const size = presented.length;
366
+ presented.forEach(({ item, itemIndex }, position) => {
367
+ const cue = timeline?.cues.find((candidate) => candidate.itemIds.includes(item.id));
368
+ slots.push({
369
+ slotId: `${entryKey}.${keyOf(item) ?? String(itemIndex)}`,
370
+ index: slots.length,
371
+ activity: item,
372
+ group: {
373
+ id: entry.id,
374
+ ...entry.title !== void 0 ? { title: entry.title } : {},
375
+ stimulus: entry.stimulus,
376
+ position,
377
+ size,
378
+ ...timeline !== void 0 ? { timeline } : {},
379
+ ...cue !== void 0 ? { cue } : {}
380
+ }
381
+ });
382
+ });
383
+ }
384
+ const seenSlotIds = /* @__PURE__ */ new Set();
385
+ const seenEntryKeys = /* @__PURE__ */ new Set();
386
+ for (const slot of slots) {
387
+ if (seenSlotIds.has(slot.slotId)) throw new Error(`flattenSequence: duplicate slot id "${slot.slotId}". Two entries declare the same slotKey, so responses stored against them could not be told apart.`);
388
+ seenSlotIds.add(slot.slotId);
389
+ }
390
+ for (const { entry, entryIndex } of ordered) {
391
+ const entryKey = keyOf(entry) ?? String(entryIndex);
392
+ if (seenEntryKeys.has(entryKey)) throw new Error(`flattenSequence: duplicate entry key "${entryKey}". A loose activity and an item group cannot share a slotKey — their slot ids would not collide, but everything that reads the entry they belong to would treat them as one entry.`);
393
+ seenEntryKeys.add(entryKey);
394
+ }
395
+ return slots;
419
396
  }
420
-
421
- // src/media-budget.ts
397
+ //#endregion
398
+ //#region src/media-budget.ts
399
+ /**
400
+ * The authored-entry prefix of a slot id: `"3"` for both `"3"` and `"3.1"`.
401
+ *
402
+ * Safe because every slot id in a plan is produced by `flattenSequence`, and
403
+ * `keyOf` rejects a `.` in both an entry key and an item key — so the first
404
+ * `.` is always the group/item separator.
405
+ */
422
406
  function entryKeyOf(slotId) {
423
- const dot = slotId.indexOf(".");
424
- return dot === -1 ? slotId : slotId.slice(0, dot);
407
+ const dot = slotId.indexOf(".");
408
+ return dot === -1 ? slotId : slotId.slice(0, dot);
425
409
  }
410
+ /** Budget key for an activity's OWN `data.media`. One budget per slot. */
426
411
  function slotMediaKey(slotId) {
427
- return `slot:${slotId}`;
412
+ return `slot:${slotId}`;
428
413
  }
414
+ /**
415
+ * Budget key for an item group's `stimulus.media`.
416
+ *
417
+ * Keyed by the ENTRY, not the slot: one recording serves every question in the
418
+ * group, so `"3.0"`…`"3.5"` share one budget. Keying by `slotId` would hand a
419
+ * six-question `maxPlays: 2` listening group twelve plays; keying by
420
+ * `stimulus.id` would collide across papers, which is the thing slot keys and
421
+ * `planHash` exist to prevent.
422
+ */
429
423
  function stimulusMediaKey(slotId) {
430
- return `stimulus:${entryKeyOf(slotId)}`;
424
+ return `stimulus:${entryKeyOf(slotId)}`;
431
425
  }
426
+ /**
427
+ * Resolves the defaults once, so the schema refinements, the plan and the
428
+ * renderer cannot drift apart about what a policy means.
429
+ *
430
+ * Absent `playback` resolves to exactly the SDK's pre-0.8.0 behaviour:
431
+ * `controls: 'native'`, no budget, free seeking, free speed, no hints.
432
+ */
432
433
  function resolvePlaybackPolicy(media) {
433
- const p = media.playback;
434
- const budgeted = typeof _optionalChain([p, 'optionalAccess', _4 => _4.maxPlays]) === "number";
435
- const seek = _nullishCoalesce(_optionalChain([p, 'optionalAccess', _5 => _5.seek]), () => ( (budgeted ? "none" : "allow")));
436
- const rate = _nullishCoalesce(_optionalChain([p, 'optionalAccess', _6 => _6.rate]), () => ( "allow"));
437
- const controls = _nullishCoalesce(_optionalChain([p, 'optionalAccess', _7 => _7.controls]), () => ( (budgeted || seek === "none" || rate === "fixed" ? "minimal" : "native")));
438
- return {
439
- controls,
440
- maxPlays: budgeted ? _optionalChain([p, 'optionalAccess', _8 => _8.maxPlays]) : null,
441
- seek,
442
- rate,
443
- nativeControlHints: _nullishCoalesce(_optionalChain([p, 'optionalAccess', _9 => _9.nativeControlHints]), () => ( []))
444
- };
434
+ const p = media.playback;
435
+ const budgeted = typeof p?.maxPlays === "number";
436
+ const seek = p?.seek ?? (budgeted ? "none" : "allow");
437
+ const rate = p?.rate ?? "allow";
438
+ return {
439
+ controls: p?.controls ?? (budgeted || seek === "none" || rate === "fixed" ? "minimal" : "native"),
440
+ maxPlays: budgeted ? p?.maxPlays : null,
441
+ seek,
442
+ rate,
443
+ nativeControlHints: p?.nativeControlHints ?? []
444
+ };
445
445
  }
446
+ /**
447
+ * Every budgeted recording a plan contains: budget key → the `maxPlays` that
448
+ * was frozen when the attempt was planned.
449
+ *
450
+ * Use it to build the storage rows an attempt needs, and to check a stored
451
+ * ledger against the paper it claims to belong to.
452
+ */
446
453
  function planMediaBudgets(plan) {
447
- const out = {};
448
- for (const slot of plan.slots) {
449
- for (const budget of _nullishCoalesce(slot.mediaBudgets, () => ( []))) {
450
- out[budget.key] = budget.maxPlays;
451
- }
452
- }
453
- return out;
454
+ const out = {};
455
+ for (const slot of plan.slots) for (const budget of slot.mediaBudgets ?? []) out[budget.key] = budget.maxPlays;
456
+ return out;
454
457
  }
455
458
  function assertEntry(key, entry) {
456
- if (entry === null || typeof entry !== "object" || !Number.isInteger(entry.plays) || entry.plays < 0) {
457
- throw new Error(
458
- `serializeMediaPlayLedger: play count for ${JSON.stringify(key)} is ${JSON.stringify(_optionalChain([entry, 'optionalAccess', _10 => _10.plays]))}. A count must be a non-negative integer; Number(a NULL column) is the usual cause.`
459
- );
460
- }
461
- if (entry.at !== void 0 && (!Number.isFinite(entry.at) || entry.at < 0)) {
462
- throw new Error(
463
- `serializeMediaPlayLedger: position for ${JSON.stringify(key)} is ${JSON.stringify(entry.at)}. A position must be a finite number of seconds >= 0.`
464
- );
465
- }
459
+ if (entry === null || typeof entry !== "object" || !Number.isInteger(entry.plays) || entry.plays < 0) throw new Error(`serializeMediaPlayLedger: play count for ${JSON.stringify(key)} is ${JSON.stringify(entry?.plays)}. A count must be a non-negative integer; Number(a NULL column) is the usual cause.`);
460
+ if (entry.at !== void 0 && (!Number.isFinite(entry.at) || entry.at < 0)) throw new Error(`serializeMediaPlayLedger: position for ${JSON.stringify(key)} is ${JSON.stringify(entry.at)}. A position must be a finite number of seconds >= 0.`);
466
461
  }
462
+ /**
463
+ * Validates a set of spent-play counts against the plan that budgeted them,
464
+ * and stamps the envelope.
465
+ *
466
+ * Validates on the way **in**: a play recorded against a recording the paper
467
+ * does not budget is a bug at the moment it is written, and discovering it
468
+ * when a learner tries to resume is discovering it far too late.
469
+ *
470
+ * @throws Error when a key is not budgeted by the plan, or a count is not a
471
+ * non-negative integer, or a position is not a finite number.
472
+ */
467
473
  function serializeMediaPlayLedger(plan, entries, options = {}) {
468
- if (entries === null || typeof entries !== "object") {
469
- throw new Error("serializeMediaPlayLedger: entries must be an object keyed by media key.");
470
- }
471
- const budgets = planMediaBudgets(plan);
472
- const out = {};
473
- for (const key of Object.keys(entries)) {
474
- if (!Object.hasOwn(budgets, key)) {
475
- throw new Error(
476
- `serializeMediaPlayLedger: media play recorded against a recording the plan does not budget: ${JSON.stringify(key)}. Keys come from slotMediaKey() / stimulusMediaKey(); a key that belongs to no budgeted media means the client and the plan disagree about which paper this is.`
477
- );
478
- }
479
- const entry = entries[key];
480
- assertEntry(key, entry);
481
- out[key] = { plays: entry.plays, ...entry.at !== void 0 ? { at: entry.at } : {} };
482
- }
483
- return {
484
- ledgerVersion: "1.0",
485
- planHash: plan.planHash,
486
- entries: out,
487
- ...options.savedAt !== void 0 ? { savedAt: options.savedAt } : {}
488
- };
474
+ if (entries === null || typeof entries !== "object") throw new Error("serializeMediaPlayLedger: entries must be an object keyed by media key.");
475
+ const budgets = planMediaBudgets(plan);
476
+ const out = {};
477
+ for (const key of Object.keys(entries)) {
478
+ if (!Object.hasOwn(budgets, key)) throw new Error(`serializeMediaPlayLedger: media play recorded against a recording the plan does not budget: ${JSON.stringify(key)}. Keys come from slotMediaKey() / stimulusMediaKey(); a key that belongs to no budgeted media means the client and the plan disagree about which paper this is.`);
479
+ const entry = entries[key];
480
+ assertEntry(key, entry);
481
+ out[key] = {
482
+ plays: entry.plays,
483
+ ...entry.at !== void 0 ? { at: entry.at } : {}
484
+ };
485
+ }
486
+ return {
487
+ ledgerVersion: "1.0",
488
+ planHash: plan.planHash,
489
+ entries: out,
490
+ ...options.savedAt !== void 0 ? { savedAt: options.savedAt } : {}
491
+ };
489
492
  }
493
+ /**
494
+ * Reopens a stored ledger against the paper it was spent on.
495
+ *
496
+ * Refuses a ledger from a different paper for the same reason
497
+ * `restoreAttemptState` does: budget keys are short and repeat across papers
498
+ * (`slot:0`, `stimulus:1`), so a ledger from last term's midterm would hold a
499
+ * learner to a budget from an exam they never sat, and look entirely plausible
500
+ * doing it.
501
+ *
502
+ * @throws Error on an unrecognised `ledgerVersion`, a `planHash` mismatch, or
503
+ * anything `serializeMediaPlayLedger` refuses.
504
+ */
490
505
  function restoreMediaPlayLedger(plan, stored) {
491
- if (stored === null || typeof stored !== "object") {
492
- throw new Error("restoreMediaPlayLedger: the stored ledger is not an object.");
493
- }
494
- if (stored.ledgerVersion !== "1.0") {
495
- throw new Error(
496
- `restoreMediaPlayLedger: unsupported ledgerVersion ${JSON.stringify(stored.ledgerVersion)}. This build understands "1.0"; a newer ledger must be migrated before it is restored.`
497
- );
498
- }
499
- if (stored.entries === null || typeof stored.entries !== "object") {
500
- throw new Error(
501
- "restoreMediaPlayLedger: the ledger carries no entries object (a NULL column, or a partially written row)."
502
- );
503
- }
504
- if (stored.planHash !== plan.planHash) {
505
- throw new Error(
506
- `restoreMediaPlayLedger: this ledger belongs to a different paper (ledger ${stored.planHash}, plan ${plan.planHash}). Restoring it would hold the learner to a play budget from an exam they never sat.`
507
- );
508
- }
509
- return serializeMediaPlayLedger(plan, stored.entries, {
510
- ...stored.savedAt !== void 0 ? { savedAt: stored.savedAt } : {}
511
- });
506
+ if (stored === null || typeof stored !== "object") throw new Error("restoreMediaPlayLedger: the stored ledger is not an object.");
507
+ if (stored.ledgerVersion !== "1.0") throw new Error(`restoreMediaPlayLedger: unsupported ledgerVersion ${JSON.stringify(stored.ledgerVersion)}. This build understands "1.0"; a newer ledger must be migrated before it is restored.`);
508
+ if (stored.entries === null || typeof stored.entries !== "object") throw new Error("restoreMediaPlayLedger: the ledger carries no entries object (a NULL column, or a partially written row).");
509
+ if (stored.planHash !== plan.planHash) throw new Error(`restoreMediaPlayLedger: this ledger belongs to a different paper (ledger ${stored.planHash}, plan ${plan.planHash}). Restoring it would hold the learner to a play budget from an exam they never sat.`);
510
+ return serializeMediaPlayLedger(plan, stored.entries, { ...stored.savedAt !== void 0 ? { savedAt: stored.savedAt } : {} });
512
511
  }
513
-
514
- // src/attempt-plan.ts
512
+ //#endregion
513
+ //#region src/attempt-plan.ts
514
+ /**
515
+ * The activity as CONTENT, with its slot key removed.
516
+ *
517
+ * `slotKey` rides on the same object but is identity, not content — the whole
518
+ * point of the design. Including it in the fingerprint meant that annotating
519
+ * an existing entry with the key that pins its identity reported as
520
+ * "this question was edited", which is precisely backwards.
521
+ */
515
522
  function contentOf(activity) {
516
- if (!Object.hasOwn(activity, "slotKey")) {
517
- return activity;
518
- }
519
- const { slotKey: _slotKey, ...content } = activity;
520
- return content;
523
+ if (!Object.hasOwn(activity, "slotKey")) return activity;
524
+ const { slotKey: _slotKey, ...content } = activity;
525
+ return content;
521
526
  }
527
+ /**
528
+ * The budgeted recordings a slot presents: its own media, and its group's
529
+ * stimulus. Returns `undefined` — not `[]` — when nothing is budgeted, so the
530
+ * conditional spread below leaves the serialized slot byte-identical to what
531
+ * every pre-0.8.0 paper produced.
532
+ */
522
533
  function mediaBudgetsOf(slot) {
523
- const budgets = [];
524
- const own = slot.activity.media;
525
- if (own !== void 0) {
526
- const maxPlays = resolvePlaybackPolicy(own).maxPlays;
527
- if (maxPlays !== null) {
528
- budgets.push({ key: slotMediaKey(slot.slotId), maxPlays });
529
- }
530
- }
531
- const stimulusMedia = _optionalChain([slot, 'access', _11 => _11.group, 'optionalAccess', _12 => _12.stimulus, 'access', _13 => _13.media]);
532
- if (stimulusMedia !== void 0) {
533
- const maxPlays = resolvePlaybackPolicy(stimulusMedia).maxPlays;
534
- if (maxPlays !== null) {
535
- budgets.push({ key: stimulusMediaKey(slot.slotId), maxPlays });
536
- }
537
- }
538
- return budgets.length > 0 ? budgets : void 0;
534
+ const budgets = [];
535
+ const own = slot.activity.media;
536
+ if (own !== void 0) {
537
+ const maxPlays = resolvePlaybackPolicy(own).maxPlays;
538
+ if (maxPlays !== null) budgets.push({
539
+ key: slotMediaKey(slot.slotId),
540
+ maxPlays
541
+ });
542
+ }
543
+ const stimulusMedia = slot.group?.stimulus.media;
544
+ if (stimulusMedia !== void 0) {
545
+ const maxPlays = resolvePlaybackPolicy(stimulusMedia).maxPlays;
546
+ if (maxPlays !== null) budgets.push({
547
+ key: stimulusMediaKey(slot.slotId),
548
+ maxPlays
549
+ });
550
+ }
551
+ return budgets.length > 0 ? budgets : void 0;
539
552
  }
553
+ /**
554
+ * Refuses a paper in which one recording is budgeted under several keys.
555
+ *
556
+ * Six questions that each carry the same `/audio/part2.mp3` with `maxPlays: 2`
557
+ * are six budgets, so the learner gets twelve plays of one recording while the
558
+ * paper says two. The fix is authoring, not arithmetic: questions that share a
559
+ * recording belong in an item group, whose stimulus is one recording with one
560
+ * budget.
561
+ */
540
562
  function assertNoSplitBudgets(slots) {
541
- const keysByUrl = /* @__PURE__ */ new Map();
542
- for (const slot of slots) {
543
- const own = slot.activity.media;
544
- if (own === void 0 || resolvePlaybackPolicy(own).maxPlays === null) {
545
- continue;
546
- }
547
- const keys = _nullishCoalesce(keysByUrl.get(own.url), () => ( []));
548
- keys.push(slotMediaKey(slot.slotId));
549
- keysByUrl.set(own.url, keys);
550
- }
551
- for (const [url, keys] of keysByUrl) {
552
- if (keys.length > 1) {
553
- throw new Error(
554
- `planAttempt: media ${JSON.stringify(url)} is budgeted under ${keys.length} separate keys (${keys.join(", ")}), so one recording grants ${keys.length} \xD7 maxPlays. Put the questions that share a recording in an item group \u2014 a group's stimulus is one recording with one budget.`
555
- );
556
- }
557
- }
563
+ const keysByUrl = /* @__PURE__ */ new Map();
564
+ for (const slot of slots) {
565
+ const own = slot.activity.media;
566
+ if (own === void 0 || resolvePlaybackPolicy(own).maxPlays === null) continue;
567
+ const keys = keysByUrl.get(own.url) ?? [];
568
+ keys.push(slotMediaKey(slot.slotId));
569
+ keysByUrl.set(own.url, keys);
570
+ }
571
+ for (const [url, keys] of keysByUrl) if (keys.length > 1) throw new Error(`planAttempt: media ${JSON.stringify(url)} is budgeted under ${keys.length} separate keys (${keys.join(", ")}), so one recording grants ${keys.length} × maxPlays. Put the questions that share a recording in an item group — a group's stimulus is one recording with one budget.`);
558
572
  }
559
573
  function planSlot(slot, points) {
560
- if (!Number.isFinite(points) || points < 0) {
561
- throw new Error(
562
- `planAttempt: slot "${slot.slotId}" resolved to ${String(points)} points. Points must be a finite, non-negative number, or the paper has no defensible total.`
563
- );
564
- }
565
- const mediaBudgets = mediaBudgetsOf(slot);
566
- return {
567
- slotId: slot.slotId,
568
- index: slot.index,
569
- activityId: slot.activity.id,
570
- activityType: slot.activity.type,
571
- points,
572
- contentHash: contentHash(contentOf(slot.activity)),
573
- ...slot.group !== void 0 ? {
574
- group: {
575
- id: slot.group.id,
576
- ...slot.group.title !== void 0 ? { title: slot.group.title } : {},
577
- stimulusHash: contentHash(slot.group.stimulus),
578
- ...slot.group.cue !== void 0 ? {
579
- cue: {
580
- id: slot.group.cue.id,
581
- at: slot.group.cue.at,
582
- ...slot.group.cue.required !== void 0 ? { required: slot.group.cue.required } : {}
583
- }
584
- } : {}
585
- }
586
- } : {},
587
- ...mediaBudgets !== void 0 ? { mediaBudgets } : {}
588
- };
574
+ if (!Number.isFinite(points) || points < 0) throw new Error(`planAttempt: slot "${slot.slotId}" resolved to ${String(points)} points. Points must be a finite, non-negative number, or the paper has no defensible total.`);
575
+ const mediaBudgets = mediaBudgetsOf(slot);
576
+ return {
577
+ slotId: slot.slotId,
578
+ index: slot.index,
579
+ activityId: slot.activity.id,
580
+ activityType: slot.activity.type,
581
+ points,
582
+ contentHash: contentHash(contentOf(slot.activity)),
583
+ ...slot.group !== void 0 ? { group: {
584
+ id: slot.group.id,
585
+ ...slot.group.title !== void 0 ? { title: slot.group.title } : {},
586
+ stimulusHash: contentHash(slot.group.stimulus),
587
+ ...slot.group.cue !== void 0 ? { cue: {
588
+ id: slot.group.cue.id,
589
+ at: slot.group.cue.at,
590
+ ...slot.group.cue.required !== void 0 ? { required: slot.group.cue.required } : {}
591
+ } } : {}
592
+ } } : {},
593
+ ...mediaBudgets !== void 0 ? { mediaBudgets } : {}
594
+ };
589
595
  }
596
+ /**
597
+ * Freezes what an attempt is being served: the presented order, each slot's
598
+ * identity and worth, and a fingerprint of the content behind it.
599
+ *
600
+ * Call this once, when the attempt starts, and store the result beside the
601
+ * responses. Everything that decides the grade is then a historical fact
602
+ * rather than a re-read of content that may since have changed — which is what
603
+ * makes a re-grade, a remark or an appeal answerable.
604
+ *
605
+ * Ordering comes from `flattenSequence`, so a plan and a live render of the
606
+ * same entries with the same seed agree slot for slot.
607
+ *
608
+ * @throws Error when a shuffle is requested without a seed, when a group is
609
+ * empty, when two entries collide on a `slotKey`, or when `points`
610
+ * returns a value that is not finite and non-negative.
611
+ */
590
612
  function planAttempt(entries, options = {}) {
591
- const { seed, shuffleEntries, points } = options;
592
- const delivery = frozenDelivery(options.delivery);
593
- const scoring = frozenScoring(options.scoring);
594
- const slots = flattenSequence(entries, {
595
- ...shuffleEntries !== void 0 ? { shuffleEntries } : {},
596
- ...seed !== void 0 ? { seed } : {}
597
- });
598
- assertNoSplitBudgets(slots);
599
- const planned = slots.map((slot) => planSlot(slot, _nullishCoalesce(_optionalChain([points, 'optionalCall', _14 => _14(slot)]), () => ( 1))));
600
- const totalPoints = planned.reduce((sum, slot) => sum + slot.points, 0);
601
- return {
602
- planVersion: "1.0",
603
- ...seed !== void 0 ? { seed } : {},
604
- ...shuffleEntries !== void 0 ? { shuffleEntries } : {},
605
- // The plan's own fingerprint covers the slots verbatim — identity, order,
606
- // points and content hashes — so one stored value answers "is this still
607
- // the paper that was sat?" and the per-slot hashes then say what moved.
608
- // A policy joins it when there is one: the same paper sat with hints and
609
- // without is two different attempts. Without one, the fingerprint is the
610
- // one every plan before policies had.
611
- // So does a scoring policy: the same answers are worth something else when
612
- // a hint costs a tenth of the marks. Each joins only when it is there, so
613
- // a plan with neither — or with a delivery policy alone — keeps the
614
- // fingerprint it always had.
615
- planHash: contentHash(
616
- delivery === void 0 && scoring === void 0 ? planned : {
617
- slots: planned,
618
- ...delivery !== void 0 ? { delivery } : {},
619
- ...scoring !== void 0 ? { scoring } : {}
620
- }
621
- ),
622
- ...delivery !== void 0 ? { delivery } : {},
623
- ...scoring !== void 0 ? { scoring } : {},
624
- slots: planned,
625
- totalPoints
626
- };
613
+ const { seed, shuffleEntries, points } = options;
614
+ const delivery = frozenDelivery(options.delivery);
615
+ const scoring = frozenScoring(options.scoring);
616
+ const slots = flattenSequence(entries, {
617
+ ...shuffleEntries !== void 0 ? { shuffleEntries } : {},
618
+ ...seed !== void 0 ? { seed } : {}
619
+ });
620
+ assertNoSplitBudgets(slots);
621
+ const planned = slots.map((slot) => planSlot(slot, points?.(slot) ?? 1));
622
+ const totalPoints = planned.reduce((sum, slot) => sum + slot.points, 0);
623
+ return {
624
+ planVersion: "1.0",
625
+ ...seed !== void 0 ? { seed } : {},
626
+ ...shuffleEntries !== void 0 ? { shuffleEntries } : {},
627
+ planHash: contentHash(delivery === void 0 && scoring === void 0 ? planned : {
628
+ slots: planned,
629
+ ...delivery !== void 0 ? { delivery } : {},
630
+ ...scoring !== void 0 ? { scoring } : {}
631
+ }),
632
+ ...delivery !== void 0 ? { delivery } : {},
633
+ ...scoring !== void 0 ? { scoring } : {},
634
+ slots: planned,
635
+ totalPoints
636
+ };
627
637
  }
638
+ /**
639
+ * Compares a stored plan with the content as it stands now, and reports every
640
+ * way they have drifted apart.
641
+ *
642
+ * This is the question a remark or an appeal actually asks: *is the paper I am
643
+ * looking at the paper this learner sat?* Ids alone cannot answer it, because
644
+ * they survive an edit unchanged.
645
+ *
646
+ * Rebuild the comparison plan with the SAME options the stored one recorded —
647
+ * its `seed`, its `shuffleEntries`, and the same `points` function — or the
648
+ * differences you see will be your own:
649
+ *
650
+ * ```ts
651
+ * const now = planAttempt(currentEntries, {
652
+ * seed: stored.seed,
653
+ * shuffleEntries: stored.shuffleEntries,
654
+ * points: pointsFor, // omit it and every slot reweights to 1
655
+ * });
656
+ * const drift = verifyAttemptPlan(stored, now);
657
+ * ```
658
+ *
659
+ * A drift is not automatically a problem — a fixed typo changes a hash without
660
+ * changing what was asked. It is a fact somebody has to be able to see.
661
+ */
628
662
  function verifyAttemptPlan(plan, current) {
629
- const before = new Map(plan.slots.map((slot) => [slot.slotId, slot]));
630
- const after = new Map(current.slots.map((slot) => [slot.slotId, slot]));
631
- const missingSlotIds = plan.slots.filter((s) => !after.has(s.slotId)).map((s) => s.slotId);
632
- const addedSlotIds = current.slots.filter((s) => !before.has(s.slotId)).map((s) => s.slotId);
633
- const changedSlotIds = [];
634
- const changedStimulusSlotIds = [];
635
- const changedCueSlotIds = [];
636
- const changedPointsSlotIds = [];
637
- const reorderedSlotIds = [];
638
- const deliveryChanged = contentHash(_nullishCoalesce(plan.delivery, () => ( null))) !== contentHash(_nullishCoalesce(current.delivery, () => ( null)));
639
- const scoringChanged = contentHash(_nullishCoalesce(plan.scoring, () => ( null))) !== contentHash(_nullishCoalesce(current.scoring, () => ( null)));
640
- for (const slot of plan.slots) {
641
- const now = after.get(slot.slotId);
642
- if (now === void 0) {
643
- continue;
644
- }
645
- if (now.contentHash !== slot.contentHash) {
646
- changedSlotIds.push(slot.slotId);
647
- }
648
- if (_optionalChain([now, 'access', _15 => _15.group, 'optionalAccess', _16 => _16.stimulusHash]) !== _optionalChain([slot, 'access', _17 => _17.group, 'optionalAccess', _18 => _18.stimulusHash])) {
649
- changedStimulusSlotIds.push(slot.slotId);
650
- }
651
- if (contentHash(_nullishCoalesce(_optionalChain([now, 'access', _19 => _19.group, 'optionalAccess', _20 => _20.cue]), () => ( null))) !== contentHash(_nullishCoalesce(_optionalChain([slot, 'access', _21 => _21.group, 'optionalAccess', _22 => _22.cue]), () => ( null)))) {
652
- changedCueSlotIds.push(slot.slotId);
653
- }
654
- if (now.points !== slot.points) {
655
- changedPointsSlotIds.push(slot.slotId);
656
- }
657
- if (now.index !== slot.index) {
658
- reorderedSlotIds.push(slot.slotId);
659
- }
660
- }
661
- return {
662
- matches: missingSlotIds.length === 0 && addedSlotIds.length === 0 && changedSlotIds.length === 0 && changedStimulusSlotIds.length === 0 && changedCueSlotIds.length === 0 && changedPointsSlotIds.length === 0 && reorderedSlotIds.length === 0 && !deliveryChanged && !scoringChanged,
663
- missingSlotIds,
664
- addedSlotIds,
665
- changedSlotIds,
666
- changedStimulusSlotIds,
667
- changedCueSlotIds,
668
- changedPointsSlotIds,
669
- reorderedSlotIds,
670
- // Present only when it is true, as `rejectedSlotIds` is on a composed
671
- // score: a drift report between two plans without a policy — every one
672
- // written before policies existed — keeps exactly the shape it had.
673
- ...deliveryChanged ? { deliveryChanged: true } : {},
674
- ...scoringChanged ? { scoringChanged: true } : {}
675
- };
663
+ const before = new Map(plan.slots.map((slot) => [slot.slotId, slot]));
664
+ const after = new Map(current.slots.map((slot) => [slot.slotId, slot]));
665
+ const missingSlotIds = plan.slots.filter((s) => !after.has(s.slotId)).map((s) => s.slotId);
666
+ const addedSlotIds = current.slots.filter((s) => !before.has(s.slotId)).map((s) => s.slotId);
667
+ const changedSlotIds = [];
668
+ const changedStimulusSlotIds = [];
669
+ const changedCueSlotIds = [];
670
+ const changedPointsSlotIds = [];
671
+ const reorderedSlotIds = [];
672
+ const deliveryChanged = contentHash(plan.delivery ?? null) !== contentHash(current.delivery ?? null);
673
+ const scoringChanged = contentHash(plan.scoring ?? null) !== contentHash(current.scoring ?? null);
674
+ for (const slot of plan.slots) {
675
+ const now = after.get(slot.slotId);
676
+ if (now === void 0) continue;
677
+ if (now.contentHash !== slot.contentHash) changedSlotIds.push(slot.slotId);
678
+ if (now.group?.stimulusHash !== slot.group?.stimulusHash) changedStimulusSlotIds.push(slot.slotId);
679
+ if (contentHash(now.group?.cue ?? null) !== contentHash(slot.group?.cue ?? null)) changedCueSlotIds.push(slot.slotId);
680
+ if (now.points !== slot.points) changedPointsSlotIds.push(slot.slotId);
681
+ if (now.index !== slot.index) reorderedSlotIds.push(slot.slotId);
682
+ }
683
+ return {
684
+ matches: missingSlotIds.length === 0 && addedSlotIds.length === 0 && changedSlotIds.length === 0 && changedStimulusSlotIds.length === 0 && changedCueSlotIds.length === 0 && changedPointsSlotIds.length === 0 && reorderedSlotIds.length === 0 && !deliveryChanged && !scoringChanged,
685
+ missingSlotIds,
686
+ addedSlotIds,
687
+ changedSlotIds,
688
+ changedStimulusSlotIds,
689
+ changedCueSlotIds,
690
+ changedPointsSlotIds,
691
+ reorderedSlotIds,
692
+ ...deliveryChanged ? { deliveryChanged: true } : {},
693
+ ...scoringChanged ? { scoringChanged: true } : {}
694
+ };
676
695
  }
696
+ /**
697
+ * The policy a plan records: checked, then spelled out. `undefined` when there
698
+ * is none, so the plan carries no field at all rather than an empty one.
699
+ */
677
700
  function frozenDelivery(policy) {
678
- if (policy === void 0 || policy === null) {
679
- return void 0;
680
- }
681
- const checked = validateDeliveryPolicy(policy);
682
- if (!checked.success) {
683
- const first = checked.issues[0];
684
- throw new Error(
685
- `planAttempt: the delivery policy is not one to record \u2014 ${_nullishCoalesce(_optionalChain([first, 'optionalAccess', _23 => _23.message]), () => ( "invalid"))}` + (checked.issues.length > 1 ? ` (and ${checked.issues.length - 1} more)` : "")
686
- );
687
- }
688
- return resolveDeliveryPolicy(checked.data);
701
+ if (policy === void 0 || policy === null) return;
702
+ const checked = validateDeliveryPolicy(policy);
703
+ if (!checked.success) {
704
+ const first = checked.issues[0];
705
+ throw new Error(`planAttempt: the delivery policy is not one to record — ${first?.message ?? "invalid"}` + (checked.issues.length > 1 ? ` (and ${checked.issues.length - 1} more)` : ""));
706
+ }
707
+ return resolveDeliveryPolicy(checked.data);
689
708
  }
709
+ /** The scoring policy a plan records, as {@link frozenDelivery} records a delivery policy. */
690
710
  function frozenScoring(policy) {
691
- if (policy === void 0 || policy === null) {
692
- return void 0;
693
- }
694
- const checked = _chunk4YANYTFNcjs.validateItemScoringPolicy.call(void 0, policy);
695
- if (!checked.success) {
696
- const first = checked.issues[0];
697
- throw new Error(
698
- `planAttempt: the scoring policy is not one to record \u2014 ${_nullishCoalesce(_optionalChain([first, 'optionalAccess', _24 => _24.message]), () => ( "invalid"))}` + (checked.issues.length > 1 ? ` (and ${checked.issues.length - 1} more)` : "")
699
- );
700
- }
701
- return _chunk4YANYTFNcjs.resolveItemScoringPolicy.call(void 0, checked.data);
711
+ if (policy === void 0 || policy === null) return;
712
+ const checked = require_scoring.validateItemScoringPolicy(policy);
713
+ if (!checked.success) {
714
+ const first = checked.issues[0];
715
+ throw new Error(`planAttempt: the scoring policy is not one to record — ${first?.message ?? "invalid"}` + (checked.issues.length > 1 ? ` (and ${checked.issues.length - 1} more)` : ""));
716
+ }
717
+ return require_scoring.resolveItemScoringPolicy(checked.data);
702
718
  }
703
719
  function missingOutcome(slot, policy) {
704
- if (typeof policy === "function") {
705
- return policy(slot);
706
- }
707
- if (policy === "zero") {
708
- return { status: "scored", score: 0, maxScore: 1, passed: false, feedback: null, details: [] };
709
- }
710
- return { status: "deferred", reason: "no_response_recorded", maxScore: 1 };
720
+ if (typeof policy === "function") return policy(slot);
721
+ if (policy === "zero") return {
722
+ status: "scored",
723
+ score: 0,
724
+ maxScore: 1,
725
+ passed: false,
726
+ feedback: null,
727
+ details: []
728
+ };
729
+ return {
730
+ status: "deferred",
731
+ reason: "no_response_recorded",
732
+ maxScore: 1
733
+ };
711
734
  }
735
+ /**
736
+ * Turns a plan plus whatever outcomes exist into the `ScoredItem[]`
737
+ * `composeAssessmentScore` consumes.
738
+ *
739
+ * The plan is the source of the denominator, not the outcomes. A slot the
740
+ * learner never reached still has to appear — otherwise it silently leaves the
741
+ * denominator and the remaining questions quietly become worth more than the
742
+ * paper says.
743
+ *
744
+ * **A missing outcome defaults to `deferred`, never `unscorable`.** The
745
+ * distinction decides a grade: `unscorable` means "a grade is never coming",
746
+ * so `composeAssessmentScore` drops the slot from the denominator AND lets the
747
+ * result go `final` — which turned a three-question paper with one answer into
748
+ * a final, passing 100%. `deferred` means "not yet", which holds the result
749
+ * `provisional` so nothing can be recorded. Pass `'zero'` once you know the
750
+ * attempt was submitted and the blanks are genuinely blanks.
751
+ *
752
+ * Pass outcomes keyed by `slotId`. Keys the plan does not know are ignored:
753
+ * a plan is the authority on what the attempt contained.
754
+ */
712
755
  function scoredItemsFromPlan(plan, outcomes, options = {}) {
713
- const policy = _nullishCoalesce(options.missing, () => ( "deferred"));
714
- return plan.slots.map((slot) => ({
715
- slotId: slot.slotId,
716
- activityId: slot.activityId,
717
- points: slot.points,
718
- // `Object.hasOwn`, not a bare lookup: slot ids come from authored
719
- // `slotKey`s, and a key of `constructor` or `toString` would otherwise
720
- // resolve through the prototype chain and hand a FUNCTION to the scorer
721
- // in place of an outcome.
722
- outcome: Object.hasOwn(outcomes, slot.slotId) ? outcomes[slot.slotId] : missingOutcome(slot, policy)
723
- }));
756
+ const policy = options.missing ?? "deferred";
757
+ return plan.slots.map((slot) => ({
758
+ slotId: slot.slotId,
759
+ activityId: slot.activityId,
760
+ points: slot.points,
761
+ outcome: Object.hasOwn(outcomes, slot.slotId) ? outcomes[slot.slotId] : missingOutcome(slot, policy)
762
+ }));
724
763
  }
725
-
726
- // src/attempt-state.ts
764
+ //#endregion
765
+ //#region src/attempt-state.ts
766
+ /**
767
+ * A DEEP copy of the responses.
768
+ *
769
+ * A one-level spread is not enough: every `LearnerResponse` shape is itself an
770
+ * object (`selectedOptionIds`, `answers`, `text`), so a spread hands back a new
771
+ * map of the caller's SAME response objects. A consumer whose reducer updates
772
+ * an answer in place — an Immer draft, a push onto a multi-select, or
773
+ * `answers[blankId] = text`, which is the natural update for that shape — then
774
+ * mutates every snapshot ever taken. The stored snapshot retroactively becomes
775
+ * the new answer, `diffResponses` sees no change, and a delta autosave writes
776
+ * nothing for an edit the learner really made.
777
+ */
727
778
  function cloneResponses(responses) {
728
- return typeof structuredClone === "function" ? structuredClone(responses) : JSON.parse(JSON.stringify(responses));
779
+ return typeof structuredClone === "function" ? structuredClone(responses) : JSON.parse(JSON.stringify(responses));
729
780
  }
781
+ /** Slot ids present in the object but unknown to the plan. */
730
782
  function unknownKeys(plan, keys) {
731
- const known = new Set(plan.slots.map((slot) => slot.slotId));
732
- return keys.filter((key) => !known.has(key));
783
+ const known = new Set(plan.slots.map((slot) => slot.slotId));
784
+ return keys.filter((key) => !known.has(key));
733
785
  }
786
+ /**
787
+ * Captures an in-progress attempt as a storable snapshot, bound to its plan.
788
+ *
789
+ * Validated against the plan on the way in, not on the way out: a response
790
+ * stored under a slot the paper does not contain is a bug at the moment it is
791
+ * written, and finding it months later — when a learner tries to resume — is
792
+ * finding it far too late.
793
+ *
794
+ * The SDK reads no clock: pass `savedAt` if you want the snapshot stamped, so
795
+ * the function stays pure and its output stays reproducible in a test.
796
+ *
797
+ * @throws Error when a response or submitted slot is not in the plan, or when
798
+ * `index` is not a position the plan actually has.
799
+ */
734
800
  function serializeAttemptState(plan, progress) {
735
- const responseKeys = Object.keys(progress.responses);
736
- const submittedSlotIds = [..._nullishCoalesce(progress.submittedSlotIds, () => ( []))];
737
- const strayResponses = unknownKeys(plan, responseKeys);
738
- if (strayResponses.length > 0) {
739
- throw new Error(
740
- `serializeAttemptState: response recorded for slot(s) the plan does not contain: ${strayResponses.join(", ")}. A response that belongs to no question cannot be scored and would be lost silently.`
741
- );
742
- }
743
- const straySubmitted = unknownKeys(plan, submittedSlotIds);
744
- if (straySubmitted.length > 0) {
745
- throw new Error(
746
- `serializeAttemptState: submitted slot(s) the plan does not contain: ${straySubmitted.join(", ")}.`
747
- );
748
- }
749
- const index = _nullishCoalesce(progress.index, () => ( 0));
750
- const lastIndex = Math.max(0, plan.slots.length - 1);
751
- if (!Number.isInteger(index) || index < 0 || index > lastIndex) {
752
- throw new Error(
753
- `serializeAttemptState: index ${String(index)} is not a position in a ${plan.slots.length}-slot plan.`
754
- );
755
- }
756
- return {
757
- stateVersion: "1.0",
758
- planHash: plan.planHash,
759
- // Copied DEEPLY, not aliased: a snapshot that keeps mutating with the
760
- // live attempt is not a snapshot. See cloneResponses.
761
- responses: cloneResponses(progress.responses),
762
- submittedSlotIds,
763
- index,
764
- ...progress.savedAt !== void 0 ? { savedAt: progress.savedAt } : {}
765
- };
801
+ const responseKeys = Object.keys(progress.responses);
802
+ const submittedSlotIds = [...progress.submittedSlotIds ?? []];
803
+ const strayResponses = unknownKeys(plan, responseKeys);
804
+ if (strayResponses.length > 0) throw new Error(`serializeAttemptState: response recorded for slot(s) the plan does not contain: ${strayResponses.join(", ")}. A response that belongs to no question cannot be scored and would be lost silently.`);
805
+ const straySubmitted = unknownKeys(plan, submittedSlotIds);
806
+ if (straySubmitted.length > 0) throw new Error(`serializeAttemptState: submitted slot(s) the plan does not contain: ${straySubmitted.join(", ")}.`);
807
+ const index = progress.index ?? 0;
808
+ const lastIndex = Math.max(0, plan.slots.length - 1);
809
+ if (!Number.isInteger(index) || index < 0 || index > lastIndex) throw new Error(`serializeAttemptState: index ${String(index)} is not a position in a ${plan.slots.length}-slot plan.`);
810
+ return {
811
+ stateVersion: "1.0",
812
+ planHash: plan.planHash,
813
+ responses: cloneResponses(progress.responses),
814
+ submittedSlotIds,
815
+ index,
816
+ ...progress.savedAt !== void 0 ? { savedAt: progress.savedAt } : {}
817
+ };
766
818
  }
819
+ /**
820
+ * Reopens a stored snapshot against a plan, refusing anything that does not
821
+ * belong to it.
822
+ *
823
+ * The `planHash` check is the point. Slot ids are short and stable by design,
824
+ * so a snapshot from a DIFFERENT paper — last term's midterm, a sibling
825
+ * version, a copy-pasted attempt row — will happily line its answers up
826
+ * against the wrong questions and look entirely plausible doing it. Comparing
827
+ * the paper's fingerprint is what makes that impossible rather than unlikely.
828
+ *
829
+ * @throws Error when the snapshot belongs to a different plan, or references
830
+ * slots the plan does not contain.
831
+ */
767
832
  function restoreAttemptState(plan, state) {
768
- if (state === null || typeof state !== "object") {
769
- throw new Error("restoreAttemptState: the snapshot is not an object.");
770
- }
771
- if (state.stateVersion !== "1.0") {
772
- throw new Error(
773
- `restoreAttemptState: unsupported stateVersion ${JSON.stringify(state.stateVersion)}. This build understands "1.0"; a newer snapshot must be migrated before it is restored.`
774
- );
775
- }
776
- if (state.responses === null || typeof state.responses !== "object") {
777
- throw new Error(
778
- "restoreAttemptState: the snapshot carries no responses object (a NULL column, or a partially written row)."
779
- );
780
- }
781
- if (state.planHash !== plan.planHash) {
782
- throw new Error(
783
- `restoreAttemptState: this snapshot belongs to a different paper (snapshot ${state.planHash}, plan ${plan.planHash}). Restoring it would attach the learner's answers to questions they never saw.`
784
- );
785
- }
786
- return serializeAttemptState(plan, {
787
- responses: state.responses,
788
- submittedSlotIds: state.submittedSlotIds,
789
- index: state.index,
790
- ...state.savedAt !== void 0 ? { savedAt: state.savedAt } : {}
791
- });
833
+ if (state === null || typeof state !== "object") throw new Error("restoreAttemptState: the snapshot is not an object.");
834
+ if (state.stateVersion !== "1.0") throw new Error(`restoreAttemptState: unsupported stateVersion ${JSON.stringify(state.stateVersion)}. This build understands "1.0"; a newer snapshot must be migrated before it is restored.`);
835
+ if (state.responses === null || typeof state.responses !== "object") throw new Error("restoreAttemptState: the snapshot carries no responses object (a NULL column, or a partially written row).");
836
+ if (state.planHash !== plan.planHash) throw new Error(`restoreAttemptState: this snapshot belongs to a different paper (snapshot ${state.planHash}, plan ${plan.planHash}). Restoring it would attach the learner's answers to questions they never saw.`);
837
+ return serializeAttemptState(plan, {
838
+ responses: state.responses,
839
+ submittedSlotIds: state.submittedSlotIds,
840
+ index: state.index,
841
+ ...state.savedAt !== void 0 ? { savedAt: state.savedAt } : {}
842
+ });
792
843
  }
844
+ /**
845
+ * Reports how two snapshots' responses differ, slot by slot.
846
+ *
847
+ * Useful for an autosave that should only write what moved, and for an audit
848
+ * trail that has to show what a learner changed between two saves — including
849
+ * an answer they cleared, which a naive comparison of the later snapshot
850
+ * alone cannot see.
851
+ *
852
+ * Entries come back sorted by `slotId`, so the output is stable regardless of
853
+ * the order the two objects happened to be written in.
854
+ */
793
855
  function diffResponses(before, after) {
794
- const slotIds = [.../* @__PURE__ */ new Set([...Object.keys(before.responses), ...Object.keys(after.responses)])];
795
- slotIds.sort();
796
- const entries = [];
797
- for (const slotId of slotIds) {
798
- const had = Object.hasOwn(before.responses, slotId);
799
- const has = Object.hasOwn(after.responses, slotId);
800
- const from = before.responses[slotId];
801
- const to = after.responses[slotId];
802
- if (had && !has) {
803
- entries.push({ slotId, change: "removed", ...from !== void 0 ? { before: from } : {} });
804
- continue;
805
- }
806
- if (!had && has) {
807
- entries.push({ slotId, change: "added", ...to !== void 0 ? { after: to } : {} });
808
- continue;
809
- }
810
- if (canonicalJson(from) !== canonicalJson(to)) {
811
- entries.push({
812
- slotId,
813
- change: "changed",
814
- ...from !== void 0 ? { before: from } : {},
815
- ...to !== void 0 ? { after: to } : {}
816
- });
817
- }
818
- }
819
- return entries;
856
+ const slotIds = [.../* @__PURE__ */ new Set([...Object.keys(before.responses), ...Object.keys(after.responses)])];
857
+ slotIds.sort();
858
+ const entries = [];
859
+ for (const slotId of slotIds) {
860
+ const had = Object.hasOwn(before.responses, slotId);
861
+ const has = Object.hasOwn(after.responses, slotId);
862
+ const from = before.responses[slotId];
863
+ const to = after.responses[slotId];
864
+ if (had && !has) {
865
+ entries.push({
866
+ slotId,
867
+ change: "removed",
868
+ ...from !== void 0 ? { before: from } : {}
869
+ });
870
+ continue;
871
+ }
872
+ if (!had && has) {
873
+ entries.push({
874
+ slotId,
875
+ change: "added",
876
+ ...to !== void 0 ? { after: to } : {}
877
+ });
878
+ continue;
879
+ }
880
+ if (canonicalJson(from) !== canonicalJson(to)) entries.push({
881
+ slotId,
882
+ change: "changed",
883
+ ...from !== void 0 ? { before: from } : {},
884
+ ...to !== void 0 ? { after: to } : {}
885
+ });
886
+ }
887
+ return entries;
820
888
  }
821
-
822
- // src/redact.ts
889
+ //#endregion
890
+ //#region src/redact.ts
891
+ /** Merges per-call overrides over a registered policy, one level deep. */
823
892
  function mergePolicy(base, overrides) {
824
- if (overrides === void 0) {
825
- return base;
826
- }
827
- const merged = { ...base };
828
- for (const [key, override] of Object.entries(overrides)) {
829
- const current = merged[key];
830
- merged[key] = typeof override === "object" && typeof current === "object" ? { ...current, ...override } : override;
831
- }
832
- return merged;
893
+ if (overrides === void 0) return base;
894
+ const merged = { ...base };
895
+ for (const [key, override] of Object.entries(overrides)) {
896
+ const current = merged[key];
897
+ merged[key] = typeof override === "object" && typeof current === "object" ? {
898
+ ...current,
899
+ ...override
900
+ } : override;
901
+ }
902
+ return merged;
833
903
  }
834
904
  function isSensitivity(value) {
835
- return typeof value === "string";
905
+ return typeof value === "string";
836
906
  }
837
907
  function keepField(sensitivity, reveal) {
838
- if (sensitivity === "public") {
839
- return true;
840
- }
841
- return sensitivity === "answer-key" && reveal === "after-submit";
908
+ if (sensitivity === "public") return true;
909
+ return sensitivity === "answer-key" && reveal === "after-submit";
842
910
  }
911
+ /** An object projection that kept no fields at all. Arrays are never dropped. */
843
912
  function isEmptyObject(value) {
844
- return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
913
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
845
914
  }
846
915
  function redactValue(value, policy, reveal) {
847
- if (Array.isArray(value)) {
848
- return value.map((element) => redactValue(element, policy, reveal));
849
- }
850
- if (value === null || typeof value !== "object") {
851
- return void 0;
852
- }
853
- const source = value;
854
- const output = {};
855
- for (const [key, fieldValue] of Object.entries(source)) {
856
- if (fieldValue === void 0) {
857
- continue;
858
- }
859
- const classification = policy[key];
860
- if (classification === void 0) {
861
- continue;
862
- }
863
- if (isSensitivity(classification)) {
864
- if (keepField(classification, reveal)) {
865
- output[key] = fieldValue;
866
- }
867
- continue;
868
- }
869
- const nested = redactValue(fieldValue, classification, reveal);
870
- if (nested !== void 0 && !isEmptyObject(nested)) {
871
- output[key] = nested;
872
- }
873
- }
874
- return output;
916
+ if (Array.isArray(value)) return value.map((element) => redactValue(element, policy, reveal));
917
+ if (value === null || typeof value !== "object") return;
918
+ const source = value;
919
+ const output = {};
920
+ for (const [key, fieldValue] of Object.entries(source)) {
921
+ if (fieldValue === void 0) continue;
922
+ const classification = policy[key];
923
+ if (classification === void 0) continue;
924
+ if (isSensitivity(classification)) {
925
+ if (keepField(classification, reveal)) output[key] = fieldValue;
926
+ continue;
927
+ }
928
+ const nested = redactValue(fieldValue, classification, reveal);
929
+ if (nested !== void 0 && !isEmptyObject(nested)) output[key] = nested;
930
+ }
931
+ return output;
875
932
  }
933
+ /**
934
+ * Produces the learner-safe projection of activity data (R7), driven by the
935
+ * `fieldPolicy` registered for `data.type`. Fail-closed and exhaustive by
936
+ * construction: a field the policy does not classify is removed — including
937
+ * unknown passthrough fields — so a NEW field added by a future SDK or a
938
+ * consumer sidecar can never leak through an out-of-date redactor.
939
+ *
940
+ * With the default `reveal: 'none'`, the output validates against the type's
941
+ * strict redacted schema (verified here; an invalid projection throws
942
+ * {@link ActivitySchemaError} rather than shipping an unproven payload).
943
+ *
944
+ * @throws UnknownActivityTypeError when `data.type` is not registered.
945
+ * @throws Error when the registered descriptor declares no `fieldPolicy`
946
+ * (redaction cannot guess sensitivities).
947
+ */
876
948
  function redact(data, options = {}) {
877
- const reveal = _nullishCoalesce(options.reveal, () => ( "none"));
878
- const descriptor = _chunk7THXD7ANcjs.getActivityTypeDescriptor.call(void 0, data.type);
879
- if (descriptor === void 0) {
880
- throw new (0, _chunk7THXD7ANcjs.UnknownActivityTypeError)(String(data.type));
881
- }
882
- if (descriptor.fieldPolicy === void 0) {
883
- throw new Error(
884
- `Activity type "${descriptor.type}" has no fieldPolicy; redact() cannot run fail-closed redaction without one.`
885
- );
886
- }
887
- const effectivePolicy = mergePolicy(descriptor.fieldPolicy, options.policy);
888
- const projected = redactValue(data, effectivePolicy, reveal);
889
- const result = { ...projected, redacted: true };
890
- if (reveal === "none" && descriptor.redactedSchema !== void 0) {
891
- const parsed = _chunk7THXD7ANcjs.readSchema.call(void 0,
892
- descriptor.redactedSchema,
893
- result,
894
- `Activity type "${descriptor.type}"`
895
- );
896
- if (!parsed.success) {
897
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(
898
- descriptor.type,
899
- parsed.issues.map((issue) => ({
900
- path: issue.path.map(String),
901
- message: issue.message,
902
- code: issue.code
903
- }))
904
- );
905
- }
906
- }
907
- return result;
949
+ const reveal = options.reveal ?? "none";
950
+ const descriptor = require_registry.getActivityTypeDescriptor(data.type);
951
+ if (descriptor === void 0) throw new require_registry.UnknownActivityTypeError(String(data.type));
952
+ if (descriptor.fieldPolicy === void 0) throw new Error(`Activity type "${descriptor.type}" has no fieldPolicy; redact() cannot run fail-closed redaction without one.`);
953
+ const result = {
954
+ ...redactValue(data, mergePolicy(descriptor.fieldPolicy, options.policy), reveal),
955
+ redacted: true
956
+ };
957
+ if (reveal === "none" && descriptor.redactedSchema !== void 0) {
958
+ const parsed = require_registry.readSchema(descriptor.redactedSchema, result, `Activity type "${descriptor.type}"`);
959
+ if (!parsed.success) throw new require_registry.ActivitySchemaError(descriptor.type, parsed.issues.map((issue) => ({
960
+ path: issue.path.map(String),
961
+ message: issue.message,
962
+ code: issue.code
963
+ })));
964
+ }
965
+ return result;
908
966
  }
967
+ /**
968
+ * Asserts that `data` is a learner-safe redacted projection: it carries the
969
+ * `redacted: true` marker and, when its type registers a strict redacted
970
+ * schema, validates against it (proving the absence of answer-key fields).
971
+ * Use this at the server boundary before sending activity data to a client
972
+ * that must not hold the key.
973
+ */
909
974
  function assertRedacted(data) {
910
- if (typeof data !== "object" || data === null) {
911
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)("unknown", [
912
- { path: [], message: "Redacted activity data must be an object.", code: "invalid_type" }
913
- ]);
914
- }
915
- const candidate = data;
916
- if (candidate.redacted !== true) {
917
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(String(_nullishCoalesce(candidate.type, () => ( "unknown"))), [
918
- {
919
- path: ["redacted"],
920
- message: "Missing redacted marker \u2014 this payload is not a redact() projection.",
921
- code: "custom"
922
- }
923
- ]);
924
- }
925
- const type = typeof candidate.type === "string" ? candidate.type : "";
926
- const descriptor = _chunk7THXD7ANcjs.getActivityTypeDescriptor.call(void 0, type);
927
- if (descriptor === void 0) {
928
- throw new (0, _chunk7THXD7ANcjs.UnknownActivityTypeError)(type);
929
- }
930
- if (descriptor.redactedSchema === void 0) {
931
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(descriptor.type, [
932
- {
933
- path: [],
934
- message: `Activity type "${descriptor.type}" registers no redactedSchema; assertRedacted cannot prove this payload is learner-safe.`,
935
- code: "custom"
936
- }
937
- ]);
938
- }
939
- const parsed = _chunk7THXD7ANcjs.readSchema.call(void 0,
940
- descriptor.redactedSchema,
941
- candidate,
942
- `Activity type "${descriptor.type}"`
943
- );
944
- if (!parsed.success) {
945
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(
946
- descriptor.type,
947
- parsed.issues.map((issue) => ({
948
- path: issue.path.map(String),
949
- message: issue.message,
950
- code: issue.code
951
- }))
952
- );
953
- }
975
+ if (typeof data !== "object" || data === null) throw new require_registry.ActivitySchemaError("unknown", [{
976
+ path: [],
977
+ message: "Redacted activity data must be an object.",
978
+ code: "invalid_type"
979
+ }]);
980
+ const candidate = data;
981
+ if (candidate.redacted !== true) throw new require_registry.ActivitySchemaError(String(candidate.type ?? "unknown"), [{
982
+ path: ["redacted"],
983
+ message: "Missing redacted marker — this payload is not a redact() projection.",
984
+ code: "custom"
985
+ }]);
986
+ const type = typeof candidate.type === "string" ? candidate.type : "";
987
+ const descriptor = require_registry.getActivityTypeDescriptor(type);
988
+ if (descriptor === void 0) throw new require_registry.UnknownActivityTypeError(type);
989
+ if (descriptor.redactedSchema === void 0) throw new require_registry.ActivitySchemaError(descriptor.type, [{
990
+ path: [],
991
+ message: `Activity type "${descriptor.type}" registers no redactedSchema; assertRedacted cannot prove this payload is learner-safe.`,
992
+ code: "custom"
993
+ }]);
994
+ const parsed = require_registry.readSchema(descriptor.redactedSchema, candidate, `Activity type "${descriptor.type}"`);
995
+ if (!parsed.success) throw new require_registry.ActivitySchemaError(descriptor.type, parsed.issues.map((issue) => ({
996
+ path: issue.path.map(String),
997
+ message: issue.message,
998
+ code: issue.code
999
+ })));
954
1000
  }
955
- var STIMULUS_FIELD_POLICY = {
956
- id: "public",
957
- kind: "public",
958
- title: "public",
959
- body: "public",
960
- bodyHtml: "public",
961
- media: _chunk7THXD7ANcjs.MEDIA_FIELD_POLICY,
962
- locale: "public",
963
- attribution: "public",
964
- transcript: "author-only"
965
- };
966
- var ITEM_GROUP_FIELD_POLICY = {
967
- schemaVersion: "public",
968
- type: "public",
969
- id: "public",
970
- title: "public",
971
- // See the note on the built-in activity policies: a slot key is identity,
972
- // and identity has to cross the redaction boundary intact.
973
- slotKey: "public",
974
- shuffle: "public",
975
- stimulus: STIMULUS_FIELD_POLICY,
976
- // Public, and classified key by key rather than as one leaf: the learner's
977
- // player needs every part of it, none of it is a key — and a leaf would hand
978
- // over the author's object by reference, with whatever else was parked in it.
979
- timeline: {
980
- // Applies to every element of the array.
981
- cues: {
982
- id: "public",
983
- at: "public",
984
- itemIds: "public",
985
- title: "public",
986
- required: "public"
987
- },
988
- chapters: {
989
- at: "public",
990
- title: "public"
991
- },
992
- navigation: "public"
993
- }
1001
+ /** The group container's own fields. `items` is handled separately, per item type. */
1002
+ const ITEM_GROUP_FIELD_POLICY = {
1003
+ schemaVersion: "public",
1004
+ type: "public",
1005
+ id: "public",
1006
+ title: "public",
1007
+ slotKey: "public",
1008
+ shuffle: "public",
1009
+ stimulus: {
1010
+ id: "public",
1011
+ kind: "public",
1012
+ title: "public",
1013
+ body: "public",
1014
+ bodyHtml: "public",
1015
+ media: require_registry.MEDIA_FIELD_POLICY,
1016
+ locale: "public",
1017
+ attribution: "public",
1018
+ transcript: "author-only"
1019
+ },
1020
+ timeline: {
1021
+ cues: {
1022
+ id: "public",
1023
+ at: "public",
1024
+ itemIds: "public",
1025
+ title: "public",
1026
+ required: "public"
1027
+ },
1028
+ chapters: {
1029
+ at: "public",
1030
+ title: "public"
1031
+ },
1032
+ navigation: "public"
1033
+ }
994
1034
  };
1035
+ /**
1036
+ * Produces the learner-safe projection of an item group: the container and
1037
+ * stimulus under their own fail-closed policy (the author transcript goes;
1038
+ * the passage, media and attribution stay — the learner is meant to see
1039
+ * them), and every item through {@link redact} with the same `options`, so
1040
+ * a per-call `policy` tightens each item exactly as it would alone.
1041
+ *
1042
+ * With the default `reveal: 'none'` the container is verified against the
1043
+ * strict redacted schema; each item was already verified by `redact`.
1044
+ */
995
1045
  function redactItemGroup(group, options = {}) {
996
- const reveal = _nullishCoalesce(options.reveal, () => ( "none"));
997
- const { items, ...container } = group;
998
- const projected = redactValue(container, ITEM_GROUP_FIELD_POLICY, reveal);
999
- const result = {
1000
- ...projected,
1001
- items: items.map((item) => redact(item, options)),
1002
- redacted: true
1003
- };
1004
- if (reveal === "none") {
1005
- const parsed = _chunkZWXQSKCXcjs.RedactedItemGroupSchema.safeParse(result);
1006
- if (!parsed.success) {
1007
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(
1008
- "item-group",
1009
- parsed.error.issues.map((issue) => ({
1010
- path: issue.path.map(String),
1011
- message: issue.message,
1012
- code: issue.code
1013
- }))
1014
- );
1015
- }
1016
- }
1017
- return result;
1046
+ const reveal = options.reveal ?? "none";
1047
+ const { items, ...container } = group;
1048
+ const result = {
1049
+ ...redactValue(container, ITEM_GROUP_FIELD_POLICY, reveal),
1050
+ items: items.map((item) => redact(item, options)),
1051
+ redacted: true
1052
+ };
1053
+ if (reveal === "none") {
1054
+ const parsed = require_item_group.RedactedItemGroupSchema.safeParse(result);
1055
+ if (!parsed.success) throw new require_registry.ActivitySchemaError("item-group", parsed.error.issues.map((issue) => ({
1056
+ path: issue.path.map(String),
1057
+ message: issue.message,
1058
+ code: issue.code
1059
+ })));
1060
+ }
1061
+ return result;
1018
1062
  }
1063
+ /**
1064
+ * Asserts that `data` is a learner-safe item group: it carries the marker,
1065
+ * its container and stimulus satisfy the strict redacted schema (so no
1066
+ * transcript, no unclassified field), and EVERY item passes
1067
+ * {@link assertRedacted} against its own type's redacted schema. Item
1068
+ * failures are reported at `items.<index>.…`. Use it at the server boundary
1069
+ * before sending a group to a client that must not hold the key.
1070
+ */
1019
1071
  function assertRedactedItemGroup(data) {
1020
- if (typeof data !== "object" || data === null) {
1021
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)("item-group", [
1022
- { path: [], message: "Redacted item group must be an object.", code: "invalid_type" }
1023
- ]);
1024
- }
1025
- const candidate = data;
1026
- if (candidate.redacted !== true) {
1027
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)("item-group", [
1028
- {
1029
- path: ["redacted"],
1030
- message: "Missing redacted marker \u2014 this payload is not a redactItemGroup() projection.",
1031
- code: "custom"
1032
- }
1033
- ]);
1034
- }
1035
- const parsed = _chunkZWXQSKCXcjs.RedactedItemGroupSchema.safeParse(candidate);
1036
- if (!parsed.success) {
1037
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(
1038
- "item-group",
1039
- parsed.error.issues.map((issue) => ({
1040
- path: issue.path.map(String),
1041
- message: issue.message,
1042
- code: issue.code
1043
- }))
1044
- );
1045
- }
1046
- parsed.data.items.forEach((item, index) => {
1047
- try {
1048
- assertRedacted(item);
1049
- } catch (error) {
1050
- if (error instanceof _chunk7THXD7ANcjs.ActivitySchemaError) {
1051
- throw new (0, _chunk7THXD7ANcjs.ActivitySchemaError)(
1052
- "item-group",
1053
- error.errors.map((issue) => ({
1054
- ...issue,
1055
- path: ["items", String(index), ...issue.path]
1056
- }))
1057
- );
1058
- }
1059
- throw error;
1060
- }
1061
- });
1072
+ if (typeof data !== "object" || data === null) throw new require_registry.ActivitySchemaError("item-group", [{
1073
+ path: [],
1074
+ message: "Redacted item group must be an object.",
1075
+ code: "invalid_type"
1076
+ }]);
1077
+ const candidate = data;
1078
+ if (candidate.redacted !== true) throw new require_registry.ActivitySchemaError("item-group", [{
1079
+ path: ["redacted"],
1080
+ message: "Missing redacted marker — this payload is not a redactItemGroup() projection.",
1081
+ code: "custom"
1082
+ }]);
1083
+ const parsed = require_item_group.RedactedItemGroupSchema.safeParse(candidate);
1084
+ if (!parsed.success) throw new require_registry.ActivitySchemaError("item-group", parsed.error.issues.map((issue) => ({
1085
+ path: issue.path.map(String),
1086
+ message: issue.message,
1087
+ code: issue.code
1088
+ })));
1089
+ parsed.data.items.forEach((item, index) => {
1090
+ try {
1091
+ assertRedacted(item);
1092
+ } catch (error) {
1093
+ if (error instanceof require_registry.ActivitySchemaError) throw new require_registry.ActivitySchemaError("item-group", error.errors.map((issue) => ({
1094
+ ...issue,
1095
+ path: [
1096
+ "items",
1097
+ String(index),
1098
+ ...issue.path
1099
+ ]
1100
+ })));
1101
+ throw error;
1102
+ }
1103
+ });
1062
1104
  }
1063
-
1064
- // src/timeline.ts
1105
+ //#endregion
1106
+ //#region src/timeline.ts
1107
+ /**
1108
+ * Reads a stored {@link MediaProgress}. Never throws: anything that is not one
1109
+ * — a string, a stale shape, an object whose getters throw — answers `null`,
1110
+ * and the player starts from the beginning.
1111
+ *
1112
+ * What it keeps, it clamps. A stored position is host storage, which is input
1113
+ * from a stranger by the time it is read back: a negative `at`, a `furthest`
1114
+ * behind `at`, or a position past the end of a video that has since been
1115
+ * re-cut must not put the playhead somewhere it cannot be. Give `duration`
1116
+ * once the video's metadata is known, and both positions are held inside it.
1117
+ */
1065
1118
  function readMediaProgress(value, duration) {
1066
- let version;
1067
- let at;
1068
- let furthest;
1069
- try {
1070
- if (typeof value !== "object" || value === null) {
1071
- return null;
1072
- }
1073
- ({ progressVersion: version, at, furthest } = value);
1074
- } catch (e) {
1075
- return null;
1076
- }
1077
- if (version !== "1.0" || typeof at !== "number" || !Number.isFinite(at)) {
1078
- return null;
1079
- }
1080
- const end = typeof duration === "number" && Number.isFinite(duration) && duration >= 0 ? duration : Number.POSITIVE_INFINITY;
1081
- const clampedAt = Math.min(Math.max(0, at), end);
1082
- const reached = typeof furthest === "number" && Number.isFinite(furthest) ? furthest : clampedAt;
1083
- return {
1084
- progressVersion: "1.0",
1085
- at: clampedAt,
1086
- furthest: Math.min(Math.max(clampedAt, reached), end)
1087
- };
1119
+ let version;
1120
+ let at;
1121
+ let furthest;
1122
+ try {
1123
+ if (typeof value !== "object" || value === null) return null;
1124
+ ({progressVersion: version, at, furthest} = value);
1125
+ } catch {
1126
+ return null;
1127
+ }
1128
+ if (version !== "1.0" || typeof at !== "number" || !Number.isFinite(at)) return null;
1129
+ const end = typeof duration === "number" && Number.isFinite(duration) && duration >= 0 ? duration : Number.POSITIVE_INFINITY;
1130
+ const clampedAt = Math.min(Math.max(0, at), end);
1131
+ return {
1132
+ progressVersion: "1.0",
1133
+ at: clampedAt,
1134
+ furthest: Math.min(Math.max(clampedAt, typeof furthest === "number" && Number.isFinite(furthest) ? furthest : clampedAt), end)
1135
+ };
1088
1136
  }
1137
+ /**
1138
+ * One interactive video's composed result: the scored items whose slot belongs
1139
+ * to the entry, composed as one section of weight 1. A slot belongs when
1140
+ * `entryKeyOf(item.slotId) === entryKey`, so a host can pass a whole attempt's
1141
+ * items and get the video's share of it.
1142
+ *
1143
+ * `entryKey` is the group's `slotKey` when it has one — which every published
1144
+ * video should — or its position in the sequence otherwise. A group object
1145
+ * alone cannot know its position, which is why this takes the key.
1146
+ *
1147
+ * @throws Error when no item belongs to the entry. A mistyped key would
1148
+ * otherwise compose nothing into a `final` result, and a refusal must
1149
+ * never read as a grade.
1150
+ */
1089
1151
  function composeTimelineScore(entryKey, items, policy) {
1090
- const own = items.filter((item) => entryKeyOf(item.slotId) === entryKey);
1091
- if (own.length === 0) {
1092
- throw new Error(
1093
- `composeTimelineScore: no scored item belongs to entry "${entryKey}". Pass the video's slotKey (or its position in the sequence), and the items of the attempt that holds it.`
1094
- );
1095
- }
1096
- return _chunk4YANYTFNcjs.composeAssessmentScore.call(void 0, [{ id: entryKey, weight: 1, items: own }], policy);
1152
+ const own = items.filter((item) => entryKeyOf(item.slotId) === entryKey);
1153
+ if (own.length === 0) throw new Error(`composeTimelineScore: no scored item belongs to entry "${entryKey}". Pass the video's slotKey (or its position in the sequence), and the items of the attempt that holds it.`);
1154
+ return require_scoring.composeAssessmentScore([{
1155
+ id: entryKey,
1156
+ weight: 1,
1157
+ items: own
1158
+ }], policy);
1097
1159
  }
1160
+ //#endregion
1161
+ exports.AI_COACHING_MAX_TIP_LENGTH = require_ai_writing.AI_COACHING_MAX_TIP_LENGTH;
1162
+ exports.AI_COACHING_MAX_WORDS = require_ai_writing.AI_COACHING_MAX_WORDS;
1163
+ exports.AI_CRITIQUE_MAX_FIELD_LENGTH = require_ai_writing.AI_CRITIQUE_MAX_FIELD_LENGTH;
1164
+ exports.AI_CRITIQUE_MAX_FINDINGS = require_ai_writing.AI_CRITIQUE_MAX_FINDINGS;
1165
+ exports.AI_DRAFTS_MAX_COUNT = require_ai_writing.AI_DRAFTS_MAX_COUNT;
1166
+ exports.AI_DRAFTS_MAX_REPAIRS = require_ai_writing.AI_DRAFTS_MAX_REPAIRS;
1167
+ exports.AI_EXPLANATION_TYPES = require_ai_writing.AI_EXPLANATION_TYPES;
1168
+ exports.AI_HINT_TYPES = require_ai_writing.AI_HINT_TYPES;
1169
+ exports.AI_TEXT_MAX_LENGTH = require_ai_writing.AI_TEXT_MAX_LENGTH;
1170
+ exports.AI_WRITING_MAX_CORRECTIONS = require_ai_writing.AI_WRITING_MAX_CORRECTIONS;
1171
+ exports.AI_WRITING_MAX_FIELD_LENGTH = require_ai_writing.AI_WRITING_MAX_FIELD_LENGTH;
1172
+ exports.ActivitySchemaError = require_registry.ActivitySchemaError;
1173
+ exports.BUILT_IN_ACTIVITY_TYPES = require_registry.BUILT_IN_ACTIVITY_TYPES;
1174
+ exports.DEFAULT_ITEM_SCORING_POLICY = require_scoring.DEFAULT_ITEM_SCORING_POLICY;
1175
+ exports.DEFAULT_PASS_THRESHOLD = require_scoring.DEFAULT_PASS_THRESHOLD;
1176
+ exports.DICTATION_MAX_ACCEPTED_TRANSCRIPTS = require_registry.DICTATION_MAX_ACCEPTED_TRANSCRIPTS;
1177
+ exports.DICTATION_MAX_EQUIVALENCES = require_registry.DICTATION_MAX_EQUIVALENCES;
1178
+ exports.DICTATION_MAX_EQUIVALENCE_LENGTH = require_registry.DICTATION_MAX_EQUIVALENCE_LENGTH;
1179
+ exports.DICTATION_MAX_TEXT_LENGTH = require_registry.DICTATION_MAX_TEXT_LENGTH;
1180
+ exports.DICTATION_MAX_TRANSCRIPT_LENGTH = require_registry.DICTATION_MAX_TRANSCRIPT_LENGTH;
1181
+ exports.DeferredScoringError = require_registry.DeferredScoringError;
1182
+ exports.INTERACTIVE_VIDEO_ITEM_TYPES = require_item_group.INTERACTIVE_VIDEO_ITEM_TYPES;
1183
+ exports.ITEM_SCORING_MAX_RETRIES = require_scoring.ITEM_SCORING_MAX_RETRIES;
1184
+ exports.OPEN_DELIVERY_POLICY = OPEN_DELIVERY_POLICY;
1185
+ exports.READ_ALOUD_MAX_DIMENSION_WEIGHT = require_registry.READ_ALOUD_MAX_DIMENSION_WEIGHT;
1186
+ exports.READ_ALOUD_MAX_REFERENCE_LENGTH = require_registry.READ_ALOUD_MAX_REFERENCE_LENGTH;
1187
+ exports.READ_ALOUD_MAX_SECONDS = require_registry.READ_ALOUD_MAX_SECONDS;
1188
+ exports.READ_ALOUD_MAX_TAKES = require_registry.READ_ALOUD_MAX_TAKES;
1189
+ exports.RedactedScoringError = require_registry.RedactedScoringError;
1190
+ exports.SPEECH_ASSESSMENT_MAX_WORDS = require_registry.SPEECH_ASSESSMENT_MAX_WORDS;
1191
+ exports.TIMELINE_MAX_CHAPTERS = require_item_group.TIMELINE_MAX_CHAPTERS;
1192
+ exports.TIMELINE_MAX_ITEMS = require_item_group.TIMELINE_MAX_ITEMS;
1193
+ exports.TIMELINE_MAX_QUIZZES = require_item_group.TIMELINE_MAX_QUIZZES;
1194
+ exports.TIMELINE_MAX_TITLE_LENGTH = require_item_group.TIMELINE_MAX_TITLE_LENGTH;
1195
+ exports.UnknownActivityTypeError = require_registry.UnknownActivityTypeError;
1196
+ exports.XAPIVerb = require_xapi.XAPIVerb;
1197
+ exports.XAPI_VERB_DISPLAY = require_xapi.XAPI_VERB_DISPLAY;
1198
+ exports.aiAllowedByContent = require_ai_writing.aiAllowedByContent;
1199
+ exports.aiCoachingRequest = require_ai_writing.aiCoachingRequest;
1200
+ exports.aiCritiqueRequest = require_ai_writing.aiCritiqueRequest;
1201
+ exports.aiDraftsRepairRequest = require_ai_writing.aiDraftsRepairRequest;
1202
+ exports.aiDraftsRequest = require_ai_writing.aiDraftsRequest;
1203
+ exports.aiExplanationRequest = require_ai_writing.aiExplanationRequest;
1204
+ exports.aiGradeOf = require_ai_writing.aiGradeOf;
1205
+ exports.aiHintRequest = require_ai_writing.aiHintRequest;
1206
+ exports.aiSupports = require_ai_writing.aiSupports;
1207
+ exports.aiWritingFeedbackRequest = require_ai_writing.aiWritingFeedbackRequest;
1208
+ exports.alignDictation = require_registry.alignDictation;
1209
+ exports.alignReadAloud = require_scoring.alignReadAloud;
1210
+ exports.assertRedacted = assertRedacted;
1211
+ exports.assertRedactedItemGroup = assertRedactedItemGroup;
1212
+ exports.buildAiFacts = require_ai_writing.buildAiFacts;
1213
+ exports.canonicalJson = canonicalJson;
1214
+ exports.checkAiCoaching = require_ai_writing.checkAiCoaching;
1215
+ exports.checkAiCritique = require_ai_writing.checkAiCritique;
1216
+ exports.checkAiDrafts = require_ai_writing.checkAiDrafts;
1217
+ exports.checkAiExplanation = require_ai_writing.checkAiExplanation;
1218
+ exports.checkAiHint = require_ai_writing.checkAiHint;
1219
+ exports.checkAiWritingFeedback = require_ai_writing.checkAiWritingFeedback;
1220
+ exports.classifyBand = require_scoring.classifyBand;
1221
+ exports.combineDeliveryPolicies = combineDeliveryPolicies;
1222
+ exports.composeAssessmentScore = require_scoring.composeAssessmentScore;
1223
+ exports.composeTimelineScore = composeTimelineScore;
1224
+ exports.computePassThreshold = require_scoring.computePassThreshold;
1225
+ exports.contentHash = contentHash;
1226
+ exports.countWords = require_registry.countWords;
1227
+ exports.createDraft = require_ai_writing.createDraft;
1228
+ exports.createInteractiveVideoDraft = require_ai_writing.createInteractiveVideoDraft;
1229
+ exports.createItemGroupDraft = require_ai_writing.createItemGroupDraft;
1230
+ exports.critiqueDraft = require_ai_writing.critiqueDraft;
1231
+ exports.critiqueDrafts = require_ai_writing.critiqueDrafts;
1232
+ exports.critiqueItemGroupDraft = require_ai_writing.critiqueItemGroupDraft;
1233
+ exports.defineActivityType = require_registry.defineActivityType;
1234
+ exports.dictationJsonSchema = require_schemas.dictationJsonSchema;
1235
+ exports.dictationReferenceWords = require_registry.dictationReferenceWords;
1236
+ exports.dictationType = require_registry.dictationType;
1237
+ exports.diffDictationChars = require_scoring.diffDictationChars;
1238
+ exports.diffResponses = diffResponses;
1239
+ exports.entryKeyOf = entryKeyOf;
1240
+ exports.evaluate = require_scoring.evaluate;
1241
+ exports.evaluateTries = require_scoring.evaluateTries;
1242
+ exports.fillInTheBlanksJsonSchema = require_schemas.fillInTheBlanksJsonSchema;
1243
+ exports.fillInTheBlanksType = require_registry.fillInTheBlanksType;
1244
+ exports.fingerprint = fingerprint;
1245
+ exports.flattenSequence = flattenSequence;
1246
+ exports.gapSelectJsonSchema = require_schemas.gapSelectJsonSchema;
1247
+ exports.gapSelectType = require_registry.gapSelectType;
1248
+ exports.generateDrafts = require_ai_writing.generateDrafts;
1249
+ exports.getActivityTypeDescriptor = require_registry.getActivityTypeDescriptor;
1250
+ exports.gradeFromRubric = require_scoring.gradeFromRubric;
1251
+ exports.gradeReadAloud = require_scoring.gradeReadAloud;
1252
+ exports.gte = require_scoring.gte;
1253
+ exports.hasGrade = require_scoring.hasGrade;
1254
+ exports.hashSeed = hashSeed;
1255
+ exports.hintRevealsAnswer = require_ai_writing.hintRevealsAnswer;
1256
+ exports.inspectWav = require_scoring.inspectWav;
1257
+ exports.interactiveVideoFromDrafts = require_ai_writing.interactiveVideoFromDrafts;
1258
+ exports.isBuiltInActivityType = require_registry.isBuiltInActivityType;
1259
+ exports.isInteractiveVideoItemType = require_item_group.isInteractiveVideoItemType;
1260
+ exports.isItemGroup = isItemGroup;
1261
+ exports.itemGroupJsonSchema = require_schemas.itemGroupJsonSchema;
1262
+ exports.jsonSchemaFor = require_schemas.jsonSchemaFor;
1263
+ exports.levenshteinDistance = require_registry.levenshteinDistance;
1264
+ exports.matchText = require_registry.matchText;
1265
+ exports.multipleChoiceJsonSchema = require_schemas.multipleChoiceJsonSchema;
1266
+ exports.multipleChoiceType = require_registry.multipleChoiceType;
1267
+ exports.outcomeFromGrade = require_scoring.outcomeFromGrade;
1268
+ exports.outcomeFromUnscorable = require_scoring.outcomeFromUnscorable;
1269
+ exports.planAttempt = planAttempt;
1270
+ exports.planMediaBudgets = planMediaBudgets;
1271
+ exports.readAiTextResult = require_ai_writing.readAiTextResult;
1272
+ exports.readAloudJsonSchema = require_schemas.readAloudJsonSchema;
1273
+ exports.readAloudType = require_registry.readAloudType;
1274
+ exports.readMediaProgress = readMediaProgress;
1275
+ exports.redact = redact;
1276
+ exports.redactItemGroup = redactItemGroup;
1277
+ exports.registerActivityType = require_registry.registerActivityType;
1278
+ exports.registeredActivityTypes = require_registry.registeredActivityTypes;
1279
+ exports.resolveDeliveryPolicy = resolveDeliveryPolicy;
1280
+ exports.resolveItemScoringPolicy = require_scoring.resolveItemScoringPolicy;
1281
+ exports.resolvePlaybackPolicy = resolvePlaybackPolicy;
1282
+ exports.restoreAttemptState = restoreAttemptState;
1283
+ exports.restoreMediaPlayLedger = restoreMediaPlayLedger;
1284
+ exports.roundGrade = require_scoring.roundGrade;
1285
+ exports.score = require_scoring.score;
1286
+ exports.scoreTries = require_scoring.scoreTries;
1287
+ exports.scoredItemsFromPlan = scoredItemsFromPlan;
1288
+ exports.seededShuffle = seededShuffle;
1289
+ exports.serializeAttemptState = serializeAttemptState;
1290
+ exports.serializeMediaPlayLedger = serializeMediaPlayLedger;
1291
+ exports.slotMediaKey = slotMediaKey;
1292
+ exports.stimulusJsonSchema = require_schemas.stimulusJsonSchema;
1293
+ exports.stimulusMediaKey = stimulusMediaKey;
1294
+ exports.validateActivity = require_schemas.validateActivity;
1295
+ exports.validateDeliveryPolicy = validateDeliveryPolicy;
1296
+ exports.validateDraft = require_ai_writing.validateDraft;
1297
+ exports.validateItemGroup = require_item_group.validateItemGroup;
1298
+ exports.validateItemGroupDraft = require_ai_writing.validateItemGroupDraft;
1299
+ exports.validateItemScoringPolicy = require_scoring.validateItemScoringPolicy;
1300
+ exports.validateMedia = require_schemas.validateMedia;
1301
+ exports.validateOptionMedia = require_schemas.validateOptionMedia;
1302
+ exports.validateSpeechAssessment = require_scoring.validateSpeechAssessment;
1303
+ exports.validateXAPIStatement = require_xapi.validateXAPIStatement;
1304
+ exports.verifyAttemptPlan = verifyAttemptPlan;
1305
+ exports.writtenResponseJsonSchema = require_schemas.writtenResponseJsonSchema;
1306
+ exports.writtenResponseType = require_registry.writtenResponseType;
1307
+ exports.xAPIBuilder = require_xapi.xAPIBuilder;
1308
+ exports.xapiDefinitionFor = require_xapi.xapiDefinitionFor;
1098
1309
 
1099
-
1100
-
1101
-
1102
-
1103
-
1104
-
1105
-
1106
-
1107
-
1108
-
1109
-
1110
-
1111
-
1112
-
1113
-
1114
-
1115
-
1116
-
1117
-
1118
-
1119
-
1120
-
1121
-
1122
-
1123
-
1124
-
1125
-
1126
-
1127
-
1128
-
1129
-
1130
-
1131
-
1132
-
1133
-
1134
-
1135
-
1136
-
1137
-
1138
-
1139
-
1140
-
1141
-
1142
-
1143
-
1144
-
1145
-
1146
-
1147
-
1148
-
1149
-
1150
-
1151
-
1152
-
1153
-
1154
-
1155
-
1156
-
1157
-
1158
-
1159
-
1160
-
1161
-
1162
-
1163
-
1164
-
1165
-
1166
-
1167
-
1168
-
1169
-
1170
-
1171
-
1172
-
1173
-
1174
-
1175
-
1176
-
1177
-
1178
-
1179
-
1180
-
1181
-
1182
-
1183
-
1184
-
1185
-
1186
-
1187
-
1188
-
1189
-
1190
-
1191
-
1192
-
1193
-
1194
-
1195
-
1196
-
1197
-
1198
-
1199
-
1200
-
1201
-
1202
-
1203
-
1204
-
1205
-
1206
-
1207
-
1208
-
1209
-
1210
-
1211
-
1212
-
1213
-
1214
-
1215
-
1216
-
1217
-
1218
-
1219
-
1220
-
1221
-
1222
-
1223
-
1224
-
1225
-
1226
-
1227
-
1228
-
1229
-
1230
-
1231
-
1232
-
1233
-
1234
-
1235
-
1236
-
1237
-
1238
-
1239
-
1240
-
1241
-
1242
-
1243
-
1244
-
1245
- exports.AI_COACHING_MAX_TIP_LENGTH = _chunkMXI26H62cjs.AI_COACHING_MAX_TIP_LENGTH; exports.AI_COACHING_MAX_WORDS = _chunkMXI26H62cjs.AI_COACHING_MAX_WORDS; exports.AI_CRITIQUE_MAX_FIELD_LENGTH = _chunkMXI26H62cjs.AI_CRITIQUE_MAX_FIELD_LENGTH; exports.AI_CRITIQUE_MAX_FINDINGS = _chunkMXI26H62cjs.AI_CRITIQUE_MAX_FINDINGS; exports.AI_DRAFTS_MAX_COUNT = _chunkMXI26H62cjs.AI_DRAFTS_MAX_COUNT; exports.AI_DRAFTS_MAX_REPAIRS = _chunkMXI26H62cjs.AI_DRAFTS_MAX_REPAIRS; exports.AI_EXPLANATION_TYPES = _chunkMXI26H62cjs.AI_EXPLANATION_TYPES; exports.AI_HINT_TYPES = _chunkMXI26H62cjs.AI_HINT_TYPES; exports.AI_TEXT_MAX_LENGTH = _chunkMXI26H62cjs.AI_TEXT_MAX_LENGTH; exports.AI_WRITING_MAX_CORRECTIONS = _chunkMXI26H62cjs.AI_WRITING_MAX_CORRECTIONS; exports.AI_WRITING_MAX_FIELD_LENGTH = _chunkMXI26H62cjs.AI_WRITING_MAX_FIELD_LENGTH; exports.ActivitySchemaError = _chunk7THXD7ANcjs.ActivitySchemaError; exports.DEFAULT_ITEM_SCORING_POLICY = _chunk4YANYTFNcjs.DEFAULT_ITEM_SCORING_POLICY; exports.DEFAULT_PASS_THRESHOLD = _chunk4YANYTFNcjs.DEFAULT_PASS_THRESHOLD; exports.DICTATION_MAX_ACCEPTED_TRANSCRIPTS = _chunk7THXD7ANcjs.DICTATION_MAX_ACCEPTED_TRANSCRIPTS; exports.DICTATION_MAX_EQUIVALENCES = _chunk7THXD7ANcjs.DICTATION_MAX_EQUIVALENCES; exports.DICTATION_MAX_EQUIVALENCE_LENGTH = _chunk7THXD7ANcjs.DICTATION_MAX_EQUIVALENCE_LENGTH; exports.DICTATION_MAX_TEXT_LENGTH = _chunk7THXD7ANcjs.DICTATION_MAX_TEXT_LENGTH; exports.DICTATION_MAX_TRANSCRIPT_LENGTH = _chunk7THXD7ANcjs.DICTATION_MAX_TRANSCRIPT_LENGTH; exports.DeferredScoringError = _chunk7THXD7ANcjs.DeferredScoringError; exports.INTERACTIVE_VIDEO_ITEM_TYPES = _chunkZWXQSKCXcjs.INTERACTIVE_VIDEO_ITEM_TYPES; exports.ITEM_SCORING_MAX_RETRIES = _chunk4YANYTFNcjs.ITEM_SCORING_MAX_RETRIES; exports.OPEN_DELIVERY_POLICY = OPEN_DELIVERY_POLICY; exports.READ_ALOUD_MAX_DIMENSION_WEIGHT = _chunk7THXD7ANcjs.READ_ALOUD_MAX_DIMENSION_WEIGHT; exports.READ_ALOUD_MAX_REFERENCE_LENGTH = _chunk7THXD7ANcjs.READ_ALOUD_MAX_REFERENCE_LENGTH; exports.READ_ALOUD_MAX_SECONDS = _chunk7THXD7ANcjs.READ_ALOUD_MAX_SECONDS; exports.READ_ALOUD_MAX_TAKES = _chunk7THXD7ANcjs.READ_ALOUD_MAX_TAKES; exports.RedactedScoringError = _chunk7THXD7ANcjs.RedactedScoringError; exports.SPEECH_ASSESSMENT_MAX_WORDS = _chunk7THXD7ANcjs.SPEECH_ASSESSMENT_MAX_WORDS; exports.TIMELINE_MAX_CHAPTERS = _chunkZWXQSKCXcjs.TIMELINE_MAX_CHAPTERS; exports.TIMELINE_MAX_ITEMS = _chunkZWXQSKCXcjs.TIMELINE_MAX_ITEMS; exports.TIMELINE_MAX_QUIZZES = _chunkZWXQSKCXcjs.TIMELINE_MAX_QUIZZES; exports.TIMELINE_MAX_TITLE_LENGTH = _chunkZWXQSKCXcjs.TIMELINE_MAX_TITLE_LENGTH; exports.UnknownActivityTypeError = _chunk7THXD7ANcjs.UnknownActivityTypeError; exports.XAPIVerb = _chunk46BXIGLGcjs.XAPIVerb; exports.XAPI_VERB_DISPLAY = _chunk46BXIGLGcjs.XAPI_VERB_DISPLAY; exports.aiAllowedByContent = _chunkMXI26H62cjs.aiAllowedByContent; exports.aiCoachingRequest = _chunkMXI26H62cjs.aiCoachingRequest; exports.aiCritiqueRequest = _chunkMXI26H62cjs.aiCritiqueRequest; exports.aiDraftsRepairRequest = _chunkMXI26H62cjs.aiDraftsRepairRequest; exports.aiDraftsRequest = _chunkMXI26H62cjs.aiDraftsRequest; exports.aiExplanationRequest = _chunkMXI26H62cjs.aiExplanationRequest; exports.aiGradeOf = _chunkMXI26H62cjs.aiGradeOf; exports.aiHintRequest = _chunkMXI26H62cjs.aiHintRequest; exports.aiSupports = _chunkMXI26H62cjs.aiSupports; exports.aiWritingFeedbackRequest = _chunkMXI26H62cjs.aiWritingFeedbackRequest; exports.alignDictation = _chunk7THXD7ANcjs.alignDictation; exports.alignReadAloud = _chunk4YANYTFNcjs.alignReadAloud; exports.assertRedacted = assertRedacted; exports.assertRedactedItemGroup = assertRedactedItemGroup; exports.buildAiFacts = _chunkMXI26H62cjs.buildAiFacts; exports.canonicalJson = canonicalJson; exports.checkAiCoaching = _chunkMXI26H62cjs.checkAiCoaching; exports.checkAiCritique = _chunkMXI26H62cjs.checkAiCritique; exports.checkAiDrafts = _chunkMXI26H62cjs.checkAiDrafts; exports.checkAiExplanation = _chunkMXI26H62cjs.checkAiExplanation; exports.checkAiHint = _chunkMXI26H62cjs.checkAiHint; exports.checkAiWritingFeedback = _chunkMXI26H62cjs.checkAiWritingFeedback; exports.classifyBand = _chunk4YANYTFNcjs.classifyBand; exports.combineDeliveryPolicies = combineDeliveryPolicies; exports.composeAssessmentScore = _chunk4YANYTFNcjs.composeAssessmentScore; exports.composeTimelineScore = composeTimelineScore; exports.computePassThreshold = _chunk4YANYTFNcjs.computePassThreshold; exports.contentHash = contentHash; exports.countWords = _chunk7THXD7ANcjs.countWords; exports.createDraft = _chunkMXI26H62cjs.createDraft; exports.createInteractiveVideoDraft = _chunkMXI26H62cjs.createInteractiveVideoDraft; exports.createItemGroupDraft = _chunkMXI26H62cjs.createItemGroupDraft; exports.critiqueDraft = _chunkMXI26H62cjs.critiqueDraft; exports.critiqueDrafts = _chunkMXI26H62cjs.critiqueDrafts; exports.critiqueItemGroupDraft = _chunkMXI26H62cjs.critiqueItemGroupDraft; exports.defineActivityType = _chunk7THXD7ANcjs.defineActivityType; exports.dictationJsonSchema = _chunkCYCTIXUNcjs.dictationJsonSchema; exports.dictationReferenceWords = _chunk7THXD7ANcjs.dictationReferenceWords; exports.dictationType = _chunk7THXD7ANcjs.dictationType; exports.diffDictationChars = _chunk4YANYTFNcjs.diffDictationChars; exports.diffResponses = diffResponses; exports.entryKeyOf = entryKeyOf; exports.evaluate = _chunk4YANYTFNcjs.evaluate; exports.evaluateTries = _chunk4YANYTFNcjs.evaluateTries; exports.fillInTheBlanksJsonSchema = _chunkCYCTIXUNcjs.fillInTheBlanksJsonSchema; exports.fillInTheBlanksType = _chunk7THXD7ANcjs.fillInTheBlanksType; exports.fingerprint = fingerprint; exports.flattenSequence = flattenSequence; exports.gapSelectJsonSchema = _chunkCYCTIXUNcjs.gapSelectJsonSchema; exports.gapSelectType = _chunk7THXD7ANcjs.gapSelectType; exports.generateDrafts = _chunkMXI26H62cjs.generateDrafts; exports.getActivityTypeDescriptor = _chunk7THXD7ANcjs.getActivityTypeDescriptor; exports.gradeFromRubric = _chunk4YANYTFNcjs.gradeFromRubric; exports.gradeReadAloud = _chunk4YANYTFNcjs.gradeReadAloud; exports.gte = _chunk4YANYTFNcjs.gte; exports.hasGrade = _chunk4YANYTFNcjs.hasGrade; exports.hashSeed = hashSeed; exports.hintRevealsAnswer = _chunkMXI26H62cjs.hintRevealsAnswer; exports.inspectWav = _chunk4YANYTFNcjs.inspectWav; exports.interactiveVideoFromDrafts = _chunkMXI26H62cjs.interactiveVideoFromDrafts; exports.isInteractiveVideoItemType = _chunkZWXQSKCXcjs.isInteractiveVideoItemType; exports.isItemGroup = isItemGroup; exports.itemGroupJsonSchema = _chunkCYCTIXUNcjs.itemGroupJsonSchema; exports.jsonSchemaFor = _chunkCYCTIXUNcjs.jsonSchemaFor; exports.levenshteinDistance = _chunk7THXD7ANcjs.levenshteinDistance; exports.matchText = _chunk7THXD7ANcjs.matchText; exports.multipleChoiceJsonSchema = _chunkCYCTIXUNcjs.multipleChoiceJsonSchema; exports.multipleChoiceType = _chunk7THXD7ANcjs.multipleChoiceType; exports.outcomeFromGrade = _chunk4YANYTFNcjs.outcomeFromGrade; exports.outcomeFromUnscorable = _chunk4YANYTFNcjs.outcomeFromUnscorable; exports.planAttempt = planAttempt; exports.planMediaBudgets = planMediaBudgets; exports.readAiTextResult = _chunkMXI26H62cjs.readAiTextResult; exports.readAloudJsonSchema = _chunkCYCTIXUNcjs.readAloudJsonSchema; exports.readAloudType = _chunk7THXD7ANcjs.readAloudType; exports.readMediaProgress = readMediaProgress; exports.redact = redact; exports.redactItemGroup = redactItemGroup; exports.registerActivityType = _chunk7THXD7ANcjs.registerActivityType; exports.registeredActivityTypes = _chunk7THXD7ANcjs.registeredActivityTypes; exports.resolveDeliveryPolicy = resolveDeliveryPolicy; exports.resolveItemScoringPolicy = _chunk4YANYTFNcjs.resolveItemScoringPolicy; exports.resolvePlaybackPolicy = resolvePlaybackPolicy; exports.restoreAttemptState = restoreAttemptState; exports.restoreMediaPlayLedger = restoreMediaPlayLedger; exports.roundGrade = _chunk4YANYTFNcjs.roundGrade; exports.score = _chunk4YANYTFNcjs.score; exports.scoreTries = _chunk4YANYTFNcjs.scoreTries; exports.scoredItemsFromPlan = scoredItemsFromPlan; exports.seededShuffle = seededShuffle; exports.serializeAttemptState = serializeAttemptState; exports.serializeMediaPlayLedger = serializeMediaPlayLedger; exports.slotMediaKey = slotMediaKey; exports.stimulusJsonSchema = _chunkCYCTIXUNcjs.stimulusJsonSchema; exports.stimulusMediaKey = stimulusMediaKey; exports.validateActivity = _chunkCYCTIXUNcjs.validateActivity; exports.validateDeliveryPolicy = validateDeliveryPolicy; exports.validateDraft = _chunkMXI26H62cjs.validateDraft; exports.validateItemGroup = _chunkZWXQSKCXcjs.validateItemGroup; exports.validateItemGroupDraft = _chunkMXI26H62cjs.validateItemGroupDraft; exports.validateItemScoringPolicy = _chunk4YANYTFNcjs.validateItemScoringPolicy; exports.validateMedia = _chunkCYCTIXUNcjs.validateMedia; exports.validateOptionMedia = _chunkCYCTIXUNcjs.validateOptionMedia; exports.validateSpeechAssessment = _chunk4YANYTFNcjs.validateSpeechAssessment; exports.validateXAPIStatement = _chunk46BXIGLGcjs.validateXAPIStatement; exports.verifyAttemptPlan = verifyAttemptPlan; exports.writtenResponseJsonSchema = _chunkCYCTIXUNcjs.writtenResponseJsonSchema; exports.writtenResponseType = _chunk7THXD7ANcjs.writtenResponseType; exports.xAPIBuilder = _chunk46BXIGLGcjs.xAPIBuilder; exports.xapiDefinitionFor = _chunk46BXIGLGcjs.xapiDefinitionFor;
1246
1310
  //# sourceMappingURL=index.cjs.map