@intellectif/lk-core 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/dist/{activity-DfAmJ1sl.d.cts → activity-BVkg9VDo.d.cts} +153 -4
- package/dist/{activity-DfAmJ1sl.d.ts → activity-BVkg9VDo.d.ts} +153 -4
- package/dist/{chunk-DQKJUMUW.cjs → chunk-2NIALQEH.cjs} +24 -19
- package/dist/chunk-2NIALQEH.cjs.map +1 -0
- package/dist/{chunk-XKKKWKRH.js → chunk-6RND47QE.js} +694 -126
- package/dist/chunk-6RND47QE.js.map +1 -0
- package/dist/{chunk-FT7SAC3X.cjs → chunk-CZRUMHKW.cjs} +4 -4
- package/dist/{chunk-FT7SAC3X.cjs.map → chunk-CZRUMHKW.cjs.map} +1 -1
- package/dist/chunk-J5OIRIOW.cjs +1034 -0
- package/dist/chunk-J5OIRIOW.cjs.map +1 -0
- package/dist/{chunk-Y36S3X3L.js → chunk-NBADQ3JU.js} +7 -2
- package/dist/chunk-NBADQ3JU.js.map +1 -0
- package/dist/{chunk-PYP3HCRQ.js → chunk-R7PV3XIC.js} +2 -2
- package/dist/chunk-RTIR6R2U.js +1034 -0
- package/dist/chunk-RTIR6R2U.js.map +1 -0
- package/dist/{chunk-XONKWL6D.cjs → chunk-VCXIPLEW.cjs} +585 -17
- package/dist/chunk-VCXIPLEW.cjs.map +1 -0
- package/dist/{index-Bz9wUigA.d.cts → index-BE1bM2Wi.d.cts} +225 -3
- package/dist/{index-CJbbVE9o.d.ts → index-v2_-Fyn3.d.ts} +225 -3
- package/dist/index.cjs +113 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -17
- package/dist/index.d.ts +50 -17
- package/dist/index.js +39 -6
- package/dist/index.js.map +1 -1
- package/dist/schemas.cjs +11 -3
- package/dist/schemas.cjs.map +1 -1
- package/dist/schemas.d.cts +2 -2
- package/dist/schemas.d.ts +2 -2
- package/dist/schemas.js +10 -2
- package/dist/scoring.cjs +23 -3
- package/dist/scoring.cjs.map +1 -1
- package/dist/scoring.d.cts +471 -16
- package/dist/scoring.d.ts +471 -16
- package/dist/scoring.js +24 -4
- package/dist/xapi.cjs +3 -3
- package/dist/xapi.d.cts +1 -1
- package/dist/xapi.d.ts +1 -1
- package/dist/xapi.js +2 -2
- package/package.json +1 -1
- package/vectors/README.md +63 -3
- package/vectors/replay.d.mts +7 -2
- package/vectors/replay.mjs +83 -1
- package/vectors/scoring.json +10875 -2
- package/dist/chunk-DQKJUMUW.cjs.map +0 -1
- package/dist/chunk-FGN4LLSP.js +0 -387
- package/dist/chunk-FGN4LLSP.js.map +0 -1
- package/dist/chunk-UHQVIC3P.cjs +0 -387
- package/dist/chunk-UHQVIC3P.cjs.map +0 -1
- package/dist/chunk-XKKKWKRH.js.map +0 -1
- package/dist/chunk-XONKWL6D.cjs.map +0 -1
- package/dist/chunk-Y36S3X3L.js.map +0 -1
- /package/dist/{chunk-PYP3HCRQ.js.map → chunk-R7PV3XIC.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/learning-kit/learning-kit/packages/lk-core/dist/chunk-J5OIRIOW.cjs","../src/is-redacted.ts","../src/scoring/rounding.ts","../src/scoring/pass-threshold.ts","../src/grading.ts","../src/scoring/compose.ts","../src/scoring/dictation/chars.ts","../src/scoring/speech/assessment.ts","../src/scoring/speech/tokens.ts","../src/scoring/speech/align.ts","../src/scoring/speech/grade.ts","../src/scoring/speech/wav.ts","../src/scoring/unscorable.ts","../src/scoring/index.ts"],"names":["EPSILON","score"],"mappings":"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","file":"/home/runner/work/learning-kit/learning-kit/packages/lk-core/dist/chunk-J5OIRIOW.cjs","sourcesContent":[null,"/**\n * True when `data` is a `redact()` projection rather than full activity data.\n * Grading a redacted item is always a bug: the answer key is gone by design, so\n * any \"score\" computed from it is meaningless — for a type whose projection\n * drops the key it used to come out `NaN`, and for one whose projection keeps\n * everything the grade reads it comes out as a plausible number with the\n * authored feedback silently missing.\n *\n * Internal, and its own module so that every grading entry point can ask the\n * same question: `score()` and `evaluate()` in `scoring/index.ts`, and\n * `gradeReadAloud` under `scoring/speech/`, which must not import the barrel\n * it is itself exported from.\n */\nexport function isRedacted(data: unknown): boolean {\n return (\n typeof data === 'object' && data !== null && (data as { redacted?: unknown }).redacted === true\n );\n}\n","/**\n * Rounding for assessment scores.\n *\n * Rounding is **two different operations** that must not share one policy, and\n * conflating them silently inverts one of them:\n *\n * - **Grade rounding** decides the number a learner is shown and recorded\n * against. It rounds to a fixed number of decimal places, conventionally\n * half-up, so a learner shown \"70%\" is not recorded as a fail at 69.6.\n * - **Band / level classification** decides which level someone is placed in.\n * It deliberately **floors**: placing a learner above their real level is\n * the more harmful error, so a band boundary must not be reached by\n * rounding up.\n *\n * There is therefore no default `RoundingPolicy` anywhere in this SDK, and\n * `dp` has no default either — an integrator's deliberate choice must never be\n * supplied by us.\n */\n\n/** How a value is rounded to `dp` decimal places. */\nexport type RoundingMode = 'half-up' | 'half-even' | 'floor' | 'ceil';\n\n/** A rounding policy. Both fields are required — the SDK never guesses either. */\nexport interface RoundingPolicy {\n mode: RoundingMode;\n /** Decimal places. Load-bearing: `dp: 2` is what stops 69.6 becoming a fail at 70. */\n dp: number;\n}\n\nconst ROUNDING_MODES: readonly unknown[] = ['half-up', 'half-even', 'floor', 'ceil'];\n/** The most decimal places a policy may round to: a scaled grade stays an integer a double holds exactly. */\nconst MAX_ROUNDING_DP = 15;\n\n/**\n * The rounding policy `value` holds, or `undefined` for none (`undefined` or\n * `null`). A malformed one would reach {@link gte} as `10 ** undefined` and\n * turn a perfect score into a fail with no error, so it throws here instead.\n *\n * Internal. Every function that takes a `rounding` option checks it here, so\n * each refuses the same policies with the same message; `scoring/index.ts`\n * does not re-export it.\n */\nexport function roundingPolicyOf(value: unknown): RoundingPolicy | undefined {\n if (value === undefined || value === null) {\n return undefined;\n }\n const { mode, dp } = value as { mode?: unknown; dp?: unknown };\n if (\n !ROUNDING_MODES.includes(mode) ||\n typeof dp !== 'number' ||\n !Number.isInteger(dp) ||\n dp < 0 ||\n dp > MAX_ROUNDING_DP\n ) {\n // Described field by field, never serialised: a policy holding a BigInt or\n // a reference to itself would make the message throw a TypeError first.\n const describe = (field: unknown): string =>\n typeof field === 'string'\n ? JSON.stringify(field)\n : typeof field === 'number' || field === undefined\n ? String(field)\n : typeof field === 'object'\n ? 'an object'\n : `a ${typeof field}`;\n throw new RangeError(\n `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} }.`,\n );\n }\n // The values just checked, not the object they came from: an accessor could\n // answer differently when the comparison reads it again.\n return { mode, dp } as RoundingPolicy;\n}\n\n/**\n * Float-noise guard. Binary floating point makes `1.005 * 100` come out as\n * `100.49999999999999` and `0.29 * 100` come out as `28.999999999999996`;\n * without an allowance both would move a learner's grade a step. It is applied\n * in the direction each mode needs — see {@link roundGrade}.\n */\nconst EPSILON = 1e-9;\n\n/**\n * Tolerance for deciding \"is this exactly halfway?\" in half-even mode.\n * Deliberately much larger than {@link EPSILON}: if the tie test shared the\n * nudge's tolerance, the nudge would decide its own outcome. Safe for grades,\n * which live on a [0,1] scale where representation error is far below this.\n */\nconst TIE_TOLERANCE = 1e-6;\n\nfunction scaled(value: number, dp: number): number {\n return value * 10 ** dp;\n}\n\n/**\n * Normalises `-0` to `0` without touching anything else. `-0` JSON-serialises\n * as `0` but fails `Object.is(-0, 0)`, which surprises a strict-equality\n * gradebook comparison. Deliberately NOT `|| 0`: that also swallowed `NaN`\n * (reachable with an absurd `dp`, where `10 ** dp` overflows to Infinity) and\n * turned a visible failure into a plausible-looking grade of zero.\n */\nfunction noNegZero(value: number): number {\n return Object.is(value, -0) ? 0 : value;\n}\n\n/**\n * Rounds `value` to `policy.dp` places under `policy.mode`.\n *\n * Idempotent for every mode: rounding an already-rounded value returns it\n * unchanged. That matters because a composed score is rounded once and then\n * compared through {@link gte}, which rounds again.\n */\nexport function roundGrade(value: number, policy: RoundingPolicy): number {\n if (!Number.isFinite(value)) {\n return value;\n }\n const factor = 10 ** policy.dp;\n const raw = scaled(value, policy.dp);\n\n // The nudge must oppose the mode, never point uniformly away from zero.\n // `Math.ceil` on a value that is ALREADY exact at `dp` jumps a whole\n // quantum if you add epsilon first: 0.7 became 0.71, a true 0 became 0.01,\n // and the operation stopped being idempotent (0.7 -> 0.71 -> 0.72). So\n // `ceil` nudges down and `floor` nudges up — just far enough to absorb\n // representation error (0.29 * 100 is 28.999999999999996, which must still\n // floor to 29) without ever moving a value that was already exact.\n // Half modes keep the sign-aware nudge, which is what makes a .5 tie land\n // away from zero.\n switch (policy.mode) {\n case 'floor':\n return noNegZero(Math.floor(raw + EPSILON) / factor);\n case 'ceil':\n return noNegZero(Math.ceil(raw - EPSILON) / factor);\n case 'half-even': {\n // Detect the tie on the RAW value, and with a tolerance decoupled from\n // (and far larger than) the nudge epsilon. Testing the NUDGED value\n // against EPSILON let the nudge itself decide whether a tie was seen:\n // 0.375 was detected but 0.125 was not, so half-even silently degraded\n // to half-up for some exact ties — and only ever in the direction that\n // rounds a learner UP, the opposite of what this mode is chosen for.\n const floored = Math.floor(raw);\n if (Math.abs(raw - floored - 0.5) > TIE_TOLERANCE) {\n return noNegZero(halfAway(raw) / factor);\n }\n // Exactly halfway: pick the even neighbour (banker's rounding).\n const even = floored % 2 === 0 ? floored : floored + 1;\n return noNegZero(even / factor);\n }\n default:\n return noNegZero(halfAway(raw) / factor);\n }\n}\n\n/**\n * Half-up on a scaled value, with the sign-aware float-noise nudge.\n * `Math.round` breaks .5 ties toward +Infinity, so negatives are mirrored to\n * keep the tie breaking away from zero in both directions.\n */\nfunction halfAway(raw: number): number {\n const nudged = raw >= 0 ? raw + EPSILON : raw - EPSILON;\n return raw >= 0 ? Math.round(nudged) : -Math.round(-nudged);\n}\n\n/**\n * Threshold comparison that rounds **both sides** before comparing.\n *\n * Comparing a raw float against a rounded threshold is how a learner ends up\n * shown one number and recorded against another. Rounding both sides — and\n * allowing an epsilon — makes \"what the learner sees\" and \"what the gradebook\n * decides\" the same comparison.\n */\nexport function gte(value: number, threshold: number, policy: RoundingPolicy): boolean {\n return roundGrade(value, policy) >= roundGrade(threshold, policy) - EPSILON;\n}\n\n/** A named band with an inclusive lower bound, e.g. `{ name: 'B1', min: 0.6 }`. */\nexport interface Band {\n name: string;\n /** Inclusive lower bound on the same scale as the value being classified. */\n min: number;\n}\n\n/**\n * Classifies a value into a band by **flooring**: the highest band whose `min`\n * the value actually reaches. Deliberately does NOT round up to a boundary —\n * over-placement is the more harmful error, so a learner just below a boundary\n * stays below it.\n *\n * Returns `null` when the value reaches no band's minimum.\n */\nexport function classifyBand(value: number, bands: readonly Band[]): Band | null {\n let best: Band | null = null;\n for (const band of bands) {\n if (value >= band.min - EPSILON && (best === null || band.min > best.min)) {\n best = band;\n }\n }\n return best;\n}\n","/**\n * The pass line, and the authored feedback it selects.\n *\n * A module of its own so grade-producing code outside the scoring barrel can\n * import it without importing the barrel: `gradeReadAloud`, under\n * `scoring/speech/`, calls `gradeFromRubric`, so if `grading.ts` imported the\n * barrel the barrel would import itself through them. `scoring/index.ts`\n * re-exports the pass line and its default by name; the feedback selector\n * beside them stays internal.\n */\nimport type { ActivityData, ActivityFeedback } from '../types/activity.js';\nimport { gte, type RoundingPolicy } from './rounding.js';\n\n/** Default minimum scaled score required to pass when `passThreshold` is absent. */\nexport const DEFAULT_PASS_THRESHOLD = 0.7;\n\n/**\n * Returns `true` iff `score` meets or exceeds the activity's `passThreshold`,\n * defaulting to {@link DEFAULT_PASS_THRESHOLD} (0.7) when the field is absent.\n *\n * Pass a {@link RoundingPolicy} to compare the way an assessment total is\n * compared — both sides rounded, via {@link gte} — so an item shown as \"70%\"\n * cannot be recorded as a fail at 69.6. It is **opt-in** rather than the\n * default because switching it on changes item-level pass/fail for scores in\n * the rounding band, and this SDK does not alter historical grades without an\n * explicit decision. Absent, the comparison is the exact raw `>=` it has\n * always been.\n */\nexport function computePassThreshold(\n activityData: ActivityData,\n score: number,\n rounding?: RoundingPolicy,\n): boolean {\n const threshold = activityData.passThreshold ?? DEFAULT_PASS_THRESHOLD;\n return rounding === undefined ? score >= threshold : gte(score, threshold, rounding);\n}\n\n/**\n * Selects the authored overall feedback for a result: `feedback.correct` when\n * the learner passed, `feedback.incorrect` otherwise; `null` when no matching\n * message was authored. Mirrors the selection the lk-react components applied\n * (keyed on `passed`, per the `ActivityFeedback` contract).\n */\nexport function selectFeedback(activityData: ActivityData, passed: boolean): string | null {\n const feedback = (activityData as { feedback?: ActivityFeedback }).feedback;\n if (feedback === undefined) {\n return null;\n }\n return (passed ? feedback.correct : feedback.incorrect) ?? null;\n}\n","import { computePassThreshold } from './scoring/pass-threshold.js';\nimport { gte, type RoundingPolicy, roundingPolicyOf } from './scoring/rounding.js';\nimport type { ActivityData, ItemOutcome } from './types/activity.js';\nimport type { CriterionScore, GradeRecord } from './types/grading.js';\n\nexport type {\n CriterionScore,\n GradeRecord,\n Grader,\n GraderKind,\n GraderUsage,\n GradingState,\n InlineCorrection,\n} from './types/grading.js';\n\n/** Options for {@link gradeFromRubric}. */\nexport interface GradeFromRubricOptions {\n /**\n * Scaled score [0,1] required to pass. Defaults to the activity's\n * `passThreshold`, or {@link DEFAULT_PASS_THRESHOLD} when absent.\n */\n passThreshold?: number;\n /** Narrative feedback for the learner, carried onto the record verbatim. */\n feedback?: string | null;\n /**\n * Compare the pass line the way the score is displayed — both sides rounded,\n * via `gte` — whichever threshold applies: `passThreshold` above, the\n * activity's own, or the 0.7 default. The returned `score` stays unrounded.\n * Opt-in, as on `score()` and `evaluate()`: switching it on moves pass/fail\n * for totals in the rounding band.\n *\n * Checked before any criterion is read: `null` reads as no policy, and a\n * policy that cannot be applied — an unknown `mode`, or a `dp` that is not a\n * whole number from 0 to 15 — throws a `RangeError`, even for a rubric that\n * turns out to be unscorable.\n */\n rounding?: RoundingPolicy;\n}\n\n/**\n * Computes the weighted total from per-criterion scores — the arithmetic that\n * must NOT be delegated to a language model.\n *\n * A grader is asked for judgement (how good is this criterion?), not for\n * mental arithmetic. Asking a model to also produce the weighted total makes\n * the final grade unverifiable and unreproducible: two runs can return\n * different totals for identical criterion scores. This function makes the\n * total a pure function of the judgements, so a grade can be recomputed and\n * audited years later.\n *\n * Weights are normalised by their sum, so they need not add to 1. Criteria\n * marked `notApplicable`, and those carrying no numeric `score` (a purely\n * banded judgement), are excluded from both numerator and denominator. When\n * nothing scoreable remains the result is `unscorable`, never a zero.\n *\n * Scores need not be in [0,1]: set `maxScore` on a criterion to declare what\n * its score is out of, and each is normalised before weighting. A grader\n * working out of 100 says so and is done:\n *\n * ```ts\n * gradeFromRubric([\n * { name: 'Task achievement', score: 82, maxScore: 100, weight: 2 },\n * { name: 'Range', score: 7, maxScore: 9, weight: 1 },\n * ]);\n * ```\n *\n * The returned `GradeRecord.score` is always scaled [0,1] against\n * `maxScore: 1`, like every other score in the SDK.\n */\nexport function gradeFromRubric(\n criteria: readonly CriterionScore[],\n activityData?: ActivityData,\n options: GradeFromRubricOptions = {},\n): GradeRecord | { unscorable: true; reason: string } {\n // A malformed rounding policy is the caller's configuration, not this\n // rubric's, so it throws before any criterion is read, as in `score()` and\n // `evaluate()`. Read null-safely: an options object passed as `null` has\n // always reached the unscorable returns below, and still does.\n const rounding = roundingPolicyOf(options?.rounding);\n\n // A corrupt score is an ERROR, not an exclusion. Exclusions (`notApplicable`,\n // and band-only criteria with no numeric score) are deliberate authoring\n // decisions; a NaN or Infinity is a broken grader. Silently dropping it\n // would regrade the learner on fewer criteria — with different effective\n // weights — and nobody would know.\n const corrupt = criteria.find(\n (criterion) =>\n criterion.notApplicable !== true &&\n criterion.score !== undefined &&\n !Number.isFinite(criterion.score),\n );\n if (corrupt !== undefined) {\n return {\n unscorable: true,\n reason: `Criterion \"${corrupt.name}\" has a non-finite score (${String(corrupt.score)}). A grade cannot be computed from it.`,\n };\n }\n\n // A `maxScore` that is absent means 1 (the scaled convention). One that is\n // present must be a positive finite number: dividing by 0, a negative, or a\n // NaN would produce a grade nobody can defend.\n const badMax = criteria.find(\n (criterion) =>\n criterion.notApplicable !== true &&\n criterion.maxScore !== undefined &&\n !(Number.isFinite(criterion.maxScore) && criterion.maxScore > 0),\n );\n if (badMax !== undefined) {\n return {\n unscorable: true,\n reason: `Criterion \"${badMax.name}\" declares maxScore ${String(badMax.maxScore)}; it must be a positive, finite number.`,\n };\n }\n\n const scoreable = criteria.filter(\n (criterion) => criterion.notApplicable !== true && typeof criterion.score === 'number',\n );\n\n /** The criterion's score as a ratio in [0,1]. `maxScore` defaults to 1. */\n const ratioOf = (criterion: CriterionScore): number =>\n (criterion.score as number) / (criterion.maxScore ?? 1);\n\n // A grader that returns raw points (4 out of 5) without declaring\n // `maxScore: 5` would otherwise yield `{ score: 4, maxScore: 1 }` and pass\n // every threshold. Reject rather than clamp: silently rescaling someone's\n // grader is worse than telling them it is out of contract. A hair outside\n // the range is float noise and is clamped further down.\n const EPSILON = 1e-9;\n const outOfRange = scoreable.find(\n (criterion) => ratioOf(criterion) < -EPSILON || ratioOf(criterion) > 1 + EPSILON,\n );\n if (outOfRange !== undefined) {\n return {\n unscorable: true,\n reason:\n `Criterion \"${outOfRange.name}\" has score ${outOfRange.score} out of ${outOfRange.maxScore ?? 1}, ` +\n 'which is outside the [0,1] range once scaled. Set `maxScore` on the criterion to declare what the ' +\n 'score is out of (e.g. `maxScore: 100` for a 0–100 grader).',\n };\n }\n\n if (scoreable.length === 0) {\n return {\n unscorable: true,\n reason: 'No criterion carried a numeric score, so no weighted total can be computed.',\n };\n }\n\n const totalWeight = scoreable.reduce((sum, criterion) => sum + (criterion.weight ?? 1), 0);\n if (!(totalWeight > 0)) {\n return {\n unscorable: true,\n reason:\n 'Criterion weights do not sum to a positive number, so the weighted total is undefined.',\n };\n }\n\n const weighted = scoreable.reduce(\n (sum, criterion) => sum + ratioOf(criterion) * (criterion.weight ?? 1),\n 0,\n );\n const rawScore = weighted / totalWeight;\n if (!Number.isFinite(rawScore)) {\n return {\n unscorable: true,\n reason: 'The weighted total is not a finite number, so no grade can be produced.',\n };\n }\n // Clamp the float-noise band only; anything genuinely out of range was\n // already rejected above.\n const score = Math.min(1, Math.max(0, rawScore));\n\n // Every threshold is compared the same way: the exact raw `>=` it has always\n // been, or both sides rounded when the caller passed a policy. Only the\n // comparison is rounded, never the score this returns.\n const reaches = (threshold: number): boolean =>\n rounding === undefined ? score >= threshold : gte(score, threshold, rounding);\n const passed =\n options.passThreshold !== undefined\n ? reaches(options.passThreshold)\n : activityData !== undefined\n ? computePassThreshold(activityData, score, rounding)\n : reaches(0.7);\n\n return {\n score,\n maxScore: 1,\n passed,\n feedback: options.feedback ?? null,\n criteria: [...criteria],\n };\n}\n\n/**\n * Lifts a {@link GradeRecord} into the `graded` arm of {@link ItemOutcome}, so\n * a grade that arrived asynchronously renders through the same path as a\n * synchronously scored item. The score/passed/feedback fields are mirrored\n * onto the outcome for uniform reads; `grade` carries the full record.\n */\nexport function outcomeFromGrade(grade: GradeRecord): ItemOutcome {\n return {\n status: 'graded',\n grade,\n score: grade.score,\n maxScore: grade.maxScore,\n passed: grade.passed,\n feedback: grade.feedback,\n };\n}\n\n/**\n * True when an outcome carries a real grade — either the SDK scored it\n * synchronously or a grader returned one. Use this instead of testing\n * `status === 'scored'`, which silently misses asynchronously graded work.\n */\nexport function hasGrade(\n outcome: ItemOutcome,\n): outcome is Extract<ItemOutcome, { status: 'scored' | 'graded' }> {\n return outcome.status === 'scored' || outcome.status === 'graded';\n}\n","import { hasGrade } from '../grading.js';\nimport type { ItemOutcome } from '../types/activity.js';\nimport { gte, type RoundingPolicy, roundGrade } from './rounding.js';\n\n/**\n * Sectioned assessment scoring — weights, per-section thresholds, and an\n * explicit reason when an attempt fails.\n *\n * This exists because the formula is invariably implemented twice: once on the\n * server that records the grade, and again on the client that shows a learner\n * their grade breakdown. Two implementations of one formula is exactly the\n * duplication an SDK should remove, and they drift — usually in the scale\n * (0–1 vs 0–100) or in whether a section override is honoured.\n */\n\n/** One item's contribution to a section. */\nexport interface ScoredItem {\n /**\n * Identity of the SLOT, not the activity. The same activity can legitimately\n * appear in two sections; keying on the activity id collapses them into one\n * and silently scores the second occurrence as zero.\n */\n slotId: string;\n /** The activity that filled this slot, for reporting. */\n activityId?: string;\n /** Maximum points this slot is worth. */\n points: number;\n /** What the learner achieved, or why there is no grade yet. */\n outcome: ItemOutcome;\n}\n\n/** A weighted section of an assessment. */\nexport interface AssessmentSectionInput {\n id: string;\n title?: string;\n /** Relative weight. Weights are normalised by their sum, so they need not total 1. */\n weight: number;\n /** Overrides the assessment-wide section threshold for this section only. */\n passThresholdOverride?: number;\n items: ScoredItem[];\n}\n\n/** Policy for {@link composeAssessmentScore}. */\nexport interface CompositionPolicy {\n /** Scaled [0,1] overall score required to pass. */\n passThreshold: number;\n /** Scaled [0,1] score each section must reach, when sections gate the pass. */\n sectionThreshold?: number;\n /**\n * How grades are rounded. Required, with no default: see `RoundingPolicy` —\n * grade rounding and band classification are different operations and the\n * SDK must not choose either for you.\n */\n rounding: RoundingPolicy;\n}\n\n/** Per-section result. */\nexport interface SectionScore {\n id: string;\n title?: string;\n /** The authored weight, verbatim. */\n weight: number;\n /**\n * The weight ACTUALLY used in the total, so a client can rebuild the grade\n * from `sections[]` and agree with the record:\n *\n * ```ts\n * roundGrade(\n * sections.reduce((sum, s) => sum + s.score * s.normalizedWeight, 0),\n * policy.rounding,\n * ) === result.score // exact, by construction\n * ```\n *\n * Apply the same final rounding: the raw weighted sum of already-rounded\n * section scores is not itself a rounded value (0.85 and 1.00 at equal\n * weights sum to 0.925 against a recorded 0.93), so comparing it unrounded\n * is off by up to half a quantum.\n *\n * Sections with nothing graded carry `0` here, because they contribute\n * nothing; the remaining weights are renormalised among themselves.\n */\n normalizedWeight: number;\n earnedPoints: number;\n /** Points that are currently gradable — excludes items still awaiting a grade. */\n gradedMaxPoints: number;\n /** Every point in the section, whether graded yet or not. */\n maxPoints: number;\n /** Scaled [0,1] over the GRADED points, rounded once. */\n score: number;\n passed: boolean;\n /** Threshold this section was judged against, after any override. */\n appliedThreshold: number | null;\n /** Slots still awaiting a grade. */\n pendingSlotIds: string[];\n /** Slots that can never be graded, excluded from the denominator. */\n unscorableSlotIds: string[];\n}\n\n/** Why an attempt failed, or `null` when it passed. */\nexport type PassFailureReason =\n | 'overall_below_threshold'\n | 'section_below_threshold'\n | 'both'\n | null;\n\n/** Result of composing an assessment. */\nexport interface AssessmentScore {\n sections: SectionScore[];\n /** Weighted total, scaled [0,1], rounded once. */\n score: number;\n /**\n * Whether the attempt passed — `null` while `status` is `provisional`,\n * because an attempt with work still ungraded has not passed OR failed yet.\n * Returning `false` there would let a UI keyed on `passed` show a fail for\n * an essay nobody has marked.\n */\n passed: boolean | null;\n /** `null` while provisional, for the same reason as {@link passed}. */\n passFailureReason: PassFailureReason;\n /**\n * `provisional` while any item is still awaiting a grade — the total is\n * computed over what HAS been graded, so it can still move. Do not record a\n * provisional score as final. Items that can NEVER be graded\n * (`unscorableSlotIds`) do not hold the result provisional.\n */\n status: 'final' | 'provisional';\n pendingSlotIds: string[];\n /** Slots that can never be graded. Excluded from the denominator. */\n unscorableSlotIds: string[];\n}\n\n/**\n * What one item contributes. THREE states, not two — collapsing the last two\n * into a single \"no points\" answer is what made an unscorable item block an\n * attempt from ever becoming final:\n *\n * - `graded` — real points, counted.\n * - `pending` — a grade is coming (deferred). Excluded from the denominator,\n * and the assessment stays `provisional` until it arrives.\n * - `unscorable` — a grade is never coming (unregistered type, redacted data,\n * incomplete key). Also excluded from the denominator, but it must NOT keep\n * the result provisional forever: `evaluate()` returns this precisely so a\n * mixed-version content bank does not crash an exam, and an attempt that\n * contains one still has to be recordable.\n */\ntype ItemContribution =\n | { state: 'graded'; points: number }\n | { state: 'pending' }\n | { state: 'unscorable' };\n\nfunction earned(item: ScoredItem): ItemContribution {\n const { outcome } = item;\n if (hasGrade(outcome)) {\n const max = outcome.maxScore > 0 ? outcome.maxScore : 1;\n return { state: 'graded', points: (outcome.score / max) * item.points };\n }\n if (outcome.status === 'deferred') {\n return { state: 'pending' };\n }\n return { state: 'unscorable' };\n}\n\n/**\n * Composes per-item outcomes into a sectioned assessment score.\n *\n * Weights are normalised by their sum. Each section's score is computed over\n * the points that are actually gradable and rounded ONCE, before any threshold\n * comparison, so the number a learner is shown is the number that decides the\n * outcome. Items still awaiting a grade are excluded from the denominator\n * rather than counted as zero, and the result is reported as `provisional`\n * until every item has a grade.\n */\nexport function composeAssessmentScore(\n sections: readonly AssessmentSectionInput[],\n policy: CompositionPolicy,\n): AssessmentScore {\n const pendingAll: string[] = [];\n const unscorableAll: string[] = [];\n\n // Pass 1 — score each section over the points that are actually gradable.\n const partials = sections.map((section) => {\n let earnedPoints = 0;\n let gradedMaxPoints = 0;\n let maxPoints = 0;\n const pendingSlotIds: string[] = [];\n const unscorableSlotIds: string[] = [];\n\n for (const item of section.items) {\n maxPoints += item.points;\n const contribution = earned(item);\n if (contribution.state === 'pending') {\n pendingSlotIds.push(item.slotId);\n pendingAll.push(item.slotId);\n continue;\n }\n if (contribution.state === 'unscorable') {\n unscorableSlotIds.push(item.slotId);\n unscorableAll.push(item.slotId);\n continue;\n }\n earnedPoints += contribution.points;\n gradedMaxPoints += item.points;\n }\n\n const raw = gradedMaxPoints > 0 ? earnedPoints / gradedMaxPoints : 0;\n const appliedThreshold = section.passThresholdOverride ?? policy.sectionThreshold ?? null;\n\n return {\n section,\n earnedPoints,\n gradedMaxPoints,\n maxPoints,\n score: roundGrade(raw, policy.rounding),\n appliedThreshold,\n pendingSlotIds,\n unscorableSlotIds,\n };\n });\n\n // Pass 2 — the LIVE weights. Only sections with something graded contribute,\n // and their weights are renormalised among themselves. A section whose items\n // are all still ungraded must not contribute a zero: that is the \"live\n // denominator\" bug, where a midterm reads 50% only because the essay has not\n // been marked and a learner sees a fail for work nobody has looked at.\n // These are the weights reported on each section, so that\n // `sum(score * normalizedWeight) === result.score` holds and a client\n // rendering the breakdown cannot disagree with the recorded grade.\n const contributingWeight = partials.reduce(\n (sum, partial) => sum + (partial.gradedMaxPoints > 0 ? partial.section.weight : 0),\n 0,\n );\n\n const scored: SectionScore[] = partials.map((partial) => ({\n id: partial.section.id,\n ...(partial.section.title !== undefined ? { title: partial.section.title } : {}),\n weight: partial.section.weight,\n normalizedWeight:\n partial.gradedMaxPoints > 0 && contributingWeight > 0\n ? partial.section.weight / contributingWeight\n : 0,\n earnedPoints: partial.earnedPoints,\n gradedMaxPoints: partial.gradedMaxPoints,\n maxPoints: partial.maxPoints,\n score: partial.score,\n // A section with nothing graded yet cannot be said to have failed.\n passed:\n partial.appliedThreshold === null || partial.gradedMaxPoints === 0\n ? true\n : gte(partial.score, partial.appliedThreshold, policy.rounding),\n appliedThreshold: partial.appliedThreshold,\n pendingSlotIds: partial.pendingSlotIds,\n unscorableSlotIds: partial.unscorableSlotIds,\n }));\n\n const weightedRaw = scored.reduce(\n (sum, section) => sum + section.score * section.normalizedWeight,\n 0,\n );\n const score = roundGrade(weightedRaw, policy.rounding);\n\n // Only work that is still COMING keeps the result provisional. Work that can\n // never be graded is excluded from the denominator but must not block the\n // attempt from being recorded.\n const status: 'final' | 'provisional' = pendingAll.length > 0 ? 'provisional' : 'final';\n\n if (status === 'provisional') {\n // Not passed and not failed: undetermined. Saying `false` here would let a\n // UI keyed on `passed` show a fail for unmarked work.\n return {\n sections: scored,\n score,\n passed: null,\n passFailureReason: null,\n status,\n pendingSlotIds: pendingAll,\n unscorableSlotIds: unscorableAll,\n };\n }\n\n // Nothing was gradable at all — every item unscorable, or an assessment with\n // no items. There is no evidence either way, so there is no verdict: a hard\n // `false` here records a fail at 0% for a learner whose work was never\n // gradable, which is the same fabricated-failure this module exists to\n // avoid, just reached through the other door.\n if (scored.every((section) => section.gradedMaxPoints === 0)) {\n return {\n sections: scored,\n score,\n passed: null,\n passFailureReason: null,\n status,\n pendingSlotIds: pendingAll,\n unscorableSlotIds: unscorableAll,\n };\n }\n\n const overallOk = gte(score, policy.passThreshold, policy.rounding);\n const sectionsOk = scored.every((section) => section.passed);\n const passed = overallOk && sectionsOk;\n\n const passFailureReason: PassFailureReason = passed\n ? null\n : !overallOk && !sectionsOk\n ? 'both'\n : overallOk\n ? 'section_below_threshold'\n : 'overall_below_threshold';\n\n return {\n sections: scored,\n score,\n passed,\n passFailureReason,\n status,\n pendingSlotIds: pendingAll,\n unscorableSlotIds: unscorableAll,\n };\n}\n","import { alignSequences, STEP } from './align.js';\n\n/** One code-point edit between a reference string and an attempt. */\nexport interface DictationCharOp {\n op: 'equal' | 'substitute' | 'missing' | 'extra';\n /** One code point of the reference, or `''` for an extra character. */\n reference: string;\n /** One code point of the attempt, or `''` for a missing character. */\n attempt: string;\n}\n\n/**\n * The code-point edit operations between two NORMALISED strings — the\n * `reference` and `attempt` of a `DictationAlignment`, or one word pair from\n * it — for a marked display: a character the learner typed right, typed\n * wrong, left out, or added. Ties are broken preferring an equal or substituted\n * pair, then a missing character, then an extra one, from the end of both\n * strings; the number of non-`equal` ops is the edit distance.\n *\n * Decoration, never grading: it needs O(|reference|·|attempt|) memory, so the\n * scorer does not call it, and a display computes it once per result.\n */\nexport function diffDictationChars(reference: string, attempt: string): DictationCharOp[] {\n const referencePoints = Array.from(reference);\n const attemptPoints = Array.from(attempt);\n return alignSequences(referencePoints, attemptPoints).map(\n ({ step, referenceIndex, attemptIndex }) => {\n if (step === STEP.missing) {\n return { op: 'missing', reference: referencePoints[referenceIndex] as string, attempt: '' };\n }\n if (step === STEP.extra) {\n return { op: 'extra', reference: '', attempt: attemptPoints[attemptIndex] as string };\n }\n const referencePoint = referencePoints[referenceIndex] as string;\n const attemptPoint = attemptPoints[attemptIndex] as string;\n return {\n op: referencePoint === attemptPoint ? 'equal' : 'substitute',\n reference: referencePoint,\n attempt: attemptPoint,\n };\n },\n );\n}\n","import { z } from 'zod/v4';\nimport type { ValidationError, ValidationResult } from '../../types/activity.js';\nimport type { SpeechAssessment, SpeechWord } from '../../types/speech.js';\nimport { SPEECH_ASSESSMENT_MAX_TEXT_LENGTH, SPEECH_ASSESSMENT_MAX_WORDS } from './limits.js';\nimport { CANONICAL_LOCALE_RE } from './locale.js';\nimport { heardTokens } from './tokens.js';\n\n// Every object below is strict, at every depth. An assessment is written by an\n// adapter the application maintains, and an unknown key there is most likely a\n// misspelt known one — which a loose schema would pass, and every reader would\n// then take for an absent, \"not assessed\" field.\n\n/** A score on the assessment's 0..100 scale. `z.number()` refuses NaN and both infinities. */\nconst Score = z.number().min(0).max(100);\n/** A confidence, 0..1. */\nconst Confidence = z.number().min(0).max(1);\n/** A time from the start of the recording, or a length of time, in milliseconds. */\nconst Milliseconds = z.number().min(0);\n\nconst PhonemeCandidateSchema = z.strictObject({\n symbol: z.string().min(1).max(16),\n score: Score,\n});\n\nconst PhonemeSchema = z.strictObject({\n symbol: z.string().min(1).max(16).optional(),\n accuracy: Score.optional(),\n startMs: Milliseconds.optional(),\n durationMs: Milliseconds.optional(),\n heardAs: z.array(PhonemeCandidateSchema).max(10).optional(),\n});\n\nconst SyllableSchema = z.strictObject({\n text: z.string().min(1).max(64),\n grapheme: z.string().max(64).optional(),\n accuracy: Score.optional(),\n startMs: Milliseconds.optional(),\n durationMs: Milliseconds.optional(),\n});\n\nconst WordSchema = z.strictObject({\n text: z.string().min(1).max(200),\n accuracy: Score.optional(),\n error: z.enum(['none', 'mispronunciation', 'omission', 'insertion']),\n vendorError: z.string().max(64).optional(),\n startMs: Milliseconds.optional(),\n durationMs: Milliseconds.optional(),\n syllables: z.array(SyllableSchema).max(50).optional(),\n phonemes: z.array(PhonemeSchema).max(50).optional(),\n breaks: z\n .strictObject({ unexpected: Confidence.optional(), missing: Confidence.optional() })\n .optional(),\n});\n\nconst AssessorSchema = z.strictObject({\n kind: z.enum(['auto', 'ai', 'human']),\n id: z.string().max(256).optional(),\n model: z.string().max(256).optional(),\n promptHash: z.string().max(256).optional(),\n});\n\nconst SpeechAssessmentSchema = z.strictObject({\n assessmentVersion: z.literal('1.0'),\n status: z.enum(['assessed', 'no_speech']),\n task: z.enum(['scripted', 'unscripted']),\n locale: z.string().regex(CANONICAL_LOCALE_RE),\n referenceText: z.string().max(8000).optional(),\n recordingKey: z.string().min(1).max(1024).optional(),\n assessor: AssessorSchema,\n scale: z.literal(100),\n scores: z.strictObject({\n accuracy: Score.optional(),\n fluency: Score.optional(),\n completeness: Score.optional(),\n prosody: Score.optional(),\n overall: Score.optional(),\n }),\n recognizedText: z.string().max(8000).optional(),\n miscue: z.enum(['assessor', 'none']),\n phonemeAlphabet: z.enum(['ipa', 'sapi']).optional(),\n words: z.array(WordSchema).max(SPEECH_ASSESSMENT_MAX_WORDS),\n prosody: z.strictObject({ monotoneConfidence: Confidence.optional() }).optional(),\n signal: z.strictObject({ snrDb: z.number().optional() }).optional(),\n});\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/** Whether any phoneme of any word names a `symbol`, or lists what was `heardAs` instead. */\nfunction namesPhonemes(words: unknown): boolean {\n return (\n Array.isArray(words) &&\n words.some((word) => {\n const phonemes = isRecord(word) ? word.phonemes : undefined;\n return (\n Array.isArray(phonemes) &&\n phonemes.some(\n (phoneme) =>\n isRecord(phoneme) && (phoneme.symbol !== undefined || phoneme.heardAs !== undefined),\n )\n );\n })\n );\n}\n\n/** How much text the words are written with between them, in UTF-16 units. */\nfunction rawTextLength(words: readonly SpeechWord[]): number {\n let total = 0;\n for (const word of words) {\n total += word.text.length;\n }\n return total;\n}\n\n/**\n * The two rules over the total text of the words, reported at `words`. They run\n * on the value zod parsed — the words as they were read, never as a second read\n * of the argument describes them — because a bound computed from a value the\n * aligner will not see bounds nothing.\n *\n * Both are needed, and neither replaces the other:\n *\n * - the **raw** total is what the words are written with. It is linear and\n * normalises nothing, so it refuses the 200 000-unit assessment the per-word\n * caps allow between them without paying the cost it exists to prevent, and\n * it runs first;\n * - the **normalised** total is what those words spell once they are folded into\n * tokens, which is what the aligner actually pairs. A compatibility character\n * can stand for eighteen, so a reading well inside the raw cap can still spell\n * more text than the aligner reads — and the words past that are dropped in\n * silence, leaving the learner marked from the noise that came before them.\n * It is measured by the very function that collects those tokens, so the two\n * bounds are one bound.\n *\n * Both count every word the assessment carries, so neither can be walked past\n * by tagging text `insertion`: an inserted word gives no token to pair, but the\n * aligner still normalises it and emits it, so its text is text the call holds.\n *\n * One error at most: the second runs only on what the first let through, so it\n * never normalises more than the raw cap allows.\n */\nfunction totalTextErrors(words: readonly SpeechWord[]): ValidationError[] {\n const raw = rawTextLength(words);\n if (raw > SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) {\n return [\n {\n path: ['words'],\n 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.`,\n code: 'too_big',\n },\n ];\n }\n const normalized = heardTokens(words).length;\n if (normalized > SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) {\n return [\n {\n path: ['words'],\n 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.`,\n code: 'too_big',\n },\n ];\n }\n return [];\n}\n\n/**\n * The two rules no single field states, and no parsed value can answer: each\n * fires on a field that is ABSENT. They read the value as it was given, not as\n * zod parsed it, so they report beside the field rules instead of waiting for\n * every field to parse — an adapter sees each problem on its first run — and\n * because a field zod refused is missing from what it parsed.\n */\nfunction crossFieldErrors(value: Record<string, unknown>): ValidationError[] {\n const errors: ValidationError[] = [];\n if (value.task === 'scripted') {\n if (value.referenceText === undefined) {\n errors.push({\n path: ['referenceText'],\n message:\n 'A scripted assessment must carry the reference text it was made against, so a grade can confirm it is the text of the item.',\n code: 'scripted_binding_required',\n });\n }\n if (value.recordingKey === undefined) {\n errors.push({\n path: ['recordingKey'],\n message:\n 'A scripted assessment must carry the key of the recording it measured, so a grade can confirm it is the recording of the learner.',\n code: 'scripted_binding_required',\n });\n }\n }\n if (value.phonemeAlphabet === undefined && namesPhonemes(value.words)) {\n errors.push({\n path: ['phonemeAlphabet'],\n message:\n 'A phoneme names a symbol or what was heard instead, so the assessment must say which phonemeAlphabet those symbols are written in.',\n code: 'phoneme_alphabet_required',\n });\n }\n return errors;\n}\n\n/**\n * One validation error as a phrase a developer can act on: the path it was\n * reported at, then the message. A refusal at the root has no path, and the\n * message already names the value.\n *\n * Internal: it is what `alignReadAloud`'s `TypeError` and `gradeReadAloud`'s\n * `invalid_assessment` reason say about the first problem they found, so the\n * two name it the same way.\n */\nexport function describeValidationError(error: ValidationError): string {\n return error.path.length === 0 ? error.message : `${error.path.join('.')}: ${error.message}`;\n}\n\n/**\n * Checks that `value` is a well-formed {@link SpeechAssessment}. It answers\n * rather than throwing: anything that is not one — a non-object, an unknown key\n * at any depth, a score off the 0..100 scale, a time that is negative or not\n * finite — comes back as `{ success: false, errors }`, one error per problem,\n * each with the path, message and code `validateActivity` gives a schema\n * failure. An object that throws when it is read is the one exception, and the\n * throw is its own: an own property getter, or a `Proxy` trap.\n *\n * Three rules span fields:\n * - `scripted_binding_required` — a `scripted` assessment without\n * `referenceText`, or without `recordingKey`, reported at each missing path;\n * - `phoneme_alphabet_required` — a phoneme names a `symbol` or `heardAs` while\n * `phonemeAlphabet` is absent, reported once, at `phonemeAlphabet`;\n * - `too_big` at `words` — the words carry more text between them, written or\n * normalised, than can be aligned against an item's text. Per-word caps bound\n * no total, and the aligner pairs tokens, of which one word holds as many as\n * its text spells. Every word counts towards both totals, an insertion\n * included: the aligner carries one into its answer, so its text is text the\n * call must hold.\n *\n * The total is measured on the words every field rule accepted, so it says\n * nothing about an assessment that is going back to its adapter anyway.\n *\n * It checks the evidence alone. Whether an assessment belongs to an item and\n * to a learner's recording is decided when it is graded.\n */\nexport function validateSpeechAssessment(value: unknown): ValidationResult<SpeechAssessment> {\n const result = SpeechAssessmentSchema.safeParse(value);\n const refused: ValidationError[] = result.success\n ? []\n : result.error.issues.map((issue) => ({\n path: issue.path.map(String),\n message: issue.message,\n code: issue.code,\n }));\n const parsed = result.success ? (result.data as SpeechAssessment) : undefined;\n // A value refused at the root has no fields for a cross-field rule to read.\n const errors = isRecord(value)\n ? [\n ...refused,\n ...crossFieldErrors(value),\n ...(parsed !== undefined ? totalTextErrors(parsed.words) : []),\n ]\n : refused;\n if (parsed !== undefined && errors.length === 0) {\n return { success: true, data: parsed };\n }\n return { success: false, errors };\n}\n","/**\n * What an assessor's words spell once they are normalised: the tokens a\n * read-aloud mark is made from.\n *\n * A module of its own because two callers must agree about them to the\n * character. The aligner pairs these tokens with the item's words, and\n * `validateSpeechAssessment` refuses an assessment whose words spell more text\n * than the aligner reads — and a rule that measured that total its own way\n * would accept a reading the aligner then cut short, leaving the words the\n * learner actually said out of the marking entirely.\n *\n * So one walk answers both: the tokens a mark is made from, and what the whole\n * assessment spells — every word of it, insertions included, since the aligner\n * carries those into its answer too.\n */\nimport type { SpeechWord } from '../../types/speech.js';\nimport { codePointLength, normalizeDictationText } from '../dictation/normalize.js';\nimport { SPEECH_ASSESSMENT_MAX_TEXT_LENGTH } from './limits.js';\n\n/** One normalised heard token, and the assessment word it was normalised from. */\nexport interface HeardToken {\n text: string;\n wordIndex: number;\n}\n\n/** The tokens an assessment's words give, and the text its words spell between them. */\nexport interface HeardTokens {\n /** The tokens a mark can be made from, in reading order, as far as the budget reaches. */\n tokens: HeardToken[];\n /**\n * What every word spells once it is normalised, in code points — the ones\n * past the budget too, and the insertions that give no token, so a rule that\n * refuses an assessment for its total can name the whole of it.\n */\n length: number;\n}\n\n/**\n * The normalised tokens of the words that can be marked, each remembering the\n * word it came from, up to what {@link SPEECH_ASSESSMENT_MAX_TEXT_LENGTH} code\n * points of text hold.\n *\n * A word the assessor tagged `insertion` gives no token — the reference has\n * nothing for it to pair with, so it is placed by its own position afterwards —\n * but its text IS charged to the length, because the aligner normalises it\n * anyway and emits it as an entry of its own. Charging it is what makes the\n * length the whole of what an assessment spells, so the bound the validator\n * states over it is the bound that holds. A word that normalises to nothing\n * spells nothing and is charged nothing.\n *\n * The length counts what the words spell as one text, so the space that joins a\n * token to the last one counts with it: the aligner's matrix is one byte per\n * token per reference word, and it is that text, not the words it was written\n * as, that decides how big it grows. Collecting stops at the first token that\n * would pass the budget, never inside one, because half a token pairs with the\n * wrong reference word and marks a reading nobody gave.\n */\nexport function heardTokens(words: readonly SpeechWord[]): HeardTokens {\n const tokens: HeardToken[] = [];\n let length = 0;\n for (const [wordIndex, word] of words.entries()) {\n const normalized = normalizeDictationText(word.text);\n if (normalized === '') {\n continue;\n }\n const inserted = word.error === 'insertion';\n // Normalised text is single-spaced and trimmed, so a plain split gives its\n // tokens and none of them is empty — which is why a length of 0 means\n // nothing has been counted yet, and only that token is charged no space.\n for (const text of normalized.split(' ')) {\n length += codePointLength(text) + (length === 0 ? 0 : 1);\n if (!inserted && length <= SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) {\n tokens.push({ text, wordIndex });\n }\n }\n }\n return { tokens, length };\n}\n","/**\n * Marking a read-aloud take word by word: the item's own reference words,\n * paired with the words an assessor heard.\n *\n * The assessor's word list is never taken as the item's. Providers disagree\n * about what a word is, and a provider can be changed; `w<n>` is the position\n * of a REFERENCE word, exactly as `dictationReferenceWords` numbers it, so a\n * mark stored today still names the same word tomorrow. The pairing is the\n * dictation aligner's, run over the dictation normaliser's tokens, so a\n * read-aloud mark and a dictation mark are made the same way.\n */\nimport type { ReadAloudData, ValidationError } from '../../types/activity.js';\nimport type { ReadAloudWordAlignment, SpeechAssessment, SpeechWord } from '../../types/speech.js';\nimport { alignSequences, dictationReferenceWords, STEP } from '../dictation/align.js';\nimport { normalizeDictationText } from '../dictation/normalize.js';\nimport { describeValidationError, validateSpeechAssessment } from './assessment.js';\nimport { type HeardToken, heardTokens } from './tokens.js';\n\n/** A reference word as `dictationReferenceWords` numbers it. */\ninterface ReferenceWord {\n itemId: string;\n word: string;\n}\n\n/**\n * The word's accuracy when it has one. Absent means the assessor did not\n * measure the word, which is never the same as measuring it at 0.\n */\nfunction accuracyOf(word: SpeechWord): { accuracy?: number } {\n return word.accuracy !== undefined ? { accuracy: word.accuracy } : {};\n}\n\n/**\n * Pairs the words of `assessment` with the words of `data.referenceText`, in\n * reading order: what was read as written, what was mispronounced, what was\n * left out, and what was said that the text does not contain.\n *\n * Reference words are the dictation tokeniser's, so `itemId` is the `w<n>`\n * `dictationReferenceWords` gives, and both `reference` and `heard` are\n * normalised — lowercased, with punctuation and spacing folded away. One\n * assessor word can normalise to no token at all (a dash), or to several (a\n * compatibility form), and two can merge into one (`rock&roll`); tokens are\n * what is aligned, and each remembers the word it came from.\n *\n * A word the assessor tagged `insertion` is never aligned — the reference has\n * nothing for it — so it is placed by the position of its own word, after the\n * last token that came from an earlier word.\n *\n * Whether a paired word counts as read correctly follows `assessment.miscue`:\n * an assessor that judges miscues is trusted, and one that does not\n * (`miscue: 'none'`) has its recognised word compared with the reference word.\n *\n * `heard` is `''` for an omitted word, and also for an inserted word whose text\n * normalises to nothing. Branch on `state`, never on the empty string.\n *\n * The heard tokens are bounded: they are taken in reading order until what the\n * assessment's words spell would pass `DICTATION_MAX_TEXT_LENGTH` code points —\n * the cap the dictation aligner reads a learner's text under — and always on a\n * whole token, because half a token pairs with the wrong word. Every word is\n * charged, an inserted one included, so that total is the whole of the text\n * this function can answer with. Words past the budget contribute no token, so\n * each reference word left with nothing read for it is `omitted`.\n * `validateSpeechAssessment` refuses an assessment whose words spell more than\n * that, measured by the same walk and against the same number, so a validated\n * assessment never reaches the budget: it is a backstop, kept because this\n * function is public and can be handed evidence this build did not check.\n *\n * **`data` is not validated.** Only the assessment is. A `referenceText` longer\n * than `READ_ALOUD_MAX_REFERENCE_LENGTH` code points is cut by the tokeniser\n * with no signal, and one that is absent or is not a string yields no reference\n * word at all, so every heard token comes back `inserted` rather than refused.\n * Validate the item with `validateActivity('read-aloud', data)` first.\n * `gradeReadAloud` is safe without that because it runs the schema itself.\n *\n * Pure and deterministic: no locale, no `Intl`, no clock.\n *\n * @throws TypeError when `assessment` is not a well-formed\n * {@link SpeechAssessment}; check it with `validateSpeechAssessment` first.\n */\nexport function alignReadAloud(\n data: Pick<ReadAloudData, 'referenceText'>,\n assessment: SpeechAssessment,\n): ReadAloudWordAlignment[] {\n const checked = validateSpeechAssessment(assessment);\n if (!checked.success) {\n throw new TypeError(\n 'alignReadAloud was given something that is not a speech assessment ' +\n `(${describeValidationError(checked.errors[0] as ValidationError)}). ` +\n 'Check it with validateSpeechAssessment before aligning it.',\n );\n }\n // The checked copy, never the argument: a getter that answers the validator\n // one word and the aligner another would mark a reading nobody gave.\n return alignValidated(data, checked.data);\n}\n\n/**\n * {@link alignReadAloud} for a caller that has already validated the\n * assessment, and holds the value the validator handed back.\n *\n * Internal: it is how `gradeReadAloud` aligns what it just checked, rather than\n * paying for the same parse twice — the cost of which is the whole assessment,\n * words, syllables and phonemes, on every grade.\n */\nexport function alignValidated(\n data: Pick<ReadAloudData, 'referenceText'>,\n assessment: SpeechAssessment,\n): ReadAloudWordAlignment[] {\n // Index 0 is the transcript's own words: a read-aloud item has one text, and\n // no accepted alternatives, so there is never another candidate list.\n const reference = dictationReferenceWords({\n transcript: data.referenceText,\n })[0] as readonly ReferenceWord[];\n\n const heard = heardTokens(assessment.words).tokens;\n\n const entries: ReadAloudWordAlignment[] = [];\n for (const { step, referenceIndex, attemptIndex } of alignSequences(\n reference.map((word) => word.word),\n heard.map((token) => token.text),\n )) {\n if (step === STEP.extra) {\n const token = heard[attemptIndex] as HeardToken;\n entries.push({\n reference: '',\n heard: token.text,\n state: 'inserted',\n ...accuracyOf(assessment.words[token.wordIndex] as SpeechWord),\n wordIndex: token.wordIndex,\n });\n continue;\n }\n const referenceWord = reference[referenceIndex] as ReferenceWord;\n if (step === STEP.missing) {\n entries.push({\n itemId: referenceWord.itemId,\n reference: referenceWord.word,\n heard: '',\n state: 'omitted',\n });\n continue;\n }\n const token = heard[attemptIndex] as HeardToken;\n const word = assessment.words[token.wordIndex] as SpeechWord;\n const state = pairedState(word, assessment.miscue, referenceWord.word, token.text);\n entries.push({\n itemId: referenceWord.itemId,\n reference: referenceWord.word,\n // An omitted word was not read: whatever text the assessor carried for it\n // describes the reference, not the learner.\n heard: state === 'omitted' ? '' : token.text,\n state,\n ...accuracyOf(word),\n wordIndex: token.wordIndex,\n });\n }\n\n placeInsertions(assessment, entries);\n return entries;\n}\n\n/** How a reference word paired with a heard token is marked. */\nfunction pairedState(\n word: SpeechWord,\n miscue: SpeechAssessment['miscue'],\n reference: string,\n heard: string,\n): ReadAloudWordAlignment['state'] {\n if (word.error === 'omission') {\n return 'omitted';\n }\n if (word.error === 'mispronunciation') {\n return 'mispronounced';\n }\n // An assessor that does not judge miscues reports every word it recognised as\n // `none`, so a recognised word that is not the reference word is the SDK's\n // own finding, not the assessor's silence.\n if (miscue === 'none' && heard !== reference) {\n return 'mispronounced';\n }\n return 'correct';\n}\n\n/**\n * Splices in one entry per `insertion` word. An inserted word has no reference\n * word to align with, so it is placed by its own position among the assessor's\n * words: immediately after the last entry that came from an earlier word, or\n * first when nothing came before it. Several in a row keep the order the\n * assessor reported them in, because each is placed after the one before it.\n */\nfunction placeInsertions(assessment: SpeechAssessment, entries: ReadAloudWordAlignment[]): void {\n assessment.words.forEach((word, wordIndex) => {\n if (word.error !== 'insertion') {\n return;\n }\n let at = 0;\n entries.forEach((entry, index) => {\n if (entry.wordIndex !== undefined && entry.wordIndex < wordIndex) {\n at = index + 1;\n }\n });\n entries.splice(at, 0, {\n reference: '',\n // Every insertion is accounted for, including one whose text normalises\n // to nothing: a reader of `wordIndex` must find each word it looks for.\n heard: normalizeDictationText(word.text),\n state: 'inserted',\n ...accuracyOf(word),\n wordIndex,\n });\n });\n}\n","/**\n * The read-aloud grade: the arithmetic over a pronunciation assessment's\n * dimension scores, and the checks that decide whether that evidence may be\n * graded at all.\n *\n * The SDK calls no assessor and holds no key. It is given the application's\n * assessment and the server's own measurement of the recording, and it refuses\n * evidence it cannot tie to this item, this text and this recording — a refusal\n * is never a zero, because a learner who was not heard has not failed.\n */\nimport { countWords } from '../../count-words.js';\nimport { ActivitySchemaError, RedactedScoringError } from '../../errors.js';\nimport { gradeFromRubric } from '../../grading.js';\nimport { isRedacted } from '../../is-redacted.js';\nimport { ReadAloudDataSchema } from '../../schemas/read-aloud.js';\nimport type {\n ReadAloudData,\n ReadAloudLearnerResponse,\n RecordingRef,\n ScoringDetail,\n ValidationError,\n} from '../../types/activity.js';\nimport type { CriterionScore, GradeRecord } from '../../types/grading.js';\nimport type {\n GradeReadAloudOptions,\n ReadAloudWordAlignment,\n SpeechAssessment,\n SpeechUnscorable,\n SpeechUnscorableCode,\n} from '../../types/speech.js';\nimport { dictationReferenceWords } from '../dictation/align.js';\nimport { computePassThreshold, selectFeedback } from '../pass-threshold.js';\nimport { roundingPolicyOf } from '../rounding.js';\nimport { alignValidated } from './align.js';\nimport { describeValidationError, validateSpeechAssessment } from './assessment.js';\n\n/**\n * Float slack when comparing two measurements of the same recording: voiced\n * time is summed window by window, so it can land a hair above the duration it\n * was measured from without either number being wrong.\n */\nconst MEASUREMENT_TOLERANCE = 1e-6;\n\n/** What every assessor score is out of, and what a criterion declares. */\nconst ASSESSMENT_SCALE = 100;\n\n/** A value named in an error message, without serialising the object it came from. */\nfunction describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : `of type ${typeof value}`;\n}\n\n/**\n * The recording a response carries, checked and copied — or `undefined` when\n * the value is not a response this grade can read.\n *\n * It answers with a copy for the same reason the item and the assessment are\n * graded from their checked copies: the response has no schema to parse it, so\n * this is the only place its fields are read. An accessor that answered a take\n * here and `null` afterwards would have graded a real reading as a blank 0, and\n * one that answered another key afterwards would have walked past the\n * recording-key binding. Every field is read once, and what is returned is what\n * was checked.\n */\nfunction readResponseRecording(value: unknown): { recording: RecordingRef | null } | undefined {\n if (typeof value !== 'object' || value === null) {\n return undefined;\n }\n const { type, recording } = value as { type?: unknown; recording?: unknown };\n if (type !== 'read-aloud') {\n return undefined;\n }\n // `null` is the blank: the learner submitted without recording. A take that\n // failed to upload is never null, so a blank is a decision, not an accident.\n if (recording === null) {\n return { recording: null };\n }\n if (typeof recording !== 'object') {\n return undefined;\n }\n const { key, mimeType } = recording as { key?: unknown; mimeType?: unknown };\n if (typeof key !== 'string' || key === '' || typeof mimeType !== 'string') {\n return undefined;\n }\n // `durationMs` is deliberately not carried over. A duration the client\n // reported is not evidence — the grade measures the recording itself through\n // `options.measured` — so the checked take holds only what the grade reads.\n return { recording: { key, mimeType } };\n}\n\n/**\n * How many words were spoken, for the speech-rate check: what the assessor\n * recognised as text when it reported any, and otherwise the words it kept —\n * an omission was not spoken, and an insertion is not in the text.\n *\n * `countWords` is the counter on purpose: it is the one the SDK already counts\n * a written response with, and a rate compared against a policy an application\n * set must be counted the same way every time.\n */\nfunction spokenWordCount(assessment: SpeechAssessment): number {\n const recognized = assessment.recognizedText;\n if (recognized !== undefined && recognized.trim() !== '') {\n return countWords(recognized);\n }\n return countWords(\n assessment.words\n .filter((word) => word.error !== 'omission' && word.error !== 'insertion')\n .map((word) => word.text)\n .join(' '),\n );\n}\n\n/**\n * The mark for one reference word: nothing was read for an omitted word, and a\n * word the assessor did not measure carries no mark at all — an absent score is\n * not a zero.\n */\nfunction scoreOf(entry: ReadAloudWordAlignment): { score?: number } {\n if (entry.state === 'omitted') {\n return { score: 0 };\n }\n return entry.accuracy !== undefined ? { score: entry.accuracy / ASSESSMENT_SCALE } : {};\n}\n\n/**\n * Grades a read-aloud take from a {@link SpeechAssessment} the application\n * obtained, the response that was submitted, and the server's own measurement\n * of the recording.\n *\n * The weighted total is computed by `gradeFromRubric` from the authored\n * dimensions that carry a weight above 0, each out of 100. The per-word marks\n * in `details` are for display and review: **they never feed the score**, which\n * is the assessor's utterance-level judgement, weighted as the item authored it.\n *\n * Evidence is refused, never scored 0, when it cannot be tied to this item and\n * this recording, when the assessor heard nothing, when the recording holds too\n * little voiced time for the words claimed, or when a weighted dimension has no\n * score — see {@link SpeechUnscorableCode}. Store a refusal with\n * `outcomeFromUnscorable`.\n *\n * A blank (`response.recording === null`) is a grade of 0 with a mark per\n * reference word: the learner submitted, and read nothing. The assessment and\n * the measurement are not read for it.\n *\n * @throws RangeError when `options.plausibility` or `options.rounding` cannot be\n * applied, or when `options.measured` is not a pair of non-negative, finite\n * millisecond counts with `voicedMs` inside `durationMs`.\n * @throws TypeError when `response` is not a read-aloud response, or when a\n * non-blank response is graded without an assessment or without a measurement.\n * @throws ActivitySchemaError when `data` is not valid read-aloud data.\n * @throws RedactedScoringError when `data` is a `redact()` projection. A\n * read-aloud projection is a valid activity, so the authored feedback would\n * simply be missing from the grade; grade against the full data server-side.\n */\nexport function gradeReadAloud(\n data: ReadAloudData,\n response: ReadAloudLearnerResponse,\n assessment: SpeechAssessment | null,\n options: GradeReadAloudOptions,\n): GradeRecord | SpeechUnscorable {\n // The caller's own configuration first: a policy that cannot be applied is a\n // bug in the server that grades, and answering every take \"unscorable\" would\n // hide it. Read defensively — these decide whether a take is graded at all,\n // and untyped JavaScript can arrive without them.\n const maxWordsPerSecond = options?.plausibility?.maxWordsPerSecond;\n if (\n typeof maxWordsPerSecond !== 'number' ||\n !Number.isFinite(maxWordsPerSecond) ||\n maxWordsPerSecond <= 0\n ) {\n throw new RangeError(\n `Invalid plausibility policy (maxWordsPerSecond ${describeValue(maxWordsPerSecond)}): expected a finite number above 0.`,\n );\n }\n const minVoicedMs = options?.plausibility?.minVoicedMs;\n if (typeof minVoicedMs !== 'number' || !Number.isFinite(minVoicedMs) || minVoicedMs < 0) {\n throw new RangeError(\n `Invalid plausibility policy (minVoicedMs ${describeValue(minVoicedMs)}): expected a finite number of milliseconds, 0 or above.`,\n );\n }\n const rounding = roundingPolicyOf(options?.rounding);\n\n const parsed = ReadAloudDataSchema.safeParse(data);\n if (!parsed.success) {\n throw new ActivitySchemaError(\n 'read-aloud',\n parsed.error.issues.map((issue) => ({\n path: issue.path.map(String),\n message: issue.message,\n code: issue.code,\n })),\n );\n }\n // A learner-safe projection parses as read-aloud data, and it keeps every\n // field this grade reads, so nothing further down would notice that the\n // authored feedback had been removed rather than never written: the grade\n // comes out a plausible number with `feedback: null`, where a type whose\n // answer key the projection drops fails closed on a non-finite score.\n // `score()` throws the same error for the same reason — a grade made without\n // the answer key is not a grade — and this is a public grader that does not\n // go through it.\n //\n // Asked of BOTH values. The parsed one, because the schema is loose and\n // carries `redacted` through, so a getter cannot answer the parse `true` and\n // this guard `false`; and the argument, because the parse copies own\n // enumerable keys only, so an INHERITED marker is absent from the copy —\n // and `score()` reads the argument, which is the parity to keep.\n if (isRedacted(data) || isRedacted(parsed.data)) {\n throw new RedactedScoringError('read-aloud');\n }\n // The checked copy of the item from here on, never the caller's object, for\n // the reason the assessment's checked copy exists below: a property getter\n // that answers the schema one value and a later read another marks a reading\n // the item never asked for — a `referenceText` honest for the parse and the\n // mismatch check, and longer on the third read, had the learner marked\n // against words that are not in the text.\n const item = parsed.data as unknown as ReadAloudData;\n const checkedResponse = readResponseRecording(response);\n if (checkedResponse === undefined) {\n throw new TypeError(\n 'gradeReadAloud needs a read-aloud response: { type: \"read-aloud\", recording: null } for a blank, ' +\n 'or a recording with a non-empty `key` and a `mimeType`.',\n );\n }\n const recording = checkedResponse.recording;\n\n if (recording === null) {\n const passed = computePassThreshold(item, 0, rounding);\n return {\n score: 0,\n maxScore: 1,\n passed,\n feedback: selectFeedback(item, passed),\n criteria: [],\n // Every word of the text, unread. A blank is graded from the item alone:\n // there is no assessment to read, and no grader to credit.\n details: referenceWordsOf(item).map(({ itemId, word }) => ({\n itemId,\n correct: false,\n outcome: 'incorrect-omission' as const,\n learnerResponse: '',\n correctResponse: word,\n weight: 1,\n score: 0,\n })),\n };\n }\n\n // Past the blank, a grade needs evidence and a measurement. Missing either is\n // the caller's bug, not evidence the SDK could refuse on its merits.\n if (assessment === null || assessment === undefined) {\n throw new TypeError(\n 'gradeReadAloud needs a speech assessment for a response that carries a recording. Pass one, or grade the blank (recording: null).',\n );\n }\n const measured = options.measured;\n if (measured === null || measured === undefined) {\n throw new TypeError(\n \"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.\",\n );\n }\n const { durationMs, voicedMs } = measured;\n if (\n typeof durationMs !== 'number' ||\n !Number.isFinite(durationMs) ||\n durationMs < 0 ||\n typeof voicedMs !== 'number' ||\n !Number.isFinite(voicedMs) ||\n voicedMs < 0 ||\n voicedMs > durationMs + MEASUREMENT_TOLERANCE\n ) {\n throw new RangeError(\n `Invalid recording measurement (durationMs ${describeValue(durationMs)}, voicedMs ${describeValue(voicedMs)}): expected finite millisecond counts, 0 or above, with voicedMs inside durationMs.`,\n );\n }\n\n const unscorable = (code: SpeechUnscorableCode, reason: string): SpeechUnscorable => ({\n unscorable: true,\n code,\n reason,\n });\n\n const checked = validateSpeechAssessment(assessment);\n if (!checked.success) {\n return unscorable(\n 'invalid_assessment',\n `The speech assessment is not well formed (${describeValidationError(checked.errors[0] as ValidationError)}).`,\n );\n }\n // The checked copy of the evidence too, never the caller's object: a property\n // getter can answer one value to the validator and another to the grade, and\n // a mark computed from the second leaves the [0, 1] range `ScoringDetail`\n // promises. `roundingPolicyOf` guards the rounding policy the same way, and\n // for the same reason.\n const evidence = checked.data;\n if (evidence.task !== 'scripted') {\n return unscorable(\n 'task_mismatch',\n `The assessment was made for a \"${evidence.task}\" task; a read-aloud grade needs one made against the item's text.`,\n );\n }\n if (evidence.locale !== item.locale) {\n return unscorable(\n 'locale_mismatch',\n `The assessment was made for locale \"${evidence.locale}\", and the item is read in \"${item.locale}\". Pronunciation is assessed against one locale's speech.`,\n );\n }\n if (evidence.referenceText !== item.referenceText) {\n return unscorable(\n 'reference_mismatch',\n 'The assessment was made against a different text from the one this item asks the learner to read.',\n );\n }\n if (evidence.recordingKey !== recording.key) {\n return unscorable(\n 'recording_mismatch',\n `The assessment was made for recording \"${String(evidence.recordingKey)}\", and this response submitted \"${recording.key}\".`,\n );\n }\n if (evidence.assessor.kind === 'ai' && options.allowAiAssessor !== true) {\n return unscorable(\n 'assessor_not_accepted',\n 'The assessment was produced by a generative model. Pass `allowAiAssessor: true` to accept that as measurement evidence.',\n );\n }\n if (evidence.status === 'no_speech') {\n return unscorable('no_speech', 'The assessor heard no speech in this recording.');\n }\n if (voicedMs < minVoicedMs) {\n return unscorable(\n 'insufficient_voiced_time',\n `The recording holds ${voicedMs} ms of voiced time, and the policy asks for at least ${minVoicedMs} ms.`,\n );\n }\n const words = spokenWordCount(evidence);\n if (words > 0 && (voicedMs === 0 || words / (voicedMs / 1000) > maxWordsPerSecond)) {\n return unscorable(\n 'implausible_speech_rate',\n `The assessment claims ${words} word(s) in ${voicedMs} ms of voiced time, above the ${maxWordsPerSecond} word(s) per second the policy allows.`,\n );\n }\n const weighed = item.scoring.dimensions.filter((dimension) => dimension.weight > 0);\n // Before the arithmetic: `gradeFromRubric` drops a criterion with no numeric\n // score, so an absent dimension would silently regrade the learner on the\n // rest at different effective weights.\n const missing = weighed.find((dimension) => evidence.scores[dimension.name] === undefined);\n if (missing !== undefined) {\n return unscorable(\n 'missing_dimension',\n `The item weighs \"${missing.name}\", and the assessment carries no ${missing.name} score. An absent score is not a 0.`,\n );\n }\n\n const criteria: CriterionScore[] = weighed.map((dimension) => ({\n name: dimension.name,\n score: evidence.scores[dimension.name] as number,\n maxScore: ASSESSMENT_SCALE,\n weight: dimension.weight,\n }));\n const graded = gradeFromRubric(criteria, item, rounding !== undefined ? { rounding } : {});\n if ('unscorable' in graded) {\n // Unreachable for data and an assessment that passed the checks above: every\n // weight is above 0 and every score is a finite 0..100. Kept because the\n // alternative to a refusal here would be inventing a number.\n return unscorable(\n 'invalid_assessment',\n `No weighted total could be computed from the assessment's scores: ${graded.reason}`,\n );\n }\n\n const details: ScoringDetail[] = [];\n for (const entry of alignValidated(item, evidence)) {\n // An inserted word is not one of the item's: it has no id to store a mark\n // under, and a word the learner added is not a word the item asked for.\n if (entry.itemId === undefined) {\n continue;\n }\n details.push({\n itemId: entry.itemId,\n correct: entry.state === 'correct',\n outcome:\n entry.state === 'correct'\n ? 'correct'\n : entry.state === 'mispronounced'\n ? 'incorrect'\n : 'incorrect-omission',\n learnerResponse: entry.heard,\n correctResponse: entry.reference,\n weight: 1,\n ...scoreOf(entry),\n });\n }\n\n return {\n ...graded,\n feedback: selectFeedback(item, graded.passed),\n details,\n // A copy, so a stored grade keeps who measured it even if the assessment\n // object is later mutated or reused.\n grader: { ...evidence.assessor },\n };\n}\n\n/** The item's reference words, as `dictationReferenceWords` numbers them. */\nfunction referenceWordsOf(\n data: Pick<ReadAloudData, 'referenceText'>,\n): readonly { itemId: string; word: string }[] {\n return dictationReferenceWords({ transcript: data.referenceText })[0] as readonly {\n itemId: string;\n word: string;\n }[];\n}\n","/**\n * Measuring a recording from its bytes: how long it is, how loud it gets, and\n * how much of it is voiced.\n *\n * This is the server's own evidence about a take. A duration a browser reported\n * is a claim, and a claim decides nothing: whether a recording holds enough\n * speech to grade is answered from the samples. Only 16-bit PCM WAV is read —\n * the one encoding an application can produce without a decoder — and anything\n * else is reported as unread rather than guessed at.\n *\n * Isomorphic: a `DataView` over the caller's bytes, no `Buffer`, no `node:`\n * imports, and no allocation proportional to the audio.\n */\nimport type { WavInspection, WavInspectionPolicy } from '../../types/speech.js';\n\n/** `RIFF` + the file size + `WAVE`, before the first chunk. */\nconst HEADER_BYTES = 12;\n/** A chunk's four-character id and its little-endian size. */\nconst CHUNK_HEADER_BYTES = 8;\n/** `audioFormat`, `channels`, `sampleRate`, `byteRate`, `blockAlign`, `bitsPerSample`. */\nconst FMT_MIN_BYTES = 16;\n/** WAVE_FORMAT_EXTENSIBLE carries a `SubFormat` GUID; the chunk is this long with it. */\nconst EXTENSIBLE_BYTES = 40;\n/** Where the `SubFormat` GUID starts inside the `fmt ` body. Its first two bytes are the real format. */\nconst SUBFORMAT_OFFSET = 24;\n/** Uncompressed PCM, as `audioFormat` and as the first two bytes of a `SubFormat`. */\nconst FORMAT_PCM = 1;\n/** WAVE_FORMAT_EXTENSIBLE: the real format is in the `SubFormat` GUID. */\nconst FORMAT_EXTENSIBLE = 0xfffe;\n/** The only sample width read. */\nconst BITS_PER_SAMPLE = 16;\nconst BYTES_PER_SAMPLE = 2;\n/** Full scale for a 16-bit sample: `-32768` is the loudest a sample can be. */\nconst FULL_SCALE = 32768;\n\n/** The part of a `fmt ` chunk the measurement needs. */\ninterface WavFormat {\n channels: number;\n sampleRate: number;\n blockAlign: number;\n}\n\n/** A value named in an error message, without serialising the object it came from. */\nfunction describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : `of type ${typeof value}`;\n}\n\n/** The four-character chunk id at `offset`. */\nfunction tagAt(view: DataView, offset: number): string {\n return String.fromCharCode(\n view.getUint8(offset),\n view.getUint8(offset + 1),\n view.getUint8(offset + 2),\n view.getUint8(offset + 3),\n );\n}\n\n/**\n * The `fmt ` chunk when it describes 16-bit PCM this reader can measure, and\n * `undefined` for anything else — a compressed stream, 8- or 24-bit samples, a\n * channel count or sample rate of 0, a `blockAlign` that disagrees with them,\n * or a chunk too short to say.\n */\nfunction formatAt(view: DataView, body: number, size: number): WavFormat | undefined {\n if (size < FMT_MIN_BYTES || body + FMT_MIN_BYTES > view.byteLength) {\n return undefined;\n }\n const audioFormat = view.getUint16(body, true);\n const channels = view.getUint16(body + 2, true);\n const sampleRate = view.getUint32(body + 4, true);\n const blockAlign = view.getUint16(body + 12, true);\n const bitsPerSample = view.getUint16(body + 14, true);\n const pcm =\n audioFormat === FORMAT_PCM ||\n (audioFormat === FORMAT_EXTENSIBLE &&\n size >= EXTENSIBLE_BYTES &&\n body + EXTENSIBLE_BYTES <= view.byteLength &&\n view.getUint16(body + SUBFORMAT_OFFSET, true) === FORMAT_PCM);\n if (\n !pcm ||\n bitsPerSample !== BITS_PER_SAMPLE ||\n channels < 1 ||\n sampleRate < 1 ||\n blockAlign !== channels * BYTES_PER_SAMPLE\n ) {\n return undefined;\n }\n return { channels, sampleRate, blockAlign };\n}\n\n/**\n * Reads a 16-bit PCM WAV and measures it: `durationMs`, `sampleRate`,\n * `channels`, the loudest sample as `peakDbfs`, and `voicedMs`, the time in\n * windows whose RMS level reaches the policy's silence floor.\n *\n * Anything it cannot measure comes back as `{ valid: false, reason }` rather\n * than a guess: `not_wav` for bytes that are not a RIFF/WAVE file, or whose\n * chunks do not describe the audio before the audio arrives;\n * `unsupported_encoding` for a WAV this reader does not read (compressed, or\n * samples that are not 16-bit); `truncated` when the data chunk is missing or\n * shorter than it declares.\n *\n * Both policy fields are required, because neither has an answer that is right\n * for every microphone: `silenceDbfs` is where an application draws the line\n * between silence and speech, and `frameMs` is how finely it looks.\n *\n * Pure and deterministic. It never throws on the bytes themselves — only on\n * arguments it cannot use.\n *\n * @throws TypeError when `bytes` is not a `Uint8Array`.\n * @throws RangeError when `silenceDbfs` is not a finite number at or below 0,\n * or `frameMs` is not a finite number above 0.\n */\nexport function inspectWav(bytes: Uint8Array, policy: WavInspectionPolicy): WavInspection {\n if (!(bytes instanceof Uint8Array)) {\n throw new TypeError(\"inspectWav reads a recording's bytes: pass a Uint8Array of the WAV file.\");\n }\n const silenceDbfs = policy?.silenceDbfs;\n if (typeof silenceDbfs !== 'number' || !Number.isFinite(silenceDbfs) || silenceDbfs > 0) {\n throw new RangeError(\n `Invalid inspection policy (silenceDbfs ${describeValue(silenceDbfs)}): expected a finite number of dBFS at or below 0, full scale being 0.`,\n );\n }\n const frameMs = policy?.frameMs;\n if (typeof frameMs !== 'number' || !Number.isFinite(frameMs) || frameMs <= 0) {\n throw new RangeError(\n `Invalid inspection policy (frameMs ${describeValue(frameMs)}): expected a finite window length in milliseconds, above 0.`,\n );\n }\n\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n if (view.byteLength < HEADER_BYTES || tagAt(view, 0) !== 'RIFF' || tagAt(view, 8) !== 'WAVE') {\n return { valid: false, reason: 'not_wav' };\n }\n\n let format: WavFormat | undefined;\n let data: { offset: number; declared: number } | undefined;\n let offset = HEADER_BYTES;\n // A chunk's body is followed by a pad byte when its size is odd, so a walk\n // that ignored the pad would read the next id one byte late.\n while (offset + CHUNK_HEADER_BYTES <= view.byteLength) {\n const id = tagAt(view, offset);\n const size = view.getUint32(offset + 4, true);\n const body = offset + CHUNK_HEADER_BYTES;\n if (id === 'fmt ') {\n format = formatAt(view, body, size);\n if (format === undefined) {\n return { valid: false, reason: 'unsupported_encoding' };\n }\n } else if (id === 'data') {\n // Samples before the format that describes them: the file cannot be read\n // in one pass, and a reader that seeks back is reading a broken file.\n if (format === undefined) {\n return { valid: false, reason: 'not_wav' };\n }\n data = { offset: body, declared: size };\n break;\n }\n offset = body + size + (size % 2);\n }\n if (format === undefined) {\n return { valid: false, reason: 'not_wav' };\n }\n if (data === undefined || data.declared > view.byteLength - data.offset) {\n return { valid: false, reason: 'truncated' };\n }\n\n const { channels, sampleRate, blockAlign } = format;\n // Whole frames only: a trailing half-frame is not a sample of every channel.\n const frames = Math.floor(data.declared / blockAlign);\n const samples = frames * channels;\n\n let peak = 0;\n for (let index = 0; index < samples; index += 1) {\n const magnitude = Math.abs(view.getInt16(data.offset + index * BYTES_PER_SAMPLE, true));\n if (magnitude > peak) {\n peak = magnitude;\n }\n }\n\n const windowFrames = Math.max(1, Math.round((sampleRate * frameMs) / 1000));\n let voicedMs = 0;\n for (let start = 0; start < frames; start += windowFrames) {\n // The last window is as long as what is left of the recording.\n const windowLength = Math.min(windowFrames, frames - start);\n const windowSamples = windowLength * channels;\n let sum = 0;\n for (let index = 0; index < windowSamples; index += 1) {\n const sample =\n view.getInt16(data.offset + (start * channels + index) * BYTES_PER_SAMPLE, true) /\n FULL_SCALE;\n sum += sample * sample;\n }\n const rms = Math.sqrt(sum / windowSamples);\n const db = rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY;\n if (db >= silenceDbfs) {\n voicedMs += (windowLength * 1000) / sampleRate;\n }\n }\n\n const durationMs = (frames * 1000) / sampleRate;\n return {\n valid: true,\n durationMs,\n sampleRate,\n channels,\n bitsPerSample: BITS_PER_SAMPLE,\n // Digital silence has no level to report, and neither has a file with no\n // frames: `-Infinity` says so, where a 0 would read as full scale.\n peakDbfs: peak === 0 ? Number.NEGATIVE_INFINITY : 20 * Math.log10(peak / FULL_SCALE),\n // Two measurements of one recording that must agree: the voiced time is\n // added window by window, the duration is a single expression, and at a\n // sample rate where a window is not a binary-exact number of milliseconds\n // the sum can land a hair above the whole. `SpeechMeasurement` says voiced\n // time is at most the duration and `gradeReadAloud` throws on a pair that\n // is not, so the clamp makes the measurement consistent by construction\n // rather than leaving a grading job to crash on the SDK's own output.\n voicedMs: Math.min(voicedMs, durationMs),\n };\n}\n","import type { ItemOutcome } from '../types/activity.js';\n\n/**\n * Lifts a result that cannot be graded — a `SpeechUnscorable` from\n * `gradeReadAloud`, or anything else carrying a `code` and a `reason` — into\n * the `unscorable` arm of {@link ItemOutcome}, keeping its code.\n *\n * The code is what an application branches on; the reason is a developer-facing\n * sentence. Stored this way, the result composes exactly as the unscorable\n * outcomes `evaluate` returns do. `evaluate` never writes a code itself.\n */\nexport function outcomeFromUnscorable(result: { code: string; reason: string }): ItemOutcome {\n return { status: 'unscorable', reason: result.reason, maxScore: 1, code: result.code };\n}\n","import { DeferredScoringError, RedactedScoringError, UnknownActivityTypeError } from '../errors.js';\nimport { isRedacted } from '../is-redacted.js';\nimport { getActivityTypeDescriptor } from '../registry/index.js';\nimport type {\n ActivityData,\n ActivityType,\n ItemOutcome,\n LearnerResponse,\n ScoringResult,\n} from '../types/activity.js';\nimport { computePassThreshold, selectFeedback } from './pass-threshold.js';\nimport { type RoundingPolicy, roundingPolicyOf } from './rounding.js';\n\n// Every type a public signature on this subpath names — argument, return value,\n// and the shapes inside them an adapter fills in. A consumer that imports\n// `gradeReadAloud` from `./scoring` imports it from here alone, and a function\n// whose arguments and result cannot be named is one that has to be called from\n// `any` — which is how the evidence stops being checked. Type-only: the bundle\n// is unchanged.\nexport type {\n ItemOutcome,\n ReadAloudData,\n ReadAloudLearnerResponse,\n RecordingRef,\n} from '../types/activity.js';\nexport type { GradeRecord } from '../types/grading.js';\nexport type {\n GradeReadAloudOptions,\n ReadAloudWordAlignment,\n ReadAloudWordState,\n SpeechAssessment,\n SpeechMeasurement,\n SpeechPhoneme,\n SpeechPhonemeCandidate,\n SpeechPlausibilityPolicy,\n SpeechSyllable,\n SpeechUnscorable,\n SpeechUnscorableCode,\n SpeechWord,\n SpeechWordError,\n WavInspection,\n WavInspectionPolicy,\n} from '../types/speech.js';\nexport type {\n AssessmentScore,\n AssessmentSectionInput,\n CompositionPolicy,\n PassFailureReason,\n ScoredItem,\n SectionScore,\n} from './compose.js';\nexport { composeAssessmentScore } from './compose.js';\nexport type {\n DictationAlignment,\n DictationCharOp,\n DictationReference,\n DictationWordAlignment,\n} from './dictation/index.js';\nexport {\n alignDictation,\n DICTATION_MAX_ACCEPTED_TRANSCRIPTS,\n DICTATION_MAX_EQUIVALENCE_LENGTH,\n DICTATION_MAX_EQUIVALENCES,\n DICTATION_MAX_TEXT_LENGTH,\n DICTATION_MAX_TRANSCRIPT_LENGTH,\n dictationReferenceWords,\n diffDictationChars,\n} from './dictation/index.js';\nexport { computePassThreshold, DEFAULT_PASS_THRESHOLD } from './pass-threshold.js';\nexport type { Band, RoundingMode, RoundingPolicy } from './rounding.js';\nexport { classifyBand, gte, roundGrade } from './rounding.js';\nexport {\n alignReadAloud,\n gradeReadAloud,\n inspectWav,\n READ_ALOUD_MAX_DIMENSION_WEIGHT,\n READ_ALOUD_MAX_REFERENCE_LENGTH,\n READ_ALOUD_MAX_SECONDS,\n READ_ALOUD_MAX_TAKES,\n SPEECH_ASSESSMENT_MAX_WORDS,\n validateSpeechAssessment,\n} from './speech/index.js';\nexport type { TextMatchPolicy, TextMatchResult } from './text-match.js';\nexport { levenshteinDistance, matchText } from './text-match.js';\nexport { outcomeFromUnscorable } from './unscorable.js';\n\n/**\n * Options for {@link score} and {@link evaluate}. Additive: with none, both\n * behave exactly as they always have.\n */\nexport interface ScoringOptions {\n /**\n * Compare the pass line the way the score is displayed — both sides rounded,\n * via {@link computePassThreshold} — so an item shown as \"70%\" is not\n * recorded as a fail at 69.995. `passed` AND the authored feedback selected by\n * it follow the rounded comparison. Opt-in, for the reason\n * {@link computePassThreshold} gives: switching it on moves item-level\n * pass/fail for scores in the rounding band.\n *\n * Checked at the call: `null` reads as no policy, and a policy that cannot be\n * applied — an unknown `mode`, or a `dp` that is not a whole number from 0 to\n * 15 — throws a `RangeError` instead of quietly failing every comparison.\n */\n rounding?: RoundingPolicy;\n}\n\n/**\n * Scores a learner response against activity data and returns a full\n * {@link ScoringResult}.\n *\n * Pure and deterministic with no side effects. It does **not** re-validate\n * `activityData` — schema validation is the component boundary's\n * responsibility; this is a low-level scoring primitive that trusts its typed\n * inputs. Dispatch is registry-backed: consumer-registered types with `sync`\n * scoring work here too. An unregistered type throws\n * {@link UnknownActivityTypeError}; a type whose grading is deferred (e.g.\n * `written-response`) throws {@link DeferredScoringError} — use\n * {@link evaluate}, which can express \"not graded yet\".\n */\nexport function score(\n activityType: ActivityType,\n activityData: ActivityData,\n learnerResponse: LearnerResponse,\n options?: ScoringOptions,\n): ScoringResult {\n const rounding = roundingPolicyOf(options?.rounding);\n const descriptor = getActivityTypeDescriptor(activityType);\n if (descriptor === undefined) {\n throw new UnknownActivityTypeError(String(activityType));\n }\n if (descriptor.scoring.kind === 'deferred') {\n throw new DeferredScoringError(descriptor.type);\n }\n if (isRedacted(activityData)) {\n throw new RedactedScoringError(descriptor.type);\n }\n\n const result = descriptor.scoring.score(activityData, learnerResponse);\n if (!Number.isFinite(result.score)) {\n throw new RedactedScoringError(descriptor.type);\n }\n const passed = computePassThreshold(activityData, result.score, rounding);\n return { ...result, passed, feedback: result.feedback ?? selectFeedback(activityData, passed) };\n}\n\n/**\n * Evaluates a learner response against an activity and returns an\n * {@link ItemOutcome} — the resilient, forward-compatible alternative to\n * {@link score}:\n *\n * - Synchronously graded types return `{ status: 'scored', ... }` with the\n * same numbers `score()` produces.\n * - Asynchronously graded types (e.g. `written-response`) return\n * `{ status: 'deferred', reason, partial }` instead of a fake zero — \"not\n * graded yet\" is expressible in the type system, never conflated with\n * \"wrong\".\n * - An unregistered `data.type` returns `{ status: 'unscorable' }` rather\n * than throwing, so a mixed-version content bank cannot crash an exam run.\n *\n * The activity type is read from `data.type` — there is no separate type\n * parameter to disagree with the payload.\n */\nexport function evaluate(\n data: ActivityData,\n response: LearnerResponse,\n options?: ScoringOptions,\n): ItemOutcome {\n // A malformed option is the caller's configuration, not the content bank's:\n // it throws before any item is read, rather than grading every item wrong.\n const rounding = roundingPolicyOf(options?.rounding);\n const type = (data as { type?: unknown }).type;\n const descriptor = typeof type === 'string' ? getActivityTypeDescriptor(type) : undefined;\n\n if (descriptor === undefined) {\n return {\n status: 'unscorable',\n reason: `Activity type \"${String(type)}\" is not registered`,\n maxScore: 1,\n };\n }\n\n if (descriptor.scoring.kind === 'deferred') {\n const partial = descriptor.scoring.partial?.(data, response);\n return {\n status: 'deferred',\n reason: descriptor.scoring.reason,\n maxScore: 1,\n ...(partial !== undefined ? { partial } : {}),\n };\n }\n\n if (isRedacted(data)) {\n return {\n status: 'unscorable',\n reason:\n 'Activity data is redacted (no answer key), so it cannot be scored on the client. Score against the full data server-side.',\n maxScore: 1,\n };\n }\n\n const result = descriptor.scoring.score(data, response);\n if (!Number.isFinite(result.score)) {\n // Defence in depth: incomplete data (a missing answer key, an empty\n // options array) used to divide by zero and surface as a real score of\n // NaN, which JSON-serializes to null in a grade column.\n return {\n status: 'unscorable',\n reason: `Scoring \"${descriptor.type}\" produced a non-finite score; the activity data is incomplete.`,\n maxScore: result.maxScore,\n };\n }\n const passed = computePassThreshold(data, result.score, rounding);\n return {\n status: 'scored',\n score: result.score,\n maxScore: result.maxScore,\n passed,\n feedback: result.feedback ?? selectFeedback(data, passed),\n details: result.details,\n };\n}\n"]}
|
|
@@ -5,12 +5,13 @@ import {
|
|
|
5
5
|
GapSelectDataSchema,
|
|
6
6
|
MediaSchema,
|
|
7
7
|
MultipleChoiceDataSchema,
|
|
8
|
+
ReadAloudDataSchema,
|
|
8
9
|
RedactedMediaSchema,
|
|
9
10
|
UnknownActivityTypeError,
|
|
10
11
|
WrittenResponseDataSchema,
|
|
11
12
|
getActivityTypeDescriptor,
|
|
12
13
|
groupCaptionsRevealDictation
|
|
13
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-6RND47QE.js";
|
|
14
15
|
|
|
15
16
|
// src/schemas/item-group.ts
|
|
16
17
|
import { z } from "zod/v4";
|
|
@@ -166,6 +167,9 @@ var gapSelectJsonSchema = z2.toJSONSchema(GapSelectDataSchema, {
|
|
|
166
167
|
var dictationJsonSchema = z2.toJSONSchema(DictationDataSchema, {
|
|
167
168
|
target: "draft-7"
|
|
168
169
|
});
|
|
170
|
+
var readAloudJsonSchema = z2.toJSONSchema(ReadAloudDataSchema, {
|
|
171
|
+
target: "draft-7"
|
|
172
|
+
});
|
|
169
173
|
var multipleChoiceJsonSchema = z2.toJSONSchema(MultipleChoiceDataSchema, {
|
|
170
174
|
target: "draft-7"
|
|
171
175
|
});
|
|
@@ -213,10 +217,11 @@ export {
|
|
|
213
217
|
itemGroupJsonSchema,
|
|
214
218
|
gapSelectJsonSchema,
|
|
215
219
|
dictationJsonSchema,
|
|
220
|
+
readAloudJsonSchema,
|
|
216
221
|
multipleChoiceJsonSchema,
|
|
217
222
|
fillInTheBlanksJsonSchema,
|
|
218
223
|
writtenResponseJsonSchema,
|
|
219
224
|
jsonSchemaFor,
|
|
220
225
|
validateActivity
|
|
221
226
|
};
|
|
222
|
-
//# sourceMappingURL=chunk-
|
|
227
|
+
//# sourceMappingURL=chunk-NBADQ3JU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schemas/item-group.ts","../src/schemas/json-schema.ts","../src/schemas/index.ts"],"sourcesContent":["import { z } from 'zod/v4';\nimport { getActivityTypeDescriptor } from '../registry/index.js';\nimport type { ActivityData, ValidationError, ValidationResult } from '../types/activity.js';\nimport type { ItemGroup, StimulusKind } from '../types/item-group.js';\nimport { GROUP_CAPTIONS_REVEAL_DICTATION, groupCaptionsRevealDictation } from './dictation.js';\nimport { MediaSchema, RedactedMediaSchema } from './media.js';\n\n/**\n * An authored slot key.\n *\n * A `.` is rejected here, not at render time: `flattenSequence` uses the first\n * `.` to separate a group from its item, so a key containing one would produce\n * an ambiguous slot id. `keyOf` already threw on it — this moves the rejection\n * to `validateItemGroup`, where an author finds it, instead of leaving it to\n * surface when a learner opens the paper. Content that would newly fail\n * validation already threw at flatten time, so it can never have been sat.\n */\nconst SlotKeySchema = z\n .string()\n .min(1)\n .refine((key) => !key.includes('.'), {\n error:\n 'slotKey must not contain \".\" — that character separates a group from its item in a slot id, so a key containing one would make the slot ambiguous.',\n });\n\nfunction hasBody(stimulus: { body?: string | undefined }): boolean {\n return typeof stimulus.body === 'string' && stimulus.body.trim().length > 0;\n}\n\n/** Which media types can carry a stimulus of each kind. `text`/`mixed` accept any. */\nfunction mediaFits(kind: StimulusKind, mediaType: string | undefined): boolean {\n switch (kind) {\n case 'audio':\n return mediaType === 'audio';\n case 'video':\n return mediaType === 'video' || mediaType === 'embed';\n case 'image':\n return mediaType === 'image';\n default:\n return true;\n }\n}\n\n/**\n * Zod schema for a {@link Stimulus}. Loose: unknown keys preserved.\n *\n * The semantic guards exist because a stimulus that does not carry what its\n * `kind` promises is an authoring error that must not reach an exam: an\n * \"audio\" stimulus with no recording renders as a blank panel above six\n * listening questions. Unrepresentable in JSON Schema; dropped from\n * `stimulusJsonSchema` by design.\n */\nexport const StimulusSchema = z\n .looseObject({\n id: z.string().min(1),\n kind: z.enum(['text', 'audio', 'video', 'image', 'mixed']),\n title: z.string().optional(),\n body: z.string().optional(),\n bodyHtml: z.string().optional(),\n media: MediaSchema.optional(),\n transcript: z.string().optional(),\n locale: z.string().optional(),\n attribution: z.string().optional(),\n })\n .refine((stimulus) => stimulus.bodyHtml === undefined || hasBody(stimulus), {\n error:\n 'bodyHtml requires a plain-text body: it is the accessible fallback rendered when no sanitiser is supplied.',\n path: ['body'],\n })\n .refine(\n (stimulus) => (stimulus.kind !== 'text' && stimulus.kind !== 'mixed') || hasBody(stimulus),\n {\n error: 'A text or mixed stimulus needs a non-empty body.',\n path: ['body'],\n },\n )\n .refine((stimulus) => stimulus.kind === 'text' || stimulus.media !== undefined, {\n error: 'An audio, video, image or mixed stimulus needs media.',\n path: ['media'],\n })\n .refine(\n (stimulus) => stimulus.media === undefined || mediaFits(stimulus.kind, stimulus.media.type),\n {\n error:\n 'media.type does not fit the stimulus kind: audio needs audio; video needs video or embed; image needs image.',\n path: ['media', 'type'],\n },\n );\n\n/**\n * The structural minimum of an item as seen by the CONTAINER schema. Each\n * item's own contract is checked against its registered schema by\n * {@link validateItemGroup} — a zod schema cannot dispatch on a registry\n * that consumers extend at runtime.\n */\nconst ItemShapeSchema = z.looseObject({\n type: z.string().min(1),\n id: z.string().min(1),\n slotKey: SlotKeySchema.optional(),\n});\n\n/**\n * Zod schema for an {@link ItemGroup} CONTAINER. Loose: unknown keys\n * preserved. Validates the group's own fields and the stimulus in full, and\n * each item only structurally (`type` and `id`); use {@link validateItemGroup}\n * to validate the items against their registered schemas as well.\n */\nexport const ItemGroupSchema = z\n .looseObject({\n schemaVersion: z.literal('1.0'),\n type: z.literal('item-group'),\n id: z.string().min(1),\n title: z.string().optional(),\n slotKey: SlotKeySchema.optional(),\n stimulus: StimulusSchema,\n items: z.array(ItemShapeSchema).min(1),\n shuffle: z.enum(['none', 'within-group']).optional(),\n })\n .refine((group) => group.items.every((item) => item.type !== 'item-group'), {\n error: 'Item groups do not nest: every item must be an activity.',\n path: ['items'],\n })\n .refine((group) => new Set(group.items.map((item) => item.id)).size === group.items.length, {\n error: 'Item ids must be unique within a group.',\n path: ['items'],\n });\n\n/** The learner-safe shape of a stimulus, derived from the strict schema below. */\nexport type RedactedStimulus = z.infer<typeof RedactedStimulusSchema>;\n\n/** Strict learner-safe stimulus: everything but the author-only `transcript`. */\nexport const RedactedStimulusSchema = z.strictObject({\n id: z.string().min(1),\n kind: z.enum(['text', 'audio', 'video', 'image', 'mixed']),\n title: z.string().optional(),\n body: z.string().optional(),\n bodyHtml: z.string().optional(),\n media: RedactedMediaSchema.optional(),\n locale: z.string().optional(),\n attribution: z.string().optional(),\n});\n\n/**\n * Strict learner-safe item group. Items are left opaque here — each is proven\n * learner-safe by `assertRedacted` against its OWN type's redacted schema,\n * which is the only place that knowledge lives. The one exception is the one\n * rule that spans the container and an item: a captioned stimulus recording\n * played to a dictation is not learner-safe, because the captions are the\n * dictation's answer.\n */\nexport const RedactedItemGroupSchema = z\n .strictObject({\n redacted: z.literal(true),\n schemaVersion: z.literal('1.0'),\n type: z.literal('item-group'),\n id: z.string().min(1),\n title: z.string().optional(),\n slotKey: SlotKeySchema.optional(),\n stimulus: RedactedStimulusSchema,\n items: z.array(z.unknown()).min(1),\n shuffle: z.enum(['none', 'within-group']).optional(),\n })\n .check((ctx) => {\n if (groupCaptionsRevealDictation(ctx.value)) {\n ctx.issues.push({\n code: 'custom',\n input: ctx.value.stimulus.media?.captionsUrl,\n message: GROUP_CAPTIONS_REVEAL_DICTATION,\n path: ['stimulus', 'media', 'captionsUrl'],\n });\n }\n });\n\nfunction toValidationErrors(\n issues: readonly { path: PropertyKey[]; message: string; code: string }[],\n prefix: string[],\n): ValidationError[] {\n return issues.map((issue) => ({\n path: [...prefix, ...issue.path.map(String)],\n message: issue.message,\n code: issue.code,\n }));\n}\n\n/**\n * Validates an item group in full: the container and stimulus against\n * {@link ItemGroupSchema}, then every item against the schema registered for\n * its `type`. Errors from items are reported at `items.<index>.…`.\n *\n * Unlike `validateActivity`, an item whose type is not registered is REPORTED\n * (code `unknown_activity_type`) rather than thrown: a group is validated as\n * a whole, and an author fixing a six-item group wants every problem listed,\n * not the first one that happened to throw.\n */\nexport function validateItemGroup(data: unknown): ValidationResult<ItemGroup> {\n const container = ItemGroupSchema.safeParse(data);\n if (!container.success) {\n return { success: false, errors: toValidationErrors(container.error.issues, []) };\n }\n\n const errors: ValidationError[] = [];\n const items: ActivityData[] = [];\n container.data.items.forEach((item, index) => {\n const descriptor = getActivityTypeDescriptor(item.type);\n if (descriptor === undefined) {\n errors.push({\n path: ['items', String(index), 'type'],\n message: `Activity type \"${item.type}\" is not registered`,\n code: 'unknown_activity_type',\n });\n return;\n }\n const parsed = descriptor.schema.safeParse(item);\n if (!parsed.success) {\n errors.push(...toValidationErrors(parsed.error.issues, ['items', String(index)]));\n return;\n }\n items.push(parsed.data as ActivityData);\n });\n if (groupCaptionsRevealDictation(container.data)) {\n errors.push({\n path: ['stimulus', 'media', 'captionsUrl'],\n message: GROUP_CAPTIONS_REVEAL_DICTATION,\n code: 'custom',\n });\n }\n\n if (errors.length > 0) {\n return { success: false, errors };\n }\n // zod4 optional outputs are `T | undefined`; the wire type uses exact\n // optionals. Structurally identical at runtime — the cast is type-level.\n return { success: true, data: { ...container.data, items } as unknown as ItemGroup };\n}\n","import { z } from 'zod/v4';\nimport { UnknownActivityTypeError } from '../errors.js';\nimport { getActivityTypeDescriptor } from '../registry/index.js';\nimport { DictationDataSchema } from './dictation.js';\nimport { FillInTheBlanksDataSchema } from './fill-in-the-blanks.js';\nimport { GapSelectDataSchema } from './gap-select.js';\nimport { ItemGroupSchema, StimulusSchema } from './item-group.js';\nimport { MultipleChoiceDataSchema } from './multiple-choice.js';\nimport { ReadAloudDataSchema } from './read-aloud.js';\nimport { WrittenResponseDataSchema } from './written-response.js';\n\n/**\n * JSON Schema (Draft 7) for a `Stimulus`. Structural contract only — the\n * kind/media/body consistency guards are Zod-only.\n */\nexport const stimulusJsonSchema = z.toJSONSchema(StimulusSchema, { target: 'draft-7' });\n\n/**\n * JSON Schema (Draft 7) for an `ItemGroup` CONTAINER. Items appear as objects\n * with `type` and `id` only; each item's own contract is `jsonSchemaFor(type)`.\n * For an AI generation pipeline, ask for the group and each item separately\n * rather than a single nested schema — that keeps the per-type schema the\n * registry's, not a copy.\n */\nexport const itemGroupJsonSchema = z.toJSONSchema(ItemGroupSchema, { target: 'draft-7' });\n\n/**\n * JSON Schema (Draft 7) representation of the Multiple Choice activity data\n * contract, generated natively by Zod 4. Draft 7 is mandated by Requirement\n * 2.2 for the widest AI-prompt / OpenAPI tooling compatibility. The semantic\n * `.refine()` guards are not representable in JSON Schema and are\n * intentionally omitted — the export captures the *structural* contract only.\n * Since v0.3 the source schemas are loose, so these no longer emit\n * `additionalProperties: false` — the JSON Schema and `validateActivity` now\n * agree on unknown-key handling.\n */\n/**\n * JSON Schema (Draft 7) representation of the Gap Select activity data\n * contract. Structural contract only: the six semantic guards — the\n * passage/gap bijection, id uniqueness, the one-choice-source rule, and an\n * answer key that names a choice the gap offers — are Zod-only, as they are\n * for the other types.\n */\nexport const gapSelectJsonSchema = z.toJSONSchema(GapSelectDataSchema, {\n target: 'draft-7',\n});\n\n/**\n * JSON Schema (Draft 7) representation of the Dictation activity data\n * contract. Structural contract, plus the raw length caps JSON Schema can\n * state in code points as zod counts them — `maxLength` 2000 on a transcript\n * and an accepted transcript, 200 on a rule's `from` and `to`. The twelve\n * semantic guards — a transcript that survives normalisation, the caps after\n * equivalences are applied, distinct candidates, audio only, the\n * slow-recording rules, no captions, no transcript in the title — are\n * Zod-only, as they are for the other types.\n */\nexport const dictationJsonSchema = z.toJSONSchema(DictationDataSchema, {\n target: 'draft-7',\n});\n\n/**\n * JSON Schema (Draft 7) representation of the Read Aloud activity data\n * contract. Structural contract, plus the raw cap JSON Schema can state in code\n * points as zod counts them — `maxLength` 2000 on the reference text — and the\n * canonical locale pattern. The ten semantic guards — the cap after\n * normalisation, a text that survives it, the spaced-script rule, the\n * model-recording rules, unique dimensions, a weight above 0, and the take\n * bounds — are Zod-only, as they are for the other types.\n */\nexport const readAloudJsonSchema = z.toJSONSchema(ReadAloudDataSchema, {\n target: 'draft-7',\n});\n\nexport const multipleChoiceJsonSchema = z.toJSONSchema(MultipleChoiceDataSchema, {\n target: 'draft-7',\n});\n\n/**\n * JSON Schema (Draft 7) representation of the Fill-in-the-Blanks activity data\n * contract, generated natively by Zod 4. Structural contract only (semantic\n * `.refine()` guards are Zod-only and not representable in JSON Schema).\n */\nexport const fillInTheBlanksJsonSchema = z.toJSONSchema(FillInTheBlanksDataSchema, {\n target: 'draft-7',\n});\n\n/**\n * JSON Schema (Draft 7) representation of the Written Response activity data\n * contract. Structural contract only.\n */\nexport const writtenResponseJsonSchema = z.toJSONSchema(WrittenResponseDataSchema, {\n target: 'draft-7',\n});\n\n/**\n * Derives the JSON Schema (Draft 7) for any REGISTERED activity type — the\n * live, registry-backed replacement for the static per-type exports above,\n * and the building block for AI generation pipelines (R6.1): pass the result\n * as a structured-output schema so a model can only emit valid items.\n *\n * @throws UnknownActivityTypeError when `type` has no registered descriptor.\n */\nexport function jsonSchemaFor(type: string): Record<string, unknown> {\n const descriptor = getActivityTypeDescriptor(type);\n if (descriptor === undefined) {\n throw new UnknownActivityTypeError(type);\n }\n return z.toJSONSchema(descriptor.schema as never, { target: 'draft-7' }) as Record<\n string,\n unknown\n >;\n}\n","import { UnknownActivityTypeError } from '../errors.js';\nimport { getActivityTypeDescriptor } from '../registry/index.js';\nimport type { ActivityDataMap, ActivityType, ValidationResult } from '../types/activity.js';\n\nexport {\n DictationDataSchema,\n DictationEquivalenceSchema,\n DictationSlowMediaSchema,\n DictationToleranceSchema,\n} from './dictation.js';\nexport { FeedbackSchema } from './feedback.js';\nexport {\n BlankConfigSchema,\n FillInTheBlanksDataSchema,\n TextMatchPolicySchema,\n} from './fill-in-the-blanks.js';\nexport {\n GapSelectBankSchema,\n GapSelectChoiceSchema,\n GapSelectDataSchema,\n GapSelectGapSchema,\n} from './gap-select.js';\nexport type { RedactedStimulus } from './item-group.js';\nexport {\n ItemGroupSchema,\n RedactedItemGroupSchema,\n RedactedStimulusSchema,\n StimulusSchema,\n validateItemGroup,\n} from './item-group.js';\nexport {\n dictationJsonSchema,\n fillInTheBlanksJsonSchema,\n gapSelectJsonSchema,\n itemGroupJsonSchema,\n jsonSchemaFor,\n multipleChoiceJsonSchema,\n readAloudJsonSchema,\n stimulusJsonSchema,\n writtenResponseJsonSchema,\n} from './json-schema.js';\nexport {\n MediaPlaybackSchema,\n MediaSchema,\n MediaUrlSchema,\n NativeControlHintSchema,\n RedactedMediaSchema,\n} from './media.js';\nexport {\n MultipleChoiceDataSchema,\n MultipleChoiceOptionMediaSchema,\n MultipleChoiceOptionSchema,\n} from './multiple-choice.js';\nexport { ReadAloudDataSchema, ReadAloudSlowMediaSchema } from './read-aloud.js';\nexport type {\n RedactedActivity,\n RedactedBlankConfig,\n RedactedDictationData,\n RedactedDictationSlowMedia,\n RedactedFillInTheBlanksData,\n RedactedGapSelectBank,\n RedactedGapSelectChoice,\n RedactedGapSelectData,\n RedactedGapSelectGap,\n RedactedMultipleChoiceData,\n RedactedMultipleChoiceOption,\n RedactedMultipleChoiceOptionMedia,\n RedactedReadAloudData,\n RedactedWrittenResponseData,\n} from './redacted.js';\nexport {\n RedactedBlankConfigSchema,\n RedactedDictationDataSchema,\n RedactedDictationSlowMediaSchema,\n RedactedFillInTheBlanksDataSchema,\n RedactedGapSelectBankSchema,\n RedactedGapSelectChoiceSchema,\n RedactedGapSelectDataSchema,\n RedactedGapSelectGapSchema,\n RedactedMultipleChoiceDataSchema,\n RedactedMultipleChoiceOptionMediaSchema,\n RedactedMultipleChoiceOptionSchema,\n RedactedReadAloudDataSchema,\n RedactedWrittenResponseDataSchema,\n} from './redacted.js';\nexport {\n WrittenResponseDataSchema,\n WrittenResponseRubricCriterionSchema,\n WrittenResponseRubricSchema,\n} from './written-response.js';\n\n/**\n * Validates raw activity data against the schema registered for the given\n * activity type (built-in or consumer-registered via `registerActivityType`).\n *\n * Unknown keys are PRESERVED, not stripped: every built-in schema is loose,\n * so consumer sidecar fields and forward-version fields survive validation\n * verbatim in the returned `data`.\n *\n * @returns `{ success: true, data }` with the typed, validated data, or\n * `{ success: false, errors }` with one entry per failed constraint.\n * @throws UnknownActivityTypeError when `type` has no registered descriptor.\n */\nexport function validateActivity<T extends ActivityType>(\n type: T,\n data: unknown,\n): ValidationResult<ActivityDataMap[T]> {\n const descriptor = getActivityTypeDescriptor(type);\n if (descriptor === undefined) {\n throw new UnknownActivityTypeError(String(type));\n }\n\n const result = descriptor.schema.safeParse(data);\n\n if (result.success) {\n return { success: true, data: result.data as ActivityDataMap[T] };\n }\n\n return {\n success: false,\n errors: result.error.issues.map((issue) => ({\n path: issue.path.map(String),\n message: issue.message,\n code: issue.code,\n })),\n };\n}\n"],"mappings":"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","names":["z","z"]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ActivitySchemaError,
|
|
3
3
|
getActivityTypeDescriptor
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6RND47QE.js";
|
|
5
5
|
|
|
6
6
|
// src/xapi/validators.ts
|
|
7
7
|
import { z } from "zod/v4";
|
|
@@ -266,4 +266,4 @@ export {
|
|
|
266
266
|
xAPIBuilder,
|
|
267
267
|
xapiDefinitionFor
|
|
268
268
|
};
|
|
269
|
-
//# sourceMappingURL=chunk-
|
|
269
|
+
//# sourceMappingURL=chunk-R7PV3XIC.js.map
|