@intellectif/lk-core 1.2.0 → 1.4.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 (105) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -2
  3. package/dist/ai-CnmLPMzd.d.cts +2417 -0
  4. package/dist/ai-CnmLPMzd.d.cts.map +1 -0
  5. package/dist/ai-CnmLPMzd.d.ts +2417 -0
  6. package/dist/ai-CnmLPMzd.d.ts.map +1 -0
  7. package/dist/ai-check.cjs +845 -620
  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 +843 -621
  14. package/dist/ai-check.js.map +1 -1
  15. package/dist/ai-writing-CPpH9Qxq.js +2601 -0
  16. package/dist/ai-writing-CPpH9Qxq.js.map +1 -0
  17. package/dist/ai-writing-G6tnCGzw.cjs +2846 -0
  18. package/dist/ai-writing-G6tnCGzw.cjs.map +1 -0
  19. package/dist/index-2nigSf4N.d.cts +339 -0
  20. package/dist/index-2nigSf4N.d.cts.map +1 -0
  21. package/dist/index-B54baFUA.d.ts +339 -0
  22. package/dist/index-B54baFUA.d.ts.map +1 -0
  23. package/dist/index-BAfEHCnD.d.cts +1071 -0
  24. package/dist/index-BAfEHCnD.d.cts.map +1 -0
  25. package/dist/index-BqMXWuNC.d.cts +153 -0
  26. package/dist/index-BqMXWuNC.d.cts.map +1 -0
  27. package/dist/index-CVWvymVb.d.ts +1071 -0
  28. package/dist/index-CVWvymVb.d.ts.map +1 -0
  29. package/dist/index-CcHDTxIZ.d.ts +153 -0
  30. package/dist/index-CcHDTxIZ.d.ts.map +1 -0
  31. package/dist/index.cjs +1211 -1146
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +941 -861
  34. package/dist/index.d.cts.map +1 -0
  35. package/dist/index.d.ts +941 -861
  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/index-BChYtrEe.d.ts +0 -548
  99. package/dist/index-VXt_v3YI.d.cts +0 -548
  100. package/dist/schemas.cjs.map +0 -1
  101. package/dist/schemas.js.map +0 -1
  102. package/dist/scoring.cjs.map +0 -1
  103. package/dist/scoring.js.map +0 -1
  104. package/dist/xapi.cjs.map +0 -1
  105. package/dist/xapi.js.map +0 -1
@@ -0,0 +1,1748 @@
1
+ const require_registry = require("./registry-DVi0LR_i.cjs");
2
+ let zod_v4 = require("zod/v4");
3
+ zod_v4 = require_registry.__toESM(zod_v4, 1);
4
+ //#region src/scoring/grade-numbers.ts
5
+ /**
6
+ * How far past its own maximum a score may sit and still be a grade. A grader
7
+ * that sums weighted parts lands on `1.0000000000000002` for a perfect answer
8
+ * as readily as on `1`; that is float noise, not a grader out of contract.
9
+ * Relative, so it means the same out of 100 as out of 1, and the same bound
10
+ * lk-react applies before it shows a grade — the two packages agree on what a
11
+ * grade is.
12
+ */
13
+ const OVERSHOOT = 1e-9;
14
+ /**
15
+ * Whether `score` out of `maxScore` can be a grade: `maxScore` a positive,
16
+ * finite number, and `score` a finite number from 0 to it. Anything else — NaN,
17
+ * Infinity, a negative, 85 "out of 1", any score out of 0, a numeric string —
18
+ * is a grader out of contract, and dividing by it puts a number on the record
19
+ * that nobody can defend.
20
+ *
21
+ * A score up to `maxScore * OVERSHOOT` above its maximum passes as it is. It
22
+ * is not clamped: an accepted grade composes exactly as it always has.
23
+ */
24
+ function isGradeInRange(score, maxScore) {
25
+ return Number.isFinite(maxScore) && maxScore > 0 && Number.isFinite(score) && score >= 0 && score - maxScore <= maxScore * OVERSHOOT;
26
+ }
27
+ //#endregion
28
+ //#region src/is-redacted.ts
29
+ /**
30
+ * True when `data` is a `redact()` projection rather than full activity data.
31
+ * Grading a redacted item is always a bug: the answer key is gone by design, so
32
+ * any "score" computed from it is meaningless — for a type whose projection
33
+ * drops the key it used to come out `NaN`, and for one whose projection keeps
34
+ * everything the grade reads it comes out as a plausible number with the
35
+ * authored feedback silently missing.
36
+ *
37
+ * Internal, and its own module so that every grading entry point can ask the
38
+ * same question: `score()` and `evaluate()` in `scoring/index.ts`, and
39
+ * `gradeReadAloud` under `scoring/speech/`, which must not import the barrel
40
+ * it is itself exported from.
41
+ */
42
+ function isRedacted(data) {
43
+ return typeof data === "object" && data !== null && data.redacted === true;
44
+ }
45
+ //#endregion
46
+ //#region src/scoring/item-scoring.ts
47
+ /**
48
+ * The policy that changes nothing: one try, nothing costs anything, the first
49
+ * try counts. What an absent policy resolves to, and exactly how questions were
50
+ * scored before policies existed.
51
+ */
52
+ const DEFAULT_ITEM_SCORING_POLICY = Object.freeze({
53
+ hintPenalty: 0,
54
+ retries: 0,
55
+ retryPenalty: 0,
56
+ counts: "first"
57
+ });
58
+ /** The most tries after the first a policy can give. */
59
+ const ITEM_SCORING_MAX_RETRIES = 10;
60
+ const SETTINGS = [
61
+ "hintPenalty",
62
+ "retries",
63
+ "retryPenalty",
64
+ "counts"
65
+ ];
66
+ const COUNTS = [
67
+ "first",
68
+ "best",
69
+ "last"
70
+ ];
71
+ const isUnset = (value) => value === void 0 || value === null;
72
+ /** A cost: a finite number from 0 to 1. */
73
+ const isFraction = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
74
+ /**
75
+ * Checks a policy before it is stored or used: every setting absent, `null`,
76
+ * or a value it can take — costs from 0 to 1, `retries` a whole number from 0
77
+ * to {@link ITEM_SCORING_MAX_RETRIES}, `counts` one of `first`, `best` or
78
+ * `last` — and no setting it does not know.
79
+ *
80
+ * It also refuses a `retryPenalty` above 0 while the first try counts: that
81
+ * cost can never be charged, and a school that set it believes retries cost
82
+ * something. Choose `counts: 'best'` or `'last'`.
83
+ */
84
+ function validateItemScoringPolicy(policy) {
85
+ if (isUnset(policy)) return {
86
+ success: true,
87
+ data: {}
88
+ };
89
+ if (typeof policy !== "object" || Array.isArray(policy)) return {
90
+ success: false,
91
+ issues: [{
92
+ path: "",
93
+ message: "An item scoring policy is an object of settings."
94
+ }]
95
+ };
96
+ const given = policy;
97
+ const issues = [];
98
+ for (const key of Object.keys(given)) if (!SETTINGS.includes(key)) issues.push({
99
+ path: key,
100
+ message: `"${key}" is not an item scoring setting. The settings are hintPenalty, retries, retryPenalty and counts.`
101
+ });
102
+ for (const key of ["hintPenalty", "retryPenalty"]) {
103
+ const value = given[key];
104
+ if (!isUnset(value) && !isFraction(value)) issues.push({
105
+ path: key,
106
+ message: `"${key}" must be a number from 0 to 1: the fraction of a question's marks it costs.`
107
+ });
108
+ }
109
+ const retries = given.retries;
110
+ if (!isUnset(retries) && !(typeof retries === "number" && Number.isInteger(retries) && retries >= 0 && retries <= 10)) issues.push({
111
+ path: "retries",
112
+ message: `"retries" must be a whole number from 0 to 10.`
113
+ });
114
+ const counts = given.counts;
115
+ if (!isUnset(counts) && !COUNTS.includes(counts)) issues.push({
116
+ path: "counts",
117
+ message: "\"counts\" must be \"first\", \"best\" or \"last\"."
118
+ });
119
+ if (isFraction(given.retryPenalty) && given.retryPenalty > 0 && (isUnset(counts) || counts === "first")) issues.push({
120
+ path: "retryPenalty",
121
+ message: "\"retryPenalty\" is never charged while the first try counts. Set \"counts\" to \"best\" or \"last\", or leave \"retryPenalty\" out."
122
+ });
123
+ return issues.length > 0 ? {
124
+ success: false,
125
+ issues
126
+ } : {
127
+ success: true,
128
+ data: given
129
+ };
130
+ }
131
+ /**
132
+ * A policy with every setting spelled out.
133
+ *
134
+ * **It throws rather than guess.** A delivery policy reads a setting it cannot
135
+ * read as a restriction, because that is the safe way round; a setting here
136
+ * moves a grade, and there is no safe way round — a cost misread as 0 raises
137
+ * grades, misread as 1 lowers them. So a policy {@link validateItemScoringPolicy}
138
+ * refuses is refused here too, with a `RangeError` naming what is wrong, as an
139
+ * unreadable `rounding` is.
140
+ */
141
+ function resolveItemScoringPolicy(policy) {
142
+ if (isUnset(policy)) return DEFAULT_ITEM_SCORING_POLICY;
143
+ const checked = validateItemScoringPolicy(policy);
144
+ if (!checked.success) throw new RangeError(`learning-kit: this item scoring policy cannot be applied — ${checked.issues.map((issue) => `${issue.path || "policy"}: ${issue.message}`).join(" ")}`);
145
+ const given = checked.data;
146
+ return {
147
+ hintPenalty: given.hintPenalty ?? 0,
148
+ retries: given.retries ?? 0,
149
+ retryPenalty: given.retryPenalty ?? 0,
150
+ counts: given.counts ?? "first"
151
+ };
152
+ }
153
+ /** A hint count as the policy reads it: anything but a whole number of 0 or more is none. */
154
+ function hintsRevealedOf(value) {
155
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : 0;
156
+ }
157
+ /**
158
+ * Removes the floating-point residue a cost leaves: `0.7 - 0.2` is
159
+ * `0.49999999999999994`, which would fail a 0.5 pass line the learner met.
160
+ * Twelve decimal places is far below anything a grade is shown or rounded to,
161
+ * and far above the residue.
162
+ */
163
+ function snap(value) {
164
+ return Math.round(value * 0xe8d4a51000) / 0xe8d4a51000;
165
+ }
166
+ /**
167
+ * A question's score under a policy, from its tries in the order they were
168
+ * made.
169
+ *
170
+ * - **What a try costs** is a fraction of the question's marks, subtracted
171
+ * from what its answer scored: `hintPenalty` for every hint shown before it
172
+ * (its `hintsRevealed`, which counts from the start of the question), and
173
+ * `retryPenalty` for every try before it. A try never scores below 0.
174
+ * - **Which try counts** is the policy's `counts`: the first, the best after
175
+ * costs (the earliest, where two are equal), or the last.
176
+ * - **Only the tries the policy allows are read**: the first `1 + retries`. A
177
+ * record with more cannot have come from a learner under this policy.
178
+ *
179
+ * Nothing that cost nothing is changed: with no cost to charge, a try's score
180
+ * is exactly what its answer scored.
181
+ *
182
+ * @throws RangeError for a policy {@link resolveItemScoringPolicy} refuses, for
183
+ * an empty list of tries, and for a try whose `score` and `maxScore`
184
+ * cannot be a grade — the rule `outcomeFromGrade` applies: `maxScore`
185
+ * positive and finite, `score` finite from 0 to it.
186
+ */
187
+ function scoreTries(tries, policy) {
188
+ const rules = resolveItemScoringPolicy(policy);
189
+ if (tries.length === 0) throw new RangeError("scoreTries: there is no try to score.");
190
+ const scored = tries.slice(0, 1 + rules.retries).map((one, index) => {
191
+ const { score, maxScore } = one;
192
+ if (typeof score !== "number" || typeof maxScore !== "number" || !isGradeInRange(score, maxScore)) throw new RangeError(`scoreTries: try ${index + 1} has no score to cost — score ${String(score)} of ${String(maxScore)}.`);
193
+ const hintsRevealed = hintsRevealedOf(one.hintsRevealed);
194
+ const penalty = snap(rules.hintPenalty * hintsRevealed + rules.retryPenalty * index);
195
+ return {
196
+ score,
197
+ scored: penalty > 0 ? Math.max(0, snap(score / maxScore - penalty)) * maxScore : score,
198
+ maxScore,
199
+ hintsRevealed,
200
+ penalty
201
+ };
202
+ });
203
+ const ratio = (one) => one.scored / one.maxScore;
204
+ let counted = 0;
205
+ if (rules.counts === "last") counted = scored.length - 1;
206
+ else if (rules.counts === "best") scored.forEach((one, index) => {
207
+ if (ratio(one) > ratio(scored[counted])) counted = index;
208
+ });
209
+ const chosen = scored[counted];
210
+ return {
211
+ score: chosen.scored,
212
+ maxScore: chosen.maxScore,
213
+ counted,
214
+ tries: scored
215
+ };
216
+ }
217
+ //#endregion
218
+ //#region src/scoring/rounding.ts
219
+ const ROUNDING_MODES = [
220
+ "half-up",
221
+ "half-even",
222
+ "floor",
223
+ "ceil"
224
+ ];
225
+ /** The most decimal places a policy may round to: a scaled grade stays an integer a double holds exactly. */
226
+ const MAX_ROUNDING_DP = 15;
227
+ /**
228
+ * The rounding policy `value` holds, or `undefined` for none (`undefined` or
229
+ * `null`). A malformed one would reach {@link gte} as `10 ** undefined` and
230
+ * turn a perfect score into a fail with no error, so it throws here instead.
231
+ *
232
+ * Internal. Every function that takes a `rounding` option checks it here, so
233
+ * each refuses the same policies with the same message; `scoring/index.ts`
234
+ * does not re-export it.
235
+ */
236
+ function roundingPolicyOf(value) {
237
+ if (value === void 0 || value === null) return;
238
+ const { mode, dp } = value;
239
+ if (!ROUNDING_MODES.includes(mode) || typeof dp !== "number" || !Number.isInteger(dp) || dp < 0 || dp > MAX_ROUNDING_DP) {
240
+ const describe = (field) => typeof field === "string" ? JSON.stringify(field) : typeof field === "number" || field === void 0 ? String(field) : typeof field === "object" ? "an object" : `a ${typeof field}`;
241
+ throw new RangeError(`Invalid rounding policy (mode ${describe(mode)}, dp ${describe(dp)}): expected { mode: 'half-up' | 'half-even' | 'floor' | 'ceil', dp: a whole number from 0 to ${MAX_ROUNDING_DP} }.`);
242
+ }
243
+ return {
244
+ mode,
245
+ dp
246
+ };
247
+ }
248
+ /**
249
+ * Float-noise guard. Binary floating point makes `1.005 * 100` come out as
250
+ * `100.49999999999999` and `0.29 * 100` come out as `28.999999999999996`;
251
+ * without an allowance both would move a learner's grade a step. It is applied
252
+ * in the direction each mode needs — see {@link roundGrade}.
253
+ */
254
+ const EPSILON = 1e-9;
255
+ /**
256
+ * Tolerance for deciding "is this exactly halfway?" in half-even mode.
257
+ * Deliberately much larger than {@link EPSILON}: if the tie test shared the
258
+ * nudge's tolerance, the nudge would decide its own outcome. Safe for grades,
259
+ * which live on a [0,1] scale where representation error is far below this.
260
+ */
261
+ const TIE_TOLERANCE = 1e-6;
262
+ function scaled(value, dp) {
263
+ return value * 10 ** dp;
264
+ }
265
+ /**
266
+ * Normalises `-0` to `0` without touching anything else. `-0` JSON-serialises
267
+ * as `0` but fails `Object.is(-0, 0)`, which surprises a strict-equality
268
+ * gradebook comparison. Deliberately NOT `|| 0`: that also swallowed `NaN`
269
+ * (reachable with an absurd `dp`, where `10 ** dp` overflows to Infinity) and
270
+ * turned a visible failure into a plausible-looking grade of zero.
271
+ */
272
+ function noNegZero(value) {
273
+ return Object.is(value, -0) ? 0 : value;
274
+ }
275
+ /**
276
+ * Rounds `value` to `policy.dp` places under `policy.mode`.
277
+ *
278
+ * Idempotent for every mode: rounding an already-rounded value returns it
279
+ * unchanged. That matters because a composed score is rounded once and then
280
+ * compared through {@link gte}, which rounds again.
281
+ */
282
+ function roundGrade(value, policy) {
283
+ if (!Number.isFinite(value)) return value;
284
+ const factor = 10 ** policy.dp;
285
+ const raw = scaled(value, policy.dp);
286
+ switch (policy.mode) {
287
+ case "floor": return noNegZero(Math.floor(raw + EPSILON) / factor);
288
+ case "ceil": return noNegZero(Math.ceil(raw - EPSILON) / factor);
289
+ case "half-even": {
290
+ const floored = Math.floor(raw);
291
+ if (Math.abs(raw - floored - .5) > TIE_TOLERANCE) return noNegZero(halfAway(raw) / factor);
292
+ return noNegZero((floored % 2 === 0 ? floored : floored + 1) / factor);
293
+ }
294
+ default: return noNegZero(halfAway(raw) / factor);
295
+ }
296
+ }
297
+ /**
298
+ * Half-up on a scaled value, with the sign-aware float-noise nudge.
299
+ * `Math.round` breaks .5 ties toward +Infinity, so negatives are mirrored to
300
+ * keep the tie breaking away from zero in both directions.
301
+ */
302
+ function halfAway(raw) {
303
+ const nudged = raw >= 0 ? raw + EPSILON : raw - EPSILON;
304
+ return raw >= 0 ? Math.round(nudged) : -Math.round(-nudged);
305
+ }
306
+ /**
307
+ * Threshold comparison that rounds **both sides** before comparing.
308
+ *
309
+ * Comparing a raw float against a rounded threshold is how a learner ends up
310
+ * shown one number and recorded against another. Rounding both sides — and
311
+ * allowing an epsilon — makes "what the learner sees" and "what the gradebook
312
+ * decides" the same comparison.
313
+ */
314
+ function gte(value, threshold, policy) {
315
+ return roundGrade(value, policy) >= roundGrade(threshold, policy) - EPSILON;
316
+ }
317
+ /**
318
+ * Classifies a value into a band by **flooring**: the highest band whose `min`
319
+ * the value actually reaches. Deliberately does NOT round up to a boundary —
320
+ * over-placement is the more harmful error, so a learner just below a boundary
321
+ * stays below it.
322
+ *
323
+ * Returns `null` when the value reaches no band's minimum.
324
+ */
325
+ function classifyBand(value, bands) {
326
+ let best = null;
327
+ for (const band of bands) if (value >= band.min - EPSILON && (best === null || band.min > best.min)) best = band;
328
+ return best;
329
+ }
330
+ //#endregion
331
+ //#region src/scoring/pass-threshold.ts
332
+ /** Default minimum scaled score required to pass when `passThreshold` is absent. */
333
+ const DEFAULT_PASS_THRESHOLD = .7;
334
+ /**
335
+ * Returns `true` iff `score` meets or exceeds the activity's `passThreshold`,
336
+ * defaulting to {@link DEFAULT_PASS_THRESHOLD} (0.7) when the field is absent.
337
+ *
338
+ * Pass a {@link RoundingPolicy} to compare the way an assessment total is
339
+ * compared — both sides rounded, via {@link gte} — so an item shown as "70%"
340
+ * cannot be recorded as a fail at 69.6. It is **opt-in** rather than the
341
+ * default because switching it on changes item-level pass/fail for scores in
342
+ * the rounding band, and this SDK does not alter historical grades without an
343
+ * explicit decision. Absent, the comparison is the exact raw `>=` it has
344
+ * always been.
345
+ */
346
+ function computePassThreshold(activityData, score, rounding) {
347
+ const threshold = activityData.passThreshold ?? .7;
348
+ return rounding === void 0 ? score >= threshold : gte(score, threshold, rounding);
349
+ }
350
+ /**
351
+ * Selects the authored overall feedback for a result: `feedback.correct` when
352
+ * the learner passed, `feedback.incorrect` otherwise; `null` when no matching
353
+ * message was authored. Mirrors the selection the lk-react components applied
354
+ * (keyed on `passed`, per the `ActivityFeedback` contract).
355
+ */
356
+ function selectFeedback(activityData, passed) {
357
+ const feedback = activityData.feedback;
358
+ if (feedback === void 0) return null;
359
+ return (passed ? feedback.correct : feedback.incorrect) ?? null;
360
+ }
361
+ //#endregion
362
+ //#region src/grading.ts
363
+ /**
364
+ * Computes the weighted total from per-criterion scores — the arithmetic that
365
+ * must NOT be delegated to a language model.
366
+ *
367
+ * A grader is asked for judgement (how good is this criterion?), not for
368
+ * mental arithmetic. Asking a model to also produce the weighted total makes
369
+ * the final grade unverifiable and unreproducible: two runs can return
370
+ * different totals for identical criterion scores. This function makes the
371
+ * total a pure function of the judgements, so a grade can be recomputed and
372
+ * audited years later.
373
+ *
374
+ * Weights are normalised by their sum, so they need not add to 1; each must be
375
+ * zero or more, and their sum a positive, finite number, or the result is
376
+ * `unscorable`. Criteria marked `notApplicable`, and those carrying no numeric
377
+ * `score` (a purely banded judgement), are excluded from both numerator and
378
+ * denominator, weight included. When nothing scoreable remains the result is
379
+ * `unscorable`, never a zero.
380
+ *
381
+ * Scores need not be in [0,1]: set `maxScore` on a criterion to declare what
382
+ * its score is out of, and each is normalised before weighting. A grader
383
+ * working out of 100 says so and is done:
384
+ *
385
+ * ```ts
386
+ * gradeFromRubric([
387
+ * { name: 'Task achievement', score: 82, maxScore: 100, weight: 2 },
388
+ * { name: 'Range', score: 7, maxScore: 9, weight: 1 },
389
+ * ]);
390
+ * ```
391
+ *
392
+ * The returned `GradeRecord.score` is always scaled [0,1] against
393
+ * `maxScore: 1`, like every other score in the SDK.
394
+ */
395
+ function gradeFromRubric(criteria, activityData, options = {}) {
396
+ const rounding = roundingPolicyOf(options?.rounding);
397
+ const corrupt = criteria.find((criterion) => criterion.notApplicable !== true && criterion.score !== void 0 && !Number.isFinite(criterion.score));
398
+ if (corrupt !== void 0) return {
399
+ unscorable: true,
400
+ reason: `Criterion "${corrupt.name}" has a non-finite score (${String(corrupt.score)}). A grade cannot be computed from it.`
401
+ };
402
+ const badMax = criteria.find((criterion) => criterion.notApplicable !== true && criterion.maxScore !== void 0 && !(Number.isFinite(criterion.maxScore) && criterion.maxScore > 0));
403
+ if (badMax !== void 0) return {
404
+ unscorable: true,
405
+ reason: `Criterion "${badMax.name}" declares maxScore ${String(badMax.maxScore)}; it must be a positive, finite number.`
406
+ };
407
+ const scoreable = criteria.filter((criterion) => criterion.notApplicable !== true && typeof criterion.score === "number");
408
+ /** The criterion's score as a ratio in [0,1]. `maxScore` defaults to 1. */
409
+ const ratioOf = (criterion) => criterion.score / (criterion.maxScore ?? 1);
410
+ const outOfRange = scoreable.find((criterion) => ratioOf(criterion) < -1e-9 || ratioOf(criterion) > 1.000000001);
411
+ if (outOfRange !== void 0) return {
412
+ unscorable: true,
413
+ reason: `Criterion "${outOfRange.name}" has score ${outOfRange.score} out of ${outOfRange.maxScore ?? 1}, which is outside the [0,1] range once scaled. Set \`maxScore\` on the criterion to declare what the score is out of (e.g. \`maxScore: 100\` for a 0–100 grader).`
414
+ };
415
+ if (scoreable.length === 0) return {
416
+ unscorable: true,
417
+ reason: "No criterion carried a numeric score, so no weighted total can be computed."
418
+ };
419
+ const totalWeight = scoreable.reduce((sum, criterion) => sum + (criterion.weight ?? 1), 0);
420
+ if (!(totalWeight > 0)) return {
421
+ unscorable: true,
422
+ reason: "Criterion weights do not sum to a positive number, so the weighted total is undefined."
423
+ };
424
+ const negativeWeight = scoreable.find((criterion) => (criterion.weight ?? 1) < 0);
425
+ if (negativeWeight !== void 0) return {
426
+ unscorable: true,
427
+ reason: `Criterion "${negativeWeight.name}" has weight ${String(negativeWeight.weight)}; a weight must be zero or more.`
428
+ };
429
+ const rawScore = scoreable.reduce((sum, criterion) => sum + ratioOf(criterion) * (criterion.weight ?? 1), 0) / totalWeight;
430
+ if (!Number.isFinite(totalWeight) || !Number.isFinite(rawScore)) return {
431
+ unscorable: true,
432
+ reason: "The weighted total is not a finite number, so no grade can be produced."
433
+ };
434
+ const score = Math.min(1, Math.max(0, rawScore));
435
+ const reaches = (threshold) => rounding === void 0 ? score >= threshold : gte(score, threshold, rounding);
436
+ return {
437
+ score,
438
+ maxScore: 1,
439
+ passed: options.passThreshold !== void 0 ? reaches(options.passThreshold) : activityData !== void 0 ? computePassThreshold(activityData, score, rounding) : reaches(.7),
440
+ feedback: options.feedback ?? null,
441
+ criteria: [...criteria]
442
+ };
443
+ }
444
+ /**
445
+ * Lifts a {@link GradeRecord} into the `graded` arm of {@link ItemOutcome}, so
446
+ * a grade that arrived asynchronously renders through the same path as a
447
+ * synchronously scored item. The score/passed/feedback fields are mirrored
448
+ * onto the outcome for uniform reads; `grade` carries the full record.
449
+ *
450
+ * A record whose numbers cannot be a grade is refused, as `evaluate` refuses a
451
+ * non-finite score and {@link gradeFromRubric} one outside [0,1]. `maxScore`
452
+ * must be a positive, finite number and `score` a finite number from 0 to it (a
453
+ * hair above, one part in a billion, is float noise and passes as it is).
454
+ * Anything else — NaN, 85 "out of 1", a score out of 0 — comes back as
455
+ * `{ status: 'deferred', reason: 'grade_rejected', maxScore: 1, rejectedGrade }`
456
+ * with the record kept verbatim. Not `graded`, or its `passed` would be read as
457
+ * a verdict; not `unscorable`, which `composeAssessmentScore` drops from the
458
+ * denominator while letting the attempt go final — so a failing grade on the
459
+ * wrong scale would vanish and the rest would be recorded as a pass. Deferred
460
+ * is what it is: the slot is still owed a real grade, so a composed attempt
461
+ * stays `provisional` and names the slot in `rejectedSlotIds`.
462
+ */
463
+ function outcomeFromGrade(grade) {
464
+ const { score, maxScore } = grade;
465
+ if (!isGradeInRange(score, maxScore)) return {
466
+ status: "deferred",
467
+ reason: "grade_rejected",
468
+ maxScore: 1,
469
+ rejectedGrade: grade
470
+ };
471
+ return {
472
+ status: "graded",
473
+ grade,
474
+ score,
475
+ maxScore,
476
+ passed: grade.passed,
477
+ feedback: grade.feedback
478
+ };
479
+ }
480
+ /**
481
+ * True when an outcome carries a real grade — either the SDK scored it
482
+ * synchronously or a grader returned one. Use this instead of testing
483
+ * `status === 'scored'`, which silently misses asynchronously graded work.
484
+ */
485
+ function hasGrade(outcome) {
486
+ return outcome.status === "scored" || outcome.status === "graded";
487
+ }
488
+ //#endregion
489
+ //#region src/scoring/compose.ts
490
+ function earned(item) {
491
+ const { outcome } = item;
492
+ if (hasGrade(outcome)) {
493
+ const { score, maxScore } = outcome;
494
+ if (!isGradeInRange(score, maxScore)) return { state: "rejected" };
495
+ return {
496
+ state: "graded",
497
+ points: score / maxScore * item.points
498
+ };
499
+ }
500
+ if (outcome.status === "deferred") return outcome.reason === "grade_rejected" ? { state: "rejected" } : { state: "pending" };
501
+ return { state: "unscorable" };
502
+ }
503
+ /**
504
+ * The `rejectedSlotIds` field, present only when something was rejected: a
505
+ * result with none is then exactly what earlier versions returned, and the
506
+ * grade-stability corpus compares results exactly.
507
+ */
508
+ function rejectedField(slotIds) {
509
+ return slotIds.length > 0 ? { rejectedSlotIds: slotIds } : {};
510
+ }
511
+ /**
512
+ * Composes per-item outcomes into a sectioned assessment score.
513
+ *
514
+ * Weights are normalised by their sum. Each section's score is computed over
515
+ * the points that are actually gradable and rounded ONCE, before any threshold
516
+ * comparison, so the number a learner is shown is the number that decides the
517
+ * outcome. Items still awaiting a grade are excluded from the denominator
518
+ * rather than counted as zero, and the result is reported as `provisional`
519
+ * until every item has a grade. An outcome whose score cannot be a grade out of
520
+ * its `maxScore` is not a grade either: it is held the same way and named in
521
+ * `rejectedSlotIds`.
522
+ */
523
+ function composeAssessmentScore(sections, policy) {
524
+ const pendingAll = [];
525
+ const rejectedAll = [];
526
+ const unscorableAll = [];
527
+ const partials = sections.map((section) => {
528
+ let earnedPoints = 0;
529
+ let gradedMaxPoints = 0;
530
+ let maxPoints = 0;
531
+ const pendingSlotIds = [];
532
+ const rejectedSlotIds = [];
533
+ const unscorableSlotIds = [];
534
+ for (const item of section.items) {
535
+ maxPoints += item.points;
536
+ const contribution = earned(item);
537
+ if (contribution.state === "pending" || contribution.state === "rejected") {
538
+ pendingSlotIds.push(item.slotId);
539
+ pendingAll.push(item.slotId);
540
+ if (contribution.state === "rejected") {
541
+ rejectedSlotIds.push(item.slotId);
542
+ rejectedAll.push(item.slotId);
543
+ }
544
+ continue;
545
+ }
546
+ if (contribution.state === "unscorable") {
547
+ unscorableSlotIds.push(item.slotId);
548
+ unscorableAll.push(item.slotId);
549
+ continue;
550
+ }
551
+ earnedPoints += contribution.points;
552
+ gradedMaxPoints += item.points;
553
+ }
554
+ const raw = gradedMaxPoints > 0 ? earnedPoints / gradedMaxPoints : 0;
555
+ const appliedThreshold = section.passThresholdOverride ?? policy.sectionThreshold ?? null;
556
+ return {
557
+ section,
558
+ earnedPoints,
559
+ gradedMaxPoints,
560
+ maxPoints,
561
+ score: roundGrade(raw, policy.rounding),
562
+ appliedThreshold,
563
+ pendingSlotIds,
564
+ rejectedSlotIds,
565
+ unscorableSlotIds
566
+ };
567
+ });
568
+ const contributingWeight = partials.reduce((sum, partial) => sum + (partial.gradedMaxPoints > 0 ? partial.section.weight : 0), 0);
569
+ const scored = partials.map((partial) => ({
570
+ id: partial.section.id,
571
+ ...partial.section.title !== void 0 ? { title: partial.section.title } : {},
572
+ weight: partial.section.weight,
573
+ normalizedWeight: partial.gradedMaxPoints > 0 && contributingWeight > 0 ? partial.section.weight / contributingWeight : 0,
574
+ earnedPoints: partial.earnedPoints,
575
+ gradedMaxPoints: partial.gradedMaxPoints,
576
+ maxPoints: partial.maxPoints,
577
+ score: partial.score,
578
+ passed: partial.appliedThreshold === null || partial.gradedMaxPoints === 0 ? true : gte(partial.score, partial.appliedThreshold, policy.rounding),
579
+ appliedThreshold: partial.appliedThreshold,
580
+ pendingSlotIds: partial.pendingSlotIds,
581
+ ...rejectedField(partial.rejectedSlotIds),
582
+ unscorableSlotIds: partial.unscorableSlotIds
583
+ }));
584
+ const score = roundGrade(scored.reduce((sum, section) => sum + section.score * section.normalizedWeight, 0), policy.rounding);
585
+ const status = pendingAll.length > 0 ? "provisional" : "final";
586
+ if (status === "provisional") return {
587
+ sections: scored,
588
+ score,
589
+ passed: null,
590
+ passFailureReason: null,
591
+ status,
592
+ pendingSlotIds: pendingAll,
593
+ ...rejectedField(rejectedAll),
594
+ unscorableSlotIds: unscorableAll
595
+ };
596
+ if (scored.every((section) => section.gradedMaxPoints === 0)) return {
597
+ sections: scored,
598
+ score,
599
+ passed: null,
600
+ passFailureReason: null,
601
+ status,
602
+ pendingSlotIds: pendingAll,
603
+ ...rejectedField(rejectedAll),
604
+ unscorableSlotIds: unscorableAll
605
+ };
606
+ const overallOk = gte(score, policy.passThreshold, policy.rounding);
607
+ const sectionsOk = scored.every((section) => section.passed);
608
+ const passed = overallOk && sectionsOk;
609
+ return {
610
+ sections: scored,
611
+ score,
612
+ passed,
613
+ passFailureReason: passed ? null : !overallOk && !sectionsOk ? "both" : overallOk ? "section_below_threshold" : "overall_below_threshold",
614
+ status,
615
+ pendingSlotIds: pendingAll,
616
+ ...rejectedField(rejectedAll),
617
+ unscorableSlotIds: unscorableAll
618
+ };
619
+ }
620
+ //#endregion
621
+ //#region src/scoring/dictation/chars.ts
622
+ /**
623
+ * The code-point edit operations between two NORMALISED strings — the
624
+ * `reference` and `attempt` of a `DictationAlignment`, or one word pair from
625
+ * it — for a marked display: a character the learner typed right, typed
626
+ * wrong, left out, or added. Ties are broken preferring an equal or substituted
627
+ * pair, then a missing character, then an extra one, from the end of both
628
+ * strings; the number of non-`equal` ops is the edit distance.
629
+ *
630
+ * Decoration, never grading: it needs O(|reference|·|attempt|) memory, so the
631
+ * scorer does not call it, and a display computes it once per result.
632
+ */
633
+ function diffDictationChars(reference, attempt) {
634
+ const referencePoints = Array.from(reference);
635
+ const attemptPoints = Array.from(attempt);
636
+ return require_registry.alignSequences(referencePoints, attemptPoints).map(({ step, referenceIndex, attemptIndex }) => {
637
+ if (step === require_registry.STEP.missing) return {
638
+ op: "missing",
639
+ reference: referencePoints[referenceIndex],
640
+ attempt: ""
641
+ };
642
+ if (step === require_registry.STEP.extra) return {
643
+ op: "extra",
644
+ reference: "",
645
+ attempt: attemptPoints[attemptIndex]
646
+ };
647
+ const referencePoint = referencePoints[referenceIndex];
648
+ const attemptPoint = attemptPoints[attemptIndex];
649
+ return {
650
+ op: referencePoint === attemptPoint ? "equal" : "substitute",
651
+ reference: referencePoint,
652
+ attempt: attemptPoint
653
+ };
654
+ });
655
+ }
656
+ //#endregion
657
+ //#region src/scoring/speech/tokens.ts
658
+ /**
659
+ * The normalised tokens of the words that can be marked, each remembering the
660
+ * word it came from, up to what {@link SPEECH_ASSESSMENT_MAX_TEXT_LENGTH} code
661
+ * points of text hold.
662
+ *
663
+ * A word the assessor tagged `insertion` gives no token — the reference has
664
+ * nothing for it to pair with, so it is placed by its own position afterwards —
665
+ * but its text IS charged to the length, because the aligner normalises it
666
+ * anyway and emits it as an entry of its own. Charging it is what makes the
667
+ * length the whole of what an assessment spells, so the bound the validator
668
+ * states over it is the bound that holds. A word that normalises to nothing
669
+ * spells nothing and is charged nothing.
670
+ *
671
+ * The length counts what the words spell as one text, so the space that joins a
672
+ * token to the last one counts with it: the aligner's matrix is one byte per
673
+ * token per reference word, and it is that text, not the words it was written
674
+ * as, that decides how big it grows. Collecting stops at the first token that
675
+ * would pass the budget, never inside one, because half a token pairs with the
676
+ * wrong reference word and marks a reading nobody gave.
677
+ */
678
+ function heardTokens(words) {
679
+ const tokens = [];
680
+ let length = 0;
681
+ for (const [wordIndex, word] of words.entries()) {
682
+ const normalized = require_registry.normalizeDictationText(word.text);
683
+ if (normalized === "") continue;
684
+ const inserted = word.error === "insertion";
685
+ for (const text of normalized.split(" ")) {
686
+ length += require_registry.codePointLength(text) + (length === 0 ? 0 : 1);
687
+ if (!inserted && length <= require_registry.SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) tokens.push({
688
+ text,
689
+ wordIndex
690
+ });
691
+ }
692
+ }
693
+ return {
694
+ tokens,
695
+ length
696
+ };
697
+ }
698
+ //#endregion
699
+ //#region src/scoring/speech/assessment.ts
700
+ /** A score on the assessment's 0..100 scale. `z.number()` refuses NaN and both infinities. */
701
+ const Score = zod_v4.number().min(0).max(100);
702
+ /** A confidence, 0..1. */
703
+ const Confidence = zod_v4.number().min(0).max(1);
704
+ /** A time from the start of the recording, or a length of time, in milliseconds. */
705
+ const Milliseconds = zod_v4.number().min(0);
706
+ const PhonemeCandidateSchema = zod_v4.strictObject({
707
+ symbol: require_registry.maxUnits(zod_v4.string().min(1), 16),
708
+ score: Score
709
+ });
710
+ const PhonemeSchema = zod_v4.strictObject({
711
+ symbol: require_registry.maxUnits(zod_v4.string().min(1), 16).optional(),
712
+ accuracy: Score.optional(),
713
+ startMs: Milliseconds.optional(),
714
+ durationMs: Milliseconds.optional(),
715
+ heardAs: zod_v4.array(PhonemeCandidateSchema).max(10).optional()
716
+ });
717
+ const SyllableSchema = zod_v4.strictObject({
718
+ text: require_registry.maxUnits(zod_v4.string().min(1), 64),
719
+ grapheme: require_registry.maxUnits(zod_v4.string(), 64).optional(),
720
+ accuracy: Score.optional(),
721
+ startMs: Milliseconds.optional(),
722
+ durationMs: Milliseconds.optional()
723
+ });
724
+ const WordSchema = zod_v4.strictObject({
725
+ text: require_registry.maxUnits(zod_v4.string().min(1), 200),
726
+ accuracy: Score.optional(),
727
+ error: zod_v4.enum([
728
+ "none",
729
+ "mispronunciation",
730
+ "omission",
731
+ "insertion"
732
+ ]),
733
+ vendorError: require_registry.maxUnits(zod_v4.string(), 64).optional(),
734
+ startMs: Milliseconds.optional(),
735
+ durationMs: Milliseconds.optional(),
736
+ syllables: zod_v4.array(SyllableSchema).max(50).optional(),
737
+ phonemes: zod_v4.array(PhonemeSchema).max(50).optional(),
738
+ breaks: zod_v4.strictObject({
739
+ unexpected: Confidence.optional(),
740
+ missing: Confidence.optional()
741
+ }).optional()
742
+ });
743
+ const AssessorSchema = zod_v4.strictObject({
744
+ kind: zod_v4.enum([
745
+ "auto",
746
+ "ai",
747
+ "human"
748
+ ]),
749
+ id: require_registry.maxUnits(zod_v4.string(), 256).optional(),
750
+ model: require_registry.maxUnits(zod_v4.string(), 256).optional(),
751
+ promptHash: require_registry.maxUnits(zod_v4.string(), 256).optional()
752
+ });
753
+ const SpeechAssessmentSchema = zod_v4.strictObject({
754
+ assessmentVersion: zod_v4.literal("1.0"),
755
+ status: zod_v4.enum(["assessed", "no_speech"]),
756
+ task: zod_v4.enum(["scripted", "unscripted"]),
757
+ locale: zod_v4.string().regex(require_registry.CANONICAL_LOCALE_RE),
758
+ referenceText: require_registry.maxUnits(zod_v4.string(), 8e3).optional(),
759
+ recordingKey: require_registry.maxUnits(zod_v4.string().min(1), 1024).optional(),
760
+ assessor: AssessorSchema,
761
+ scale: zod_v4.literal(100),
762
+ scores: zod_v4.strictObject({
763
+ accuracy: Score.optional(),
764
+ fluency: Score.optional(),
765
+ completeness: Score.optional(),
766
+ prosody: Score.optional(),
767
+ overall: Score.optional()
768
+ }),
769
+ recognizedText: require_registry.maxUnits(zod_v4.string(), 8e3).optional(),
770
+ miscue: zod_v4.enum(["assessor", "none"]),
771
+ phonemeAlphabet: zod_v4.enum(["ipa", "sapi"]).optional(),
772
+ words: zod_v4.array(WordSchema).max(require_registry.SPEECH_ASSESSMENT_MAX_WORDS),
773
+ prosody: zod_v4.strictObject({ monotoneConfidence: Confidence.optional() }).optional(),
774
+ signal: zod_v4.strictObject({ snrDb: zod_v4.number().optional() }).optional()
775
+ });
776
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
777
+ /** Whether any phoneme of any word names a `symbol`, or lists what was `heardAs` instead. */
778
+ function namesPhonemes(words) {
779
+ return Array.isArray(words) && words.some((word) => {
780
+ const phonemes = isRecord(word) ? word.phonemes : void 0;
781
+ return Array.isArray(phonemes) && phonemes.some((phoneme) => isRecord(phoneme) && (phoneme.symbol !== void 0 || phoneme.heardAs !== void 0));
782
+ });
783
+ }
784
+ /** How much text the words are written with between them, in UTF-16 units. */
785
+ function rawTextLength(words) {
786
+ let total = 0;
787
+ for (const word of words) total += word.text.length;
788
+ return total;
789
+ }
790
+ /**
791
+ * The two rules over the total text of the words, reported at `words`. They run
792
+ * on the value zod parsed — the words as they were read, never as a second read
793
+ * of the argument describes them — because a bound computed from a value the
794
+ * aligner will not see bounds nothing.
795
+ *
796
+ * Both are needed, and neither replaces the other:
797
+ *
798
+ * - the **raw** total is what the words are written with. It is linear and
799
+ * normalises nothing, so it refuses the 200 000-unit assessment the per-word
800
+ * caps allow between them without paying the cost it exists to prevent, and
801
+ * it runs first;
802
+ * - the **normalised** total is what those words spell once they are folded into
803
+ * tokens, which is what the aligner actually pairs. A compatibility character
804
+ * can stand for eighteen, so a reading well inside the raw cap can still spell
805
+ * more text than the aligner reads — and the words past that are dropped in
806
+ * silence, leaving the learner marked from the noise that came before them.
807
+ * It is measured by the very function that collects those tokens, so the two
808
+ * bounds are one bound.
809
+ *
810
+ * Both count every word the assessment carries, so neither can be walked past
811
+ * by tagging text `insertion`: an inserted word gives no token to pair, but the
812
+ * aligner still normalises it and emits it, so its text is text the call holds.
813
+ *
814
+ * One error at most: the second runs only on what the first let through, so it
815
+ * never normalises more than the raw cap allows.
816
+ */
817
+ function totalTextErrors(words) {
818
+ const raw = rawTextLength(words);
819
+ if (raw > require_registry.SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) return [{
820
+ path: ["words"],
821
+ message: `The words of an assessment carry ${raw} characters between them, and at most ${require_registry.SPEECH_ASSESSMENT_MAX_TEXT_LENGTH} can be aligned against the text of an item.`,
822
+ code: "too_big"
823
+ }];
824
+ const normalized = heardTokens(words).length;
825
+ if (normalized > require_registry.SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) return [{
826
+ path: ["words"],
827
+ message: `The words of an assessment spell ${normalized} characters between them once they are normalised, and at most ${require_registry.SPEECH_ASSESSMENT_MAX_TEXT_LENGTH} can be aligned against the text of an item. One character can stand for several.`,
828
+ code: "too_big"
829
+ }];
830
+ return [];
831
+ }
832
+ /**
833
+ * The two rules no single field states, and no parsed value can answer: each
834
+ * fires on a field that is ABSENT. They read the value as it was given, not as
835
+ * zod parsed it, so they report beside the field rules instead of waiting for
836
+ * every field to parse — an adapter sees each problem on its first run — and
837
+ * because a field zod refused is missing from what it parsed.
838
+ */
839
+ function crossFieldErrors(value) {
840
+ const errors = [];
841
+ if (value.task === "scripted") {
842
+ if (value.referenceText === void 0) errors.push({
843
+ path: ["referenceText"],
844
+ message: "A scripted assessment must carry the reference text it was made against, so a grade can confirm it is the text of the item.",
845
+ code: "scripted_binding_required"
846
+ });
847
+ if (value.recordingKey === void 0) errors.push({
848
+ path: ["recordingKey"],
849
+ message: "A scripted assessment must carry the key of the recording it measured, so a grade can confirm it is the recording of the learner.",
850
+ code: "scripted_binding_required"
851
+ });
852
+ }
853
+ if (value.phonemeAlphabet === void 0 && namesPhonemes(value.words)) errors.push({
854
+ path: ["phonemeAlphabet"],
855
+ message: "A phoneme names a symbol or what was heard instead, so the assessment must say which phonemeAlphabet those symbols are written in.",
856
+ code: "phoneme_alphabet_required"
857
+ });
858
+ return errors;
859
+ }
860
+ /**
861
+ * One validation error as a phrase a developer can act on: the path it was
862
+ * reported at, then the message. A refusal at the root has no path, and the
863
+ * message already names the value.
864
+ *
865
+ * Internal: it is what `alignReadAloud`'s `TypeError` and `gradeReadAloud`'s
866
+ * `invalid_assessment` reason say about the first problem they found, so the
867
+ * two name it the same way.
868
+ */
869
+ function describeValidationError(error) {
870
+ return error.path.length === 0 ? error.message : `${error.path.join(".")}: ${error.message}`;
871
+ }
872
+ /**
873
+ * Checks that `value` is a well-formed {@link SpeechAssessment}. It answers
874
+ * rather than throwing: anything that is not one — a non-object, an unknown key
875
+ * at any depth, a score off the 0..100 scale, a time that is negative or not
876
+ * finite — comes back as `{ success: false, errors }`, one error per problem,
877
+ * each with the path, message and code `validateActivity` gives a schema
878
+ * failure. An object that throws when it is read is the one exception, and the
879
+ * throw is its own: an own property getter, or a `Proxy` trap.
880
+ *
881
+ * Three rules span fields:
882
+ * - `scripted_binding_required` — a `scripted` assessment without
883
+ * `referenceText`, or without `recordingKey`, reported at each missing path;
884
+ * - `phoneme_alphabet_required` — a phoneme names a `symbol` or `heardAs` while
885
+ * `phonemeAlphabet` is absent, reported once, at `phonemeAlphabet`;
886
+ * - `too_big` at `words` — the words carry more text between them, written or
887
+ * normalised, than can be aligned against an item's text. Per-word caps bound
888
+ * no total, and the aligner pairs tokens, of which one word holds as many as
889
+ * its text spells. Every word counts towards both totals, an insertion
890
+ * included: the aligner carries one into its answer, so its text is text the
891
+ * call must hold.
892
+ *
893
+ * The total is measured on the words every field rule accepted, so it says
894
+ * nothing about an assessment that is going back to its adapter anyway.
895
+ *
896
+ * It checks the evidence alone. Whether an assessment belongs to an item and
897
+ * to a learner's recording is decided when it is graded.
898
+ */
899
+ function validateSpeechAssessment(value) {
900
+ const result = SpeechAssessmentSchema.safeParse(value);
901
+ const refused = result.success ? [] : result.error.issues.map((issue) => ({
902
+ path: issue.path.map(String),
903
+ message: issue.message,
904
+ code: issue.code
905
+ }));
906
+ const parsed = result.success ? result.data : void 0;
907
+ const errors = isRecord(value) ? [
908
+ ...refused,
909
+ ...crossFieldErrors(value),
910
+ ...parsed !== void 0 ? totalTextErrors(parsed.words) : []
911
+ ] : refused;
912
+ if (parsed !== void 0 && errors.length === 0) return {
913
+ success: true,
914
+ data: parsed
915
+ };
916
+ return {
917
+ success: false,
918
+ errors
919
+ };
920
+ }
921
+ //#endregion
922
+ //#region src/scoring/speech/align.ts
923
+ /**
924
+ * The word's accuracy when it has one. Absent means the assessor did not
925
+ * measure the word, which is never the same as measuring it at 0.
926
+ */
927
+ function accuracyOf(word) {
928
+ return word.accuracy !== void 0 ? { accuracy: word.accuracy } : {};
929
+ }
930
+ /**
931
+ * Pairs the words of `assessment` with the words of `data.referenceText`, in
932
+ * reading order: what was read as written, what was mispronounced, what was
933
+ * left out, and what was said that the text does not contain.
934
+ *
935
+ * Reference words are the dictation tokeniser's, so `itemId` is the `w<n>`
936
+ * `dictationReferenceWords` gives, and both `reference` and `heard` are
937
+ * normalised — lowercased, with punctuation and spacing folded away. One
938
+ * assessor word can normalise to no token at all (a dash), or to several (a
939
+ * compatibility form), and two can merge into one (`rock&roll`); tokens are
940
+ * what is aligned, and each remembers the word it came from.
941
+ *
942
+ * A word the assessor tagged `insertion` is never aligned — the reference has
943
+ * nothing for it — so it is placed by the position of its own word, after the
944
+ * last token that came from an earlier word.
945
+ *
946
+ * Whether a paired word counts as read correctly follows `assessment.miscue`:
947
+ * an assessor that judges miscues is trusted, and one that does not
948
+ * (`miscue: 'none'`) has its recognised word compared with the reference word.
949
+ *
950
+ * `heard` is `''` for an omitted word, and also for an inserted word whose text
951
+ * normalises to nothing. Branch on `state`, never on the empty string.
952
+ *
953
+ * The heard tokens are bounded: they are taken in reading order until what the
954
+ * assessment's words spell would pass `DICTATION_MAX_TEXT_LENGTH` code points —
955
+ * the cap the dictation aligner reads a learner's text under — and always on a
956
+ * whole token, because half a token pairs with the wrong word. Every word is
957
+ * charged, an inserted one included, so that total is the whole of the text
958
+ * this function can answer with. Words past the budget contribute no token, so
959
+ * each reference word left with nothing read for it is `omitted`.
960
+ * `validateSpeechAssessment` refuses an assessment whose words spell more than
961
+ * that, measured by the same walk and against the same number, so a validated
962
+ * assessment never reaches the budget: it is a backstop, kept because this
963
+ * function is public and can be handed evidence this build did not check.
964
+ *
965
+ * **`data` is not validated.** Only the assessment is. A `referenceText` longer
966
+ * than `READ_ALOUD_MAX_REFERENCE_LENGTH` code points is cut by the tokeniser
967
+ * with no signal, and one that is absent or is not a string yields no reference
968
+ * word at all, so every heard token comes back `inserted` rather than refused.
969
+ * Validate the item with `validateActivity('read-aloud', data)` first.
970
+ * `gradeReadAloud` is safe without that because it runs the schema itself.
971
+ *
972
+ * Pure and deterministic: no locale, no `Intl`, no clock.
973
+ *
974
+ * @throws TypeError when `assessment` is not a well-formed
975
+ * {@link SpeechAssessment}; check it with `validateSpeechAssessment` first.
976
+ */
977
+ function alignReadAloud(data, assessment) {
978
+ const checked = validateSpeechAssessment(assessment);
979
+ if (!checked.success) throw new TypeError(`alignReadAloud was given something that is not a speech assessment (${describeValidationError(checked.errors[0])}). Check it with validateSpeechAssessment before aligning it.`);
980
+ return alignValidated(data, checked.data);
981
+ }
982
+ /**
983
+ * {@link alignReadAloud} for a caller that has already validated the
984
+ * assessment, and holds the value the validator handed back.
985
+ *
986
+ * Internal: it is how `gradeReadAloud` aligns what it just checked, rather than
987
+ * paying for the same parse twice — the cost of which is the whole assessment,
988
+ * words, syllables and phonemes, on every grade.
989
+ */
990
+ function alignValidated(data, assessment) {
991
+ const reference = require_registry.dictationReferenceWords({ transcript: data.referenceText })[0];
992
+ const heard = heardTokens(assessment.words).tokens;
993
+ const entries = [];
994
+ for (const { step, referenceIndex, attemptIndex } of require_registry.alignSequences(reference.map((word) => word.word), heard.map((token) => token.text))) {
995
+ if (step === require_registry.STEP.extra) {
996
+ const token = heard[attemptIndex];
997
+ entries.push({
998
+ reference: "",
999
+ heard: token.text,
1000
+ state: "inserted",
1001
+ ...accuracyOf(assessment.words[token.wordIndex]),
1002
+ wordIndex: token.wordIndex
1003
+ });
1004
+ continue;
1005
+ }
1006
+ const referenceWord = reference[referenceIndex];
1007
+ if (step === require_registry.STEP.missing) {
1008
+ entries.push({
1009
+ itemId: referenceWord.itemId,
1010
+ reference: referenceWord.word,
1011
+ heard: "",
1012
+ state: "omitted"
1013
+ });
1014
+ continue;
1015
+ }
1016
+ const token = heard[attemptIndex];
1017
+ const word = assessment.words[token.wordIndex];
1018
+ const state = pairedState(word, assessment.miscue, referenceWord.word, token.text);
1019
+ entries.push({
1020
+ itemId: referenceWord.itemId,
1021
+ reference: referenceWord.word,
1022
+ heard: state === "omitted" ? "" : token.text,
1023
+ state,
1024
+ ...accuracyOf(word),
1025
+ wordIndex: token.wordIndex
1026
+ });
1027
+ }
1028
+ placeInsertions(assessment, entries);
1029
+ return entries;
1030
+ }
1031
+ /** How a reference word paired with a heard token is marked. */
1032
+ function pairedState(word, miscue, reference, heard) {
1033
+ if (word.error === "omission") return "omitted";
1034
+ if (word.error === "mispronunciation") return "mispronounced";
1035
+ if (miscue === "none" && heard !== reference) return "mispronounced";
1036
+ return "correct";
1037
+ }
1038
+ /**
1039
+ * Splices in one entry per `insertion` word. An inserted word has no reference
1040
+ * word to align with, so it is placed by its own position among the assessor's
1041
+ * words: immediately after the last entry that came from an earlier word, or
1042
+ * first when nothing came before it. Several in a row keep the order the
1043
+ * assessor reported them in, because each is placed after the one before it.
1044
+ */
1045
+ function placeInsertions(assessment, entries) {
1046
+ assessment.words.forEach((word, wordIndex) => {
1047
+ if (word.error !== "insertion") return;
1048
+ let at = 0;
1049
+ entries.forEach((entry, index) => {
1050
+ if (entry.wordIndex !== void 0 && entry.wordIndex < wordIndex) at = index + 1;
1051
+ });
1052
+ entries.splice(at, 0, {
1053
+ reference: "",
1054
+ heard: require_registry.normalizeDictationText(word.text),
1055
+ state: "inserted",
1056
+ ...accuracyOf(word),
1057
+ wordIndex
1058
+ });
1059
+ });
1060
+ }
1061
+ //#endregion
1062
+ //#region src/scoring/speech/grade.ts
1063
+ /**
1064
+ * The read-aloud grade: the arithmetic over a pronunciation assessment's
1065
+ * dimension scores, and the checks that decide whether that evidence may be
1066
+ * graded at all.
1067
+ *
1068
+ * The SDK calls no assessor and holds no key. It is given the application's
1069
+ * assessment and the server's own measurement of the recording, and it refuses
1070
+ * evidence it cannot tie to this item, this text and this recording — a refusal
1071
+ * is never a zero, because a learner who was not heard has not failed.
1072
+ */
1073
+ /**
1074
+ * Float slack when comparing two measurements of the same recording: voiced
1075
+ * time is summed window by window, so it can land a hair above the duration it
1076
+ * was measured from without either number being wrong.
1077
+ */
1078
+ const MEASUREMENT_TOLERANCE = 1e-6;
1079
+ /** What every assessor score is out of, and what a criterion declares. */
1080
+ const ASSESSMENT_SCALE = 100;
1081
+ /** A value named in an error message, without serialising the object it came from. */
1082
+ function describeValue$1(value) {
1083
+ return typeof value === "number" ? String(value) : `of type ${typeof value}`;
1084
+ }
1085
+ /**
1086
+ * The recording a response carries, checked and copied — or `undefined` when
1087
+ * the value is not a response this grade can read.
1088
+ *
1089
+ * It answers with a copy for the same reason the item and the assessment are
1090
+ * graded from their checked copies: the response has no schema to parse it, so
1091
+ * this is the only place its fields are read. An accessor that answered a take
1092
+ * here and `null` afterwards would have graded a real reading as a blank 0, and
1093
+ * one that answered another key afterwards would have walked past the
1094
+ * recording-key binding. Every field is read once, and what is returned is what
1095
+ * was checked.
1096
+ */
1097
+ function readResponseRecording(value) {
1098
+ if (typeof value !== "object" || value === null) return;
1099
+ const { type, recording } = value;
1100
+ if (type !== "read-aloud") return;
1101
+ if (recording === null) return { recording: null };
1102
+ if (typeof recording !== "object") return;
1103
+ const { key, mimeType } = recording;
1104
+ if (typeof key !== "string" || key === "" || typeof mimeType !== "string") return;
1105
+ return { recording: {
1106
+ key,
1107
+ mimeType
1108
+ } };
1109
+ }
1110
+ /**
1111
+ * How many words were spoken, for the speech-rate check: what the assessor
1112
+ * recognised as text when it reported any, and otherwise the words it kept —
1113
+ * an omission was not spoken, and an insertion is not in the text.
1114
+ *
1115
+ * `countWords` is the counter on purpose: it is the one the SDK already counts
1116
+ * a written response with, and a rate compared against a policy an application
1117
+ * set must be counted the same way every time.
1118
+ */
1119
+ function spokenWordCount(assessment) {
1120
+ const recognized = assessment.recognizedText;
1121
+ if (recognized !== void 0 && recognized.trim() !== "") return require_registry.countWords(recognized);
1122
+ return require_registry.countWords(assessment.words.filter((word) => word.error !== "omission" && word.error !== "insertion").map((word) => word.text).join(" "));
1123
+ }
1124
+ /**
1125
+ * The mark for one reference word: nothing was read for an omitted word, and a
1126
+ * word the assessor did not measure carries no mark at all — an absent score is
1127
+ * not a zero.
1128
+ */
1129
+ function scoreOf(entry) {
1130
+ if (entry.state === "omitted") return { score: 0 };
1131
+ return entry.accuracy !== void 0 ? { score: entry.accuracy / ASSESSMENT_SCALE } : {};
1132
+ }
1133
+ /**
1134
+ * Grades a read-aloud take from a {@link SpeechAssessment} the application
1135
+ * obtained, the response that was submitted, and the server's own measurement
1136
+ * of the recording.
1137
+ *
1138
+ * The weighted total is computed by `gradeFromRubric` from the authored
1139
+ * dimensions that carry a weight above 0, each out of 100. The per-word marks
1140
+ * in `details` are for display and review: **they never feed the score**, which
1141
+ * is the assessor's utterance-level judgement, weighted as the item authored it.
1142
+ *
1143
+ * Evidence is refused, never scored 0, when it cannot be tied to this item and
1144
+ * this recording, when the assessor heard nothing, when the recording holds too
1145
+ * little voiced time for the words claimed, or when a weighted dimension has no
1146
+ * score — see {@link SpeechUnscorableCode}. Store a refusal with
1147
+ * `outcomeFromUnscorable`.
1148
+ *
1149
+ * A blank (`response.recording === null`) is a grade of 0 with a mark per
1150
+ * reference word: the learner submitted, and read nothing. The assessment and
1151
+ * the measurement are not read for it.
1152
+ *
1153
+ * @throws RangeError when `options.plausibility` or `options.rounding` cannot be
1154
+ * applied, or when `options.measured` is not a pair of non-negative, finite
1155
+ * millisecond counts with `voicedMs` inside `durationMs`.
1156
+ * @throws TypeError when `response` is not a read-aloud response, or when a
1157
+ * non-blank response is graded without an assessment or without a measurement.
1158
+ * @throws ActivitySchemaError when `data` is not valid read-aloud data.
1159
+ * @throws RedactedScoringError when `data` is a `redact()` projection. A
1160
+ * read-aloud projection is a valid activity, so the authored feedback would
1161
+ * simply be missing from the grade; grade against the full data server-side.
1162
+ */
1163
+ function gradeReadAloud(data, response, assessment, options) {
1164
+ const maxWordsPerSecond = options?.plausibility?.maxWordsPerSecond;
1165
+ if (typeof maxWordsPerSecond !== "number" || !Number.isFinite(maxWordsPerSecond) || maxWordsPerSecond <= 0) throw new RangeError(`Invalid plausibility policy (maxWordsPerSecond ${describeValue$1(maxWordsPerSecond)}): expected a finite number above 0.`);
1166
+ const minVoicedMs = options?.plausibility?.minVoicedMs;
1167
+ if (typeof minVoicedMs !== "number" || !Number.isFinite(minVoicedMs) || minVoicedMs < 0) throw new RangeError(`Invalid plausibility policy (minVoicedMs ${describeValue$1(minVoicedMs)}): expected a finite number of milliseconds, 0 or above.`);
1168
+ const rounding = roundingPolicyOf(options?.rounding);
1169
+ const parsed = require_registry.ReadAloudDataSchema.safeParse(data);
1170
+ if (!parsed.success) throw new require_registry.ActivitySchemaError("read-aloud", parsed.error.issues.map((issue) => ({
1171
+ path: issue.path.map(String),
1172
+ message: issue.message,
1173
+ code: issue.code
1174
+ })));
1175
+ if (isRedacted(data) || isRedacted(parsed.data)) throw new require_registry.RedactedScoringError("read-aloud");
1176
+ const item = parsed.data;
1177
+ const checkedResponse = readResponseRecording(response);
1178
+ if (checkedResponse === void 0) throw new TypeError("gradeReadAloud needs a read-aloud response: { type: \"read-aloud\", recording: null } for a blank, or a recording with a non-empty `key` and a `mimeType`.");
1179
+ const recording = checkedResponse.recording;
1180
+ if (recording === null) {
1181
+ const passed = computePassThreshold(item, 0, rounding);
1182
+ return {
1183
+ score: 0,
1184
+ maxScore: 1,
1185
+ passed,
1186
+ feedback: selectFeedback(item, passed),
1187
+ criteria: [],
1188
+ details: referenceWordsOf(item).map(({ itemId, word }) => ({
1189
+ itemId,
1190
+ outcome: "incorrect-omission",
1191
+ learnerResponse: "",
1192
+ correctResponse: word,
1193
+ weight: 1,
1194
+ score: 0
1195
+ }))
1196
+ };
1197
+ }
1198
+ if (assessment === null || assessment === void 0) throw new TypeError("gradeReadAloud needs a speech assessment for a response that carries a recording. Pass one, or grade the blank (recording: null).");
1199
+ const measured = options.measured;
1200
+ if (measured === null || measured === void 0) throw new TypeError("gradeReadAloud needs `options.measured`, the server's own measurement of the recording (inspectWav), for a response that carries a recording. A duration the client reported is not evidence.");
1201
+ const { durationMs, voicedMs } = measured;
1202
+ if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0 || typeof voicedMs !== "number" || !Number.isFinite(voicedMs) || voicedMs < 0 || voicedMs > durationMs + MEASUREMENT_TOLERANCE) throw new RangeError(`Invalid recording measurement (durationMs ${describeValue$1(durationMs)}, voicedMs ${describeValue$1(voicedMs)}): expected finite millisecond counts, 0 or above, with voicedMs inside durationMs.`);
1203
+ const unscorable = (code, reason) => ({
1204
+ unscorable: true,
1205
+ code,
1206
+ reason
1207
+ });
1208
+ const checked = validateSpeechAssessment(assessment);
1209
+ if (!checked.success) return unscorable("invalid_assessment", `The speech assessment is not well formed (${describeValidationError(checked.errors[0])}).`);
1210
+ const evidence = checked.data;
1211
+ if (evidence.task !== "scripted") return unscorable("task_mismatch", `The assessment was made for a "${evidence.task}" task; a read-aloud grade needs one made against the item's text.`);
1212
+ if (evidence.locale !== item.locale) return unscorable("locale_mismatch", `The assessment was made for locale "${evidence.locale}", and the item is read in "${item.locale}". Pronunciation is assessed against one locale's speech.`);
1213
+ if (evidence.referenceText !== item.referenceText) return unscorable("reference_mismatch", "The assessment was made against a different text from the one this item asks the learner to read.");
1214
+ if (evidence.recordingKey !== recording.key) return unscorable("recording_mismatch", `The assessment was made for recording "${String(evidence.recordingKey)}", and this response submitted "${recording.key}".`);
1215
+ if (evidence.assessor.kind === "ai" && options.allowAiAssessor !== true) return unscorable("assessor_not_accepted", "The assessment was produced by a generative model. Pass `allowAiAssessor: true` to accept that as measurement evidence.");
1216
+ if (evidence.status === "no_speech") return unscorable("no_speech", "The assessor heard no speech in this recording.");
1217
+ if (voicedMs < minVoicedMs) return unscorable("insufficient_voiced_time", `The recording holds ${voicedMs} ms of voiced time, and the policy asks for at least ${minVoicedMs} ms.`);
1218
+ const words = spokenWordCount(evidence);
1219
+ if (words > 0 && (voicedMs === 0 || words / (voicedMs / 1e3) > maxWordsPerSecond)) return unscorable("implausible_speech_rate", `The assessment claims ${words} word(s) in ${voicedMs} ms of voiced time, above the ${maxWordsPerSecond} word(s) per second the policy allows.`);
1220
+ const weighed = item.scoring.dimensions.filter((dimension) => dimension.weight > 0);
1221
+ const missing = weighed.find((dimension) => evidence.scores[dimension.name] === void 0);
1222
+ if (missing !== void 0) return unscorable("missing_dimension", `The item weighs "${missing.name}", and the assessment carries no ${missing.name} score. An absent score is not a 0.`);
1223
+ const graded = gradeFromRubric(weighed.map((dimension) => ({
1224
+ name: dimension.name,
1225
+ score: evidence.scores[dimension.name],
1226
+ maxScore: ASSESSMENT_SCALE,
1227
+ weight: dimension.weight
1228
+ })), item, rounding !== void 0 ? { rounding } : {});
1229
+ if ("unscorable" in graded) return unscorable("invalid_assessment", `No weighted total could be computed from the assessment's scores: ${graded.reason}`);
1230
+ const details = [];
1231
+ for (const entry of alignValidated(item, evidence)) {
1232
+ if (entry.itemId === void 0) continue;
1233
+ details.push({
1234
+ itemId: entry.itemId,
1235
+ outcome: entry.state === "correct" ? "correct" : entry.state === "mispronounced" ? "incorrect" : "incorrect-omission",
1236
+ learnerResponse: entry.heard,
1237
+ correctResponse: entry.reference,
1238
+ weight: 1,
1239
+ ...scoreOf(entry)
1240
+ });
1241
+ }
1242
+ return {
1243
+ ...graded,
1244
+ feedback: selectFeedback(item, graded.passed),
1245
+ details,
1246
+ grader: { ...evidence.assessor }
1247
+ };
1248
+ }
1249
+ /** The item's reference words, as `dictationReferenceWords` numbers them. */
1250
+ function referenceWordsOf(data) {
1251
+ return require_registry.dictationReferenceWords({ transcript: data.referenceText })[0];
1252
+ }
1253
+ //#endregion
1254
+ //#region src/scoring/speech/wav.ts
1255
+ /** `RIFF` + the file size + `WAVE`, before the first chunk. */
1256
+ const HEADER_BYTES = 12;
1257
+ /** A chunk's four-character id and its little-endian size. */
1258
+ const CHUNK_HEADER_BYTES = 8;
1259
+ /** `audioFormat`, `channels`, `sampleRate`, `byteRate`, `blockAlign`, `bitsPerSample`. */
1260
+ const FMT_MIN_BYTES = 16;
1261
+ /** WAVE_FORMAT_EXTENSIBLE carries a `SubFormat` GUID; the chunk is this long with it. */
1262
+ const EXTENSIBLE_BYTES = 40;
1263
+ /** Where the `SubFormat` GUID starts inside the `fmt ` body. Its first two bytes are the real format. */
1264
+ const SUBFORMAT_OFFSET = 24;
1265
+ /** Uncompressed PCM, as `audioFormat` and as the first two bytes of a `SubFormat`. */
1266
+ const FORMAT_PCM = 1;
1267
+ /** WAVE_FORMAT_EXTENSIBLE: the real format is in the `SubFormat` GUID. */
1268
+ const FORMAT_EXTENSIBLE = 65534;
1269
+ /** The only sample width read. */
1270
+ const BITS_PER_SAMPLE = 16;
1271
+ const BYTES_PER_SAMPLE = 2;
1272
+ /** Full scale for a 16-bit sample: `-32768` is the loudest a sample can be. */
1273
+ const FULL_SCALE = 32768;
1274
+ /** A value named in an error message, without serialising the object it came from. */
1275
+ function describeValue(value) {
1276
+ return typeof value === "number" ? String(value) : `of type ${typeof value}`;
1277
+ }
1278
+ /** The four-character chunk id at `offset`. */
1279
+ function tagAt(view, offset) {
1280
+ return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
1281
+ }
1282
+ /**
1283
+ * The `fmt ` chunk when it describes 16-bit PCM this reader can measure, and
1284
+ * `undefined` for anything else — a compressed stream, 8- or 24-bit samples, a
1285
+ * channel count or sample rate of 0, a `blockAlign` that disagrees with them,
1286
+ * or a chunk too short to say.
1287
+ */
1288
+ function formatAt(view, body, size) {
1289
+ if (size < FMT_MIN_BYTES || body + FMT_MIN_BYTES > view.byteLength) return;
1290
+ const audioFormat = view.getUint16(body, true);
1291
+ const channels = view.getUint16(body + 2, true);
1292
+ const sampleRate = view.getUint32(body + 4, true);
1293
+ const blockAlign = view.getUint16(body + 12, true);
1294
+ const bitsPerSample = view.getUint16(body + 14, true);
1295
+ if (!(audioFormat === FORMAT_PCM || audioFormat === FORMAT_EXTENSIBLE && size >= EXTENSIBLE_BYTES && body + EXTENSIBLE_BYTES <= view.byteLength && view.getUint16(body + SUBFORMAT_OFFSET, true) === FORMAT_PCM) || bitsPerSample !== BITS_PER_SAMPLE || channels < 1 || sampleRate < 1 || blockAlign !== channels * BYTES_PER_SAMPLE) return;
1296
+ return {
1297
+ channels,
1298
+ sampleRate,
1299
+ blockAlign
1300
+ };
1301
+ }
1302
+ /**
1303
+ * Reads a 16-bit PCM WAV and measures it: `durationMs`, `sampleRate`,
1304
+ * `channels`, the loudest sample as `peakDbfs`, and `voicedMs`, the time in
1305
+ * windows whose RMS level reaches the policy's silence floor.
1306
+ *
1307
+ * Anything it cannot measure comes back as `{ valid: false, reason }` rather
1308
+ * than a guess: `not_wav` for bytes that are not a RIFF/WAVE file, or whose
1309
+ * chunks do not describe the audio before the audio arrives;
1310
+ * `unsupported_encoding` for a WAV this reader does not read (compressed, or
1311
+ * samples that are not 16-bit); `truncated` when the data chunk is missing or
1312
+ * shorter than it declares.
1313
+ *
1314
+ * Both policy fields are required, because neither has an answer that is right
1315
+ * for every microphone: `silenceDbfs` is where an application draws the line
1316
+ * between silence and speech, and `frameMs` is how finely it looks.
1317
+ *
1318
+ * Pure and deterministic. It never throws on the bytes themselves — only on
1319
+ * arguments it cannot use.
1320
+ *
1321
+ * @throws TypeError when `bytes` is not a `Uint8Array`.
1322
+ * @throws RangeError when `silenceDbfs` is not a finite number at or below 0,
1323
+ * or `frameMs` is not a finite number above 0.
1324
+ */
1325
+ function inspectWav(bytes, policy) {
1326
+ if (!(bytes instanceof Uint8Array)) throw new TypeError("inspectWav reads a recording's bytes: pass a Uint8Array of the WAV file.");
1327
+ const silenceDbfs = policy?.silenceDbfs;
1328
+ if (typeof silenceDbfs !== "number" || !Number.isFinite(silenceDbfs) || silenceDbfs > 0) throw new RangeError(`Invalid inspection policy (silenceDbfs ${describeValue(silenceDbfs)}): expected a finite number of dBFS at or below 0, full scale being 0.`);
1329
+ const frameMs = policy?.frameMs;
1330
+ if (typeof frameMs !== "number" || !Number.isFinite(frameMs) || frameMs <= 0) throw new RangeError(`Invalid inspection policy (frameMs ${describeValue(frameMs)}): expected a finite window length in milliseconds, above 0.`);
1331
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1332
+ if (view.byteLength < HEADER_BYTES || tagAt(view, 0) !== "RIFF" || tagAt(view, 8) !== "WAVE") return {
1333
+ valid: false,
1334
+ reason: "not_wav"
1335
+ };
1336
+ let format;
1337
+ let data;
1338
+ let offset = HEADER_BYTES;
1339
+ while (offset + CHUNK_HEADER_BYTES <= view.byteLength) {
1340
+ const id = tagAt(view, offset);
1341
+ const size = view.getUint32(offset + 4, true);
1342
+ const body = offset + CHUNK_HEADER_BYTES;
1343
+ if (id === "fmt ") {
1344
+ format = formatAt(view, body, size);
1345
+ if (format === void 0) return {
1346
+ valid: false,
1347
+ reason: "unsupported_encoding"
1348
+ };
1349
+ } else if (id === "data") {
1350
+ if (format === void 0) return {
1351
+ valid: false,
1352
+ reason: "not_wav"
1353
+ };
1354
+ data = {
1355
+ offset: body,
1356
+ declared: size
1357
+ };
1358
+ break;
1359
+ }
1360
+ offset = body + size + size % 2;
1361
+ }
1362
+ if (format === void 0) return {
1363
+ valid: false,
1364
+ reason: "not_wav"
1365
+ };
1366
+ if (data === void 0 || data.declared > view.byteLength - data.offset) return {
1367
+ valid: false,
1368
+ reason: "truncated"
1369
+ };
1370
+ const { channels, sampleRate, blockAlign } = format;
1371
+ const frames = Math.floor(data.declared / blockAlign);
1372
+ const samples = frames * channels;
1373
+ let peak = 0;
1374
+ for (let index = 0; index < samples; index += 1) {
1375
+ const magnitude = Math.abs(view.getInt16(data.offset + index * BYTES_PER_SAMPLE, true));
1376
+ if (magnitude > peak) peak = magnitude;
1377
+ }
1378
+ const windowFrames = Math.max(1, Math.round(sampleRate * frameMs / 1e3));
1379
+ let voicedMs = 0;
1380
+ for (let start = 0; start < frames; start += windowFrames) {
1381
+ const windowLength = Math.min(windowFrames, frames - start);
1382
+ const windowSamples = windowLength * channels;
1383
+ let sum = 0;
1384
+ for (let index = 0; index < windowSamples; index += 1) {
1385
+ const sample = view.getInt16(data.offset + (start * channels + index) * BYTES_PER_SAMPLE, true) / FULL_SCALE;
1386
+ sum += sample * sample;
1387
+ }
1388
+ const rms = Math.sqrt(sum / windowSamples);
1389
+ if ((rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY) >= silenceDbfs) voicedMs += windowLength * 1e3 / sampleRate;
1390
+ }
1391
+ const durationMs = frames * 1e3 / sampleRate;
1392
+ return {
1393
+ valid: true,
1394
+ durationMs,
1395
+ sampleRate,
1396
+ channels,
1397
+ bitsPerSample: BITS_PER_SAMPLE,
1398
+ peakDbfs: peak === 0 ? Number.NEGATIVE_INFINITY : 20 * Math.log10(peak / FULL_SCALE),
1399
+ voicedMs: Math.min(voicedMs, durationMs)
1400
+ };
1401
+ }
1402
+ //#endregion
1403
+ //#region src/scoring/unscorable.ts
1404
+ /**
1405
+ * Lifts a result that cannot be graded — a `SpeechUnscorable` from
1406
+ * `gradeReadAloud`, or anything else carrying a `code` and a `reason` — into
1407
+ * the `unscorable` arm of {@link ItemOutcome}, keeping its code.
1408
+ *
1409
+ * The code is what an application branches on; the reason is a developer-facing
1410
+ * sentence. Stored this way, the result composes exactly as the unscorable
1411
+ * outcomes `evaluate` returns do. `evaluate` never writes a code itself.
1412
+ */
1413
+ function outcomeFromUnscorable(result) {
1414
+ return {
1415
+ status: "unscorable",
1416
+ reason: result.reason,
1417
+ maxScore: 1,
1418
+ code: result.code
1419
+ };
1420
+ }
1421
+ //#endregion
1422
+ //#region src/scoring/index.ts
1423
+ /**
1424
+ * Scores a learner response against activity data and returns a full
1425
+ * {@link ScoringResult}.
1426
+ *
1427
+ * Pure and deterministic with no side effects. It does **not** re-validate
1428
+ * `activityData` — schema validation is the component boundary's
1429
+ * responsibility; this is a low-level scoring primitive that trusts its typed
1430
+ * inputs. Dispatch is registry-backed: consumer-registered types with `sync`
1431
+ * scoring work here too. An unregistered type throws
1432
+ * {@link UnknownActivityTypeError}; a type whose grading is deferred (e.g.
1433
+ * `written-response`) throws {@link DeferredScoringError} — use
1434
+ * {@link evaluate}, which can express "not graded yet".
1435
+ */
1436
+ function score(activityType, activityData, learnerResponse, options) {
1437
+ const rounding = roundingPolicyOf(options?.rounding);
1438
+ const descriptor = require_registry.getActivityTypeDescriptor(activityType);
1439
+ if (descriptor === void 0) throw new require_registry.UnknownActivityTypeError(String(activityType));
1440
+ if (descriptor.scoring.kind === "deferred") throw new require_registry.DeferredScoringError(descriptor.type);
1441
+ if (isRedacted(activityData)) throw new require_registry.RedactedScoringError(descriptor.type);
1442
+ const result = descriptor.scoring.score(activityData, learnerResponse);
1443
+ if (!Number.isFinite(result.score)) throw new require_registry.RedactedScoringError(descriptor.type);
1444
+ const passed = computePassThreshold(activityData, result.score, rounding);
1445
+ return {
1446
+ ...result,
1447
+ passed,
1448
+ feedback: result.feedback ?? selectFeedback(activityData, passed)
1449
+ };
1450
+ }
1451
+ /**
1452
+ * Evaluates a learner response against an activity and returns an
1453
+ * {@link ItemOutcome} — the resilient, forward-compatible alternative to
1454
+ * {@link score}:
1455
+ *
1456
+ * - Synchronously graded types return `{ status: 'scored', ... }` with the
1457
+ * same numbers `score()` produces.
1458
+ * - Asynchronously graded types (e.g. `written-response`) return
1459
+ * `{ status: 'deferred', reason, partial }` instead of a fake zero — "not
1460
+ * graded yet" is expressible in the type system, never conflated with
1461
+ * "wrong".
1462
+ * - An unregistered `data.type` returns `{ status: 'unscorable' }` rather
1463
+ * than throwing, so a mixed-version content bank cannot crash an exam run.
1464
+ * - With a scoring policy (`options.scoring`), a scored outcome is charged what
1465
+ * the response's hints cost, and `passed` is read from what is left. The
1466
+ * `details` and authored `feedback` stay the answer's. For several tries at
1467
+ * one question, use {@link evaluateTries}.
1468
+ *
1469
+ * The activity type is read from `data.type` — there is no separate type
1470
+ * parameter to disagree with the payload.
1471
+ */
1472
+ function evaluate(data, response, options) {
1473
+ const rounding = roundingPolicyOf(options?.rounding);
1474
+ const scoring = options?.scoring;
1475
+ const rules = resolveItemScoringPolicy(scoring);
1476
+ const outcome = evaluateAnswer(data, response, rounding);
1477
+ if (scoring === void 0 || scoring === null || outcome.status !== "scored") return outcome;
1478
+ return costed(data, outcome, [{
1479
+ response,
1480
+ outcome
1481
+ }], rules, rounding).outcome;
1482
+ }
1483
+ /** What `evaluate` does with no policy: the answer's own outcome. */
1484
+ function evaluateAnswer(data, response, rounding) {
1485
+ const type = data.type;
1486
+ const descriptor = typeof type === "string" ? require_registry.getActivityTypeDescriptor(type) : void 0;
1487
+ if (descriptor === void 0) return {
1488
+ status: "unscorable",
1489
+ reason: `Activity type "${String(type)}" is not registered`,
1490
+ maxScore: 1
1491
+ };
1492
+ if (descriptor.scoring.kind === "deferred") {
1493
+ const partial = descriptor.scoring.partial?.(data, response);
1494
+ return {
1495
+ status: "deferred",
1496
+ reason: descriptor.scoring.reason,
1497
+ maxScore: 1,
1498
+ ...partial !== void 0 ? { partial } : {}
1499
+ };
1500
+ }
1501
+ if (isRedacted(data)) return {
1502
+ status: "unscorable",
1503
+ reason: "Activity data is redacted (no answer key), so it cannot be scored on the client. Score against the full data server-side.",
1504
+ maxScore: 1
1505
+ };
1506
+ const result = descriptor.scoring.score(data, response);
1507
+ if (!Number.isFinite(result.score)) return {
1508
+ status: "unscorable",
1509
+ reason: `Scoring "${descriptor.type}" produced a non-finite score; the activity data is incomplete.`,
1510
+ maxScore: result.maxScore
1511
+ };
1512
+ const passed = computePassThreshold(data, result.score, rounding);
1513
+ return {
1514
+ status: "scored",
1515
+ score: result.score,
1516
+ maxScore: result.maxScore,
1517
+ passed,
1518
+ feedback: result.feedback ?? selectFeedback(data, passed),
1519
+ details: result.details
1520
+ };
1521
+ }
1522
+ /**
1523
+ * A question's outcome from every try a learner made at it, in order, under a
1524
+ * paper's scoring policy: what a server that scores practice itself calls,
1525
+ * with the tries it stored, to reach the grade the SDK's components reached.
1526
+ *
1527
+ * Each response is scored as {@link evaluate} scores it, and costed and chosen
1528
+ * as {@link scoreTries} does: hints from each response's `hintsRevealed`,
1529
+ * which counts from the start of the question; the tries before each one;
1530
+ * and the try `counts` names. Only the first `1 + retries` responses are read.
1531
+ *
1532
+ * With no policy it is `evaluate` of the first response: one try, as before.
1533
+ * With no response at all, the question was never answered —
1534
+ * `{ status: 'deferred', reason: 'no_response_recorded' }`, as a missing slot
1535
+ * is everywhere else.
1536
+ */
1537
+ function evaluateTries(data, responses, options) {
1538
+ const rounding = roundingPolicyOf(options?.rounding);
1539
+ const rules = resolveItemScoringPolicy(options?.scoring);
1540
+ if (responses.length === 0) return {
1541
+ outcome: {
1542
+ status: "deferred",
1543
+ reason: "no_response_recorded",
1544
+ maxScore: 1
1545
+ },
1546
+ counted: null,
1547
+ tries: []
1548
+ };
1549
+ const graded = responses.slice(0, 1 + rules.retries).map((response) => ({
1550
+ response,
1551
+ outcome: evaluateAnswer(data, response, rounding)
1552
+ }));
1553
+ const opening = graded[0].outcome;
1554
+ if (opening.status !== "scored") return {
1555
+ outcome: opening,
1556
+ counted: null,
1557
+ tries: []
1558
+ };
1559
+ if ((options?.scoring === void 0 || options.scoring === null) && !isGradeInRange(opening.score, opening.maxScore)) return {
1560
+ outcome: opening,
1561
+ counted: 0,
1562
+ tries: []
1563
+ };
1564
+ return costed(data, opening, graded, rules, rounding);
1565
+ }
1566
+ /**
1567
+ * Charges each try what it cost and picks the one that counts. A try whose
1568
+ * numbers cannot be a grade — a registered scorer out of contract — has
1569
+ * nothing to charge a cost to, and leaves the question unscorable rather than
1570
+ * scored on the other tries.
1571
+ */
1572
+ function costed(data, opening, graded, rules, rounding) {
1573
+ const scored = [];
1574
+ for (const [index, one] of graded.entries()) {
1575
+ if (one.outcome.status !== "scored" || !isGradeInRange(one.outcome.score, one.outcome.maxScore)) return {
1576
+ outcome: {
1577
+ status: "unscorable",
1578
+ reason: `Try ${index + 1} has no grade to charge a cost to, so the question has none under this scoring policy.`,
1579
+ maxScore: opening.maxScore
1580
+ },
1581
+ counted: null,
1582
+ tries: []
1583
+ };
1584
+ scored.push(one.outcome);
1585
+ }
1586
+ const result = scoreTries(scored.map((outcome, index) => ({
1587
+ score: outcome.score,
1588
+ maxScore: outcome.maxScore,
1589
+ hintsRevealed: (graded[index]?.response)?.hintsRevealed
1590
+ })), rules);
1591
+ const chosen = scored[result.counted];
1592
+ return {
1593
+ outcome: result.score === chosen.score ? chosen : {
1594
+ ...chosen,
1595
+ score: result.score,
1596
+ passed: computePassThreshold(data, result.score, rounding)
1597
+ },
1598
+ counted: result.counted,
1599
+ tries: result.tries
1600
+ };
1601
+ }
1602
+ //#endregion
1603
+ Object.defineProperty(exports, "DEFAULT_ITEM_SCORING_POLICY", {
1604
+ enumerable: true,
1605
+ get: function() {
1606
+ return DEFAULT_ITEM_SCORING_POLICY;
1607
+ }
1608
+ });
1609
+ Object.defineProperty(exports, "DEFAULT_PASS_THRESHOLD", {
1610
+ enumerable: true,
1611
+ get: function() {
1612
+ return DEFAULT_PASS_THRESHOLD;
1613
+ }
1614
+ });
1615
+ Object.defineProperty(exports, "ITEM_SCORING_MAX_RETRIES", {
1616
+ enumerable: true,
1617
+ get: function() {
1618
+ return ITEM_SCORING_MAX_RETRIES;
1619
+ }
1620
+ });
1621
+ Object.defineProperty(exports, "alignReadAloud", {
1622
+ enumerable: true,
1623
+ get: function() {
1624
+ return alignReadAloud;
1625
+ }
1626
+ });
1627
+ Object.defineProperty(exports, "classifyBand", {
1628
+ enumerable: true,
1629
+ get: function() {
1630
+ return classifyBand;
1631
+ }
1632
+ });
1633
+ Object.defineProperty(exports, "composeAssessmentScore", {
1634
+ enumerable: true,
1635
+ get: function() {
1636
+ return composeAssessmentScore;
1637
+ }
1638
+ });
1639
+ Object.defineProperty(exports, "computePassThreshold", {
1640
+ enumerable: true,
1641
+ get: function() {
1642
+ return computePassThreshold;
1643
+ }
1644
+ });
1645
+ Object.defineProperty(exports, "diffDictationChars", {
1646
+ enumerable: true,
1647
+ get: function() {
1648
+ return diffDictationChars;
1649
+ }
1650
+ });
1651
+ Object.defineProperty(exports, "evaluate", {
1652
+ enumerable: true,
1653
+ get: function() {
1654
+ return evaluate;
1655
+ }
1656
+ });
1657
+ Object.defineProperty(exports, "evaluateTries", {
1658
+ enumerable: true,
1659
+ get: function() {
1660
+ return evaluateTries;
1661
+ }
1662
+ });
1663
+ Object.defineProperty(exports, "gradeFromRubric", {
1664
+ enumerable: true,
1665
+ get: function() {
1666
+ return gradeFromRubric;
1667
+ }
1668
+ });
1669
+ Object.defineProperty(exports, "gradeReadAloud", {
1670
+ enumerable: true,
1671
+ get: function() {
1672
+ return gradeReadAloud;
1673
+ }
1674
+ });
1675
+ Object.defineProperty(exports, "gte", {
1676
+ enumerable: true,
1677
+ get: function() {
1678
+ return gte;
1679
+ }
1680
+ });
1681
+ Object.defineProperty(exports, "hasGrade", {
1682
+ enumerable: true,
1683
+ get: function() {
1684
+ return hasGrade;
1685
+ }
1686
+ });
1687
+ Object.defineProperty(exports, "inspectWav", {
1688
+ enumerable: true,
1689
+ get: function() {
1690
+ return inspectWav;
1691
+ }
1692
+ });
1693
+ Object.defineProperty(exports, "isGradeInRange", {
1694
+ enumerable: true,
1695
+ get: function() {
1696
+ return isGradeInRange;
1697
+ }
1698
+ });
1699
+ Object.defineProperty(exports, "outcomeFromGrade", {
1700
+ enumerable: true,
1701
+ get: function() {
1702
+ return outcomeFromGrade;
1703
+ }
1704
+ });
1705
+ Object.defineProperty(exports, "outcomeFromUnscorable", {
1706
+ enumerable: true,
1707
+ get: function() {
1708
+ return outcomeFromUnscorable;
1709
+ }
1710
+ });
1711
+ Object.defineProperty(exports, "resolveItemScoringPolicy", {
1712
+ enumerable: true,
1713
+ get: function() {
1714
+ return resolveItemScoringPolicy;
1715
+ }
1716
+ });
1717
+ Object.defineProperty(exports, "roundGrade", {
1718
+ enumerable: true,
1719
+ get: function() {
1720
+ return roundGrade;
1721
+ }
1722
+ });
1723
+ Object.defineProperty(exports, "score", {
1724
+ enumerable: true,
1725
+ get: function() {
1726
+ return score;
1727
+ }
1728
+ });
1729
+ Object.defineProperty(exports, "scoreTries", {
1730
+ enumerable: true,
1731
+ get: function() {
1732
+ return scoreTries;
1733
+ }
1734
+ });
1735
+ Object.defineProperty(exports, "validateItemScoringPolicy", {
1736
+ enumerable: true,
1737
+ get: function() {
1738
+ return validateItemScoringPolicy;
1739
+ }
1740
+ });
1741
+ Object.defineProperty(exports, "validateSpeechAssessment", {
1742
+ enumerable: true,
1743
+ get: function() {
1744
+ return validateSpeechAssessment;
1745
+ }
1746
+ });
1747
+
1748
+ //# sourceMappingURL=scoring-B2_HEhMT.cjs.map