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