@intellectif/lk-core 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -2
  3. package/dist/{activity-BmjBjXVJ.d.cts → activity-f0bgZSTP.d.cts} +175 -134
  4. package/dist/{activity-BmjBjXVJ.d.ts → activity-f0bgZSTP.d.ts} +175 -134
  5. package/dist/ai-check.cjs +283 -0
  6. package/dist/ai-check.cjs.map +1 -0
  7. package/dist/ai-check.d.cts +106 -0
  8. package/dist/ai-check.d.ts +106 -0
  9. package/dist/ai-check.js +283 -0
  10. package/dist/ai-check.js.map +1 -0
  11. package/dist/chunk-BIJCO2HM.js +443 -0
  12. package/dist/chunk-BIJCO2HM.js.map +1 -0
  13. package/dist/{chunk-XNMKDFPW.cjs → chunk-COBK2RUX.cjs} +44 -9
  14. package/dist/chunk-COBK2RUX.cjs.map +1 -0
  15. package/dist/chunk-FVZJ4X3Y.cjs +443 -0
  16. package/dist/chunk-FVZJ4X3Y.cjs.map +1 -0
  17. package/dist/{chunk-WXS5EB65.js → chunk-MMEQKFFQ.js} +43 -8
  18. package/dist/chunk-MMEQKFFQ.js.map +1 -0
  19. package/dist/{index-G_KusUcK.d.ts → index-DMCM3e3P.d.ts} +1 -1
  20. package/dist/{index-CvfeD_cU.d.cts → index-bpuDhvXV.d.cts} +1 -1
  21. package/dist/index.cjs +37 -422
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +30 -11
  24. package/dist/index.d.ts +30 -11
  25. package/dist/index.js +37 -422
  26. package/dist/index.js.map +1 -1
  27. package/dist/schemas.d.cts +2 -2
  28. package/dist/schemas.d.ts +2 -2
  29. package/dist/scoring.cjs +2 -2
  30. package/dist/scoring.d.cts +33 -5
  31. package/dist/scoring.d.ts +33 -5
  32. package/dist/scoring.js +1 -1
  33. package/dist/xapi.d.cts +1 -1
  34. package/dist/xapi.d.ts +1 -1
  35. package/package.json +11 -1
  36. package/vectors/README.md +9 -3
  37. package/vectors/scoring.json +1258 -10
  38. package/dist/chunk-WXS5EB65.js.map +0 -1
  39. package/dist/chunk-XNMKDFPW.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # @intellectif/lk-core
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d576382: Groundwork under the AI line: what a call cost, what was refused, and a way to test a prompt.
8
+
9
+ - **`AiTextResult.usage`** — what the call cost, as your provider reported it to your port. It is the
10
+ same `GraderUsage` a returned grade already carries (`promptTokens`, `completionTokens`, `costUsd`),
11
+ so one shape covers help for a learner and marking by a grader. The SDK never estimates it and never
12
+ adds it up; it carries what you send, and drops a number that is not finite and zero or more rather
13
+ than let it into your totals.
14
+ - **`ai-help-refused`** joins the interaction kinds: a host can now see, in production, that its model
15
+ wrote a hint containing the answer or an explanation of a verdict the SDK did not reach. The
16
+ development console warning said it only to a developer. The event carries the feature and the
17
+ reason and **never the text** — the text of a refused hint is the answer.
18
+ - **`@intellectif/lk-core/ai-check`, a new entry point: a test kit for your prompts.** `runAiCheck`
19
+ makes the calls a learner's questions would make — on items whose answers the SDK knows — and runs
20
+ the same checks it runs before a learner sees anything, reporting what would have been shown and
21
+ what would have been refused, with the reason. `aiCheckCases` hands over the cases (every type it
22
+ explains, answered right, wrong and partly right; every type it hints for, before an answer and
23
+ after a wrong one), and takes cases of your own built on your own items. `formatAiCheckReport`
24
+ prints a run for a CI log.
25
+
26
+ It is a separate entry point because it is for your test run, not a learner's browser: nothing a
27
+ page renders imports it.
28
+
29
+ The empty `@intellectif/lk-ai` placeholder has been deleted — it never held code, and the ports it
30
+ was planned for live here, beside the scorers whose readings the checks are.
31
+
32
+ See `docs/ai.md`.
33
+
34
+ ## 0.18.0
35
+
36
+ ### Minor Changes
37
+
38
+ - 71de78d: The grade return trip now checks its numbers. A returned grade that cannot be a grade — `NaN`, `Infinity`, a negative, 85 "out of 1", any score out of 0 — no longer composes to a final result. **This can change a recorded grade**, for exactly that population, and for nothing else.
39
+
40
+ **What was wrong.** `evaluate` refuses a non-finite score and `gradeFromRubric` refuses one outside `[0,1]`, but the path a deferred grade comes back on took any number on trust. `outcomeFromGrade` mirrored whatever `GradeRecord` it was handed, and `composeAssessmentScore` divided by it, reading a `maxScore` of 0 or less as 1. Composed beside one full-marks item:
41
+
42
+ - a grader that returned raw points (`score: 85, maxScore: 1`) gave a **final** result, score 43, passed;
43
+ - `score: NaN` gave a final `NaN` score — `null` once stored as JSON — beside a section marked passed;
44
+ - `score: Infinity` gave a final pass;
45
+ - `score: 0.5, maxScore: 0` was counted as 0.5 out of 1.
46
+
47
+ **What changes.**
48
+
49
+ - **`outcomeFromGrade`** lifts a record only when `maxScore` is a positive, finite number and `score` a finite number from 0 to it. A score up to one part in a billion above `maxScore` is float noise (`1.0000000000000002` from summing weighted parts) and passes as it is, unclamped. The tolerance is relative, and it is the bound lk-react's grade reader already applies before it shows a grade, so the two packages agree on what a grade is. There is no tolerance below 0: nothing the SDK produces is negative. A record that fails comes back as `{ status: 'deferred', reason: 'grade_rejected', maxScore: 1, rejectedGrade }`. The record is kept verbatim for audit, and nothing on it is mirrored, so its `passed` is never read as a verdict. `hasGrade` is `false` for it. A valid record lifts exactly as before.
50
+ - **`composeAssessmentScore`** applies the same check to every `scored` and `graded` outcome, including ones an earlier release stored. It treats a failing outcome, and a `grade_rejected` one, as still owed a grade: out of the denominator, listed in `pendingSlotIds`, and also in a new **`rejectedSlotIds`** on the result and on its section. The result stays `provisional` with `passed: null`.
51
+ - **`aiExplanationRequest`** returns `null` for a `scored` grade of record that fails the same check. In 0.17.0 it handed the numbers to the model as they were: 85 "out of 1" on a wrong multiple-choice answer went as `category: 'correct'`, passed; `NaN` went as a `null` score. Grading locally instead would speak to a grade the screen does not show, so there is no explanation at all, and lk-react's "Explain my answer" reads "No explanation is available right now." without calling the port.
52
+ - **`gradeFromRubric`** refuses a criterion weight below 0 as `unscorable`, and likewise a weight sum that overflows to Infinity. It checked only that the weights summed to a positive number, so `[0 ×2, 1 ×−1]` came back as a real 0 and `[1 ×2, 0 ×−1]` as a perfect 1, the clamp meant for float noise hiding both. Only criteria that enter the arithmetic are read, as for `score` and `maxScore`. A rubric already refused for its weight sum keeps the reason it always had. `[1 ×1e308, 0 ×1e308]` used to grade 0: its sum is Infinity, and every criterion divided into it.
53
+
54
+ **Why deferred, and not unscorable.** `unscorable` means a grade is never coming. `composeAssessmentScore` leaves such a slot out of the denominator and still lets the attempt go `final` with a verdict. A failing grade on the wrong scale would vanish, and the rest of the paper would be recorded as a pass. `deferred` is what the slot is: a real grade is still owed. Every reader that already treats `deferred` as not yet, lk-react's components included, stays safe without knowing the new reason. `rejectedSlotIds` tells a host to re-grade rather than wait. A rejected slot is also listed in `pendingSlotIds`, so `status` is `provisional` exactly when `pendingSlotIds` is non-empty, the rule it has always followed.
55
+
56
+ **One deliberate reversal.** A `maxScore` of 0 or less used to be read as 1 rather than divided by. A unit test and one grade-stability vector pinned that on purpose, to stop a division by zero. Dividing is not the only alternative: nothing is out of 0, so reading 0.5 against a denominator nobody declared invents a grade. Such an outcome is now rejected like any other score that cannot be a grade. The test is rewritten to say so, and the vector `composeAssessmentScore/item-max-score-zero` is re-frozen.
57
+
58
+ **What stays the same.** Of the 564 vectors in the grade-stability corpus, 563 replay byte for byte against this build, both ESM and CJS, and the one that moves is the reversal above. Every input that was a grade composes exactly as before. `rejectedSlotIds` is present only when something was rejected, so a result without one is identical to what earlier releases returned. 28 new vectors cover each refused kind of number, the accepted float noise up to its exact edge, and the new rubric refusals.
59
+
60
+ **Before you upgrade.** Search stored outcomes for a `score` or `maxScore` that is not a finite number (in JSON, `null`), a `maxScore` of 0 or less, or a `score` below 0 or above its `maxScore`. Re-composing an attempt that holds one now returns `provisional` rather than the final result you recorded. Re-grade those slots. `DeferredReason` gains `'grade_rejected'`, so an exhaustive `switch` over it needs a case. The new fields are optional.
61
+
62
+ lk-react needs no code change. A record `outcomeFromGrade` now refuses renders as "Not graded yet.", like any deferred outcome. With lk-react 17, `<WrittenResponse>` showed such a record's numbers ("Score 8500%. Passed." for 85 out of 1). A `graded` outcome stored by an earlier release is not re-read, and still renders as before.
63
+
3
64
  ## 0.17.0
4
65
 
5
66
  ### Minor Changes
package/README.md CHANGED
@@ -23,8 +23,8 @@ npm install @intellectif/lk-core
23
23
  - **Scoring engine** — `score(activityType, data, response)` returning `{ score ∈ [0,1], maxScore: 1, passed, feedback, details }`. Pure, deterministic, 100 % test coverage enforced. It **throws** rather than inventing a number: `DeferredScoringError` for a deferred-graded type (`written-response`), and `RedactedScoringError` when handed a `redact()` projection, which has no answer key. Both are on the documented exam path — use `evaluate()` there, which returns `deferred` / `unscorable` instead of throwing.
24
24
  - **Dictation marking as data** — a dictation is graded by the character-level similarity of the typed text to the transcript, with one `ScoringDetail` per word carrying its own `score`. `alignDictation(data, text)` returns the same comparison the grade was made of (normalised strings, word pairings, which accepted transcript won), `diffDictationChars(reference, attempt)` the character-level edits for a marked display, and `dictationReferenceWords(data)` the ids and words a stored result can be checked against — so a review screen or an analytics job never re-implements the arithmetic. `score()` and `evaluate()` take an optional `{ rounding }` to compare the pass line as displayed.
25
25
  - **`evaluate(data, response)` → `ItemOutcome`** — the resilient result: `scored`, `deferred` (graded later by an AI or a human), `graded` (the grade came back), or `unscorable`. "Not graded yet" is expressible in the type system and is never conflated with a zero.
26
- - **Deferred grading, both directions** — `GradeRecord`, `gradeFromRubric()` (the weighted total as a pure function of the grader's judgements — never the model's arithmetic; declare `maxScore` per criterion if your grader works out of 100 or out of 9), `outcomeFromGrade()`, `hasGrade()`.
27
- - **Assessment composition** — `composeAssessmentScore()` for weighted, sectioned totals, with a `provisional` status so an unmarked essay never deflates a grade, and `RoundingPolicy` / `roundGrade` / `gte` / `classifyBand` keeping grade rounding and band classification as the two distinct operations they are.
26
+ - **Deferred grading, both directions** — `GradeRecord`, `gradeFromRubric()` (the weighted total as a pure function of the grader's judgements — never the model's arithmetic; declare `maxScore` per criterion if your grader works out of 100 or out of 9), `outcomeFromGrade()` (which refuses a record whose numbers cannot be a grade instead of mirroring them), `hasGrade()`.
27
+ - **Assessment composition** — `composeAssessmentScore()` for weighted, sectioned totals, with a `provisional` status so an unmarked essay never deflates a grade — and a grade out of contract (`NaN`, 85 "out of 1") never finalises one — and `RoundingPolicy` / `roundGrade` / `gte` / `classifyBand` keeping grade rounding and band classification as the two distinct operations they are.
28
28
  - **Item groups** — `Stimulus` + `ItemGroup` (one passage or recording serving several questions), `flattenSequence()` for seeded, shuffle-atomic presentation order with stable slot identity. Slot ids are positional by default (`"3"`, `"1.0"`); author a `slotKey` on an entry and its id survives an insert, so a stored response cannot slide onto a different question.
29
29
  - **Attempt plans** — `planAttempt(entries, { seed, shuffleEntries, points })` freezes the exact paper a learner sat: slot order, per-slot points, a content hash of each item, and a `planHash` over the lot. `verifyAttemptPlan(plan, current)` reports the drift when that content is later edited (`changedSlotIds`, `changedStimulusSlotIds`, `changedPointsSlotIds`, `missingSlotIds`, …) — the thing you need during a remark or an appeal. `scoredItemsFromPlan(plan, outcomes)` turns collected outcomes into the `ScoredItem[]` composition wants, and defaults a slot with **no** outcome to `deferred` rather than silently shrinking the denominator.
30
30
  - **Attempt state** — `serializeAttemptState(plan, progress)` / `restoreAttemptState(plan, snapshot)` round-trip an interrupted attempt (responses, submitted slots, position) and are **bound to the plan**: restoring answers onto a paper the learner never sat throws instead of succeeding quietly. `diffResponses(before, after)` reports what moved between two snapshots, including an answer the learner *cleared*.
@@ -115,6 +115,7 @@ result.passed; // boolean, or null while provisional. Never record a provision
115
115
  | `@intellectif/lk-core/schemas` | Zod schemas + `validateActivity` + JSON Schema export |
116
116
  | `@intellectif/lk-core/scoring` | `score`, `evaluate`, `composeAssessmentScore`, rounding, text matching |
117
117
  | `@intellectif/lk-core/xapi` | `xAPIBuilder`, `XAPIVerb`, `validateXAPIStatement` |
118
+ | `@intellectif/lk-core/ai-check` | `runAiCheck`, `aiCheckCases`, `formatAiCheckReport` — test your AI prompts in CI |
118
119
 
119
120
  The barrel re-exports everything, and it is the **only** entry point for attempt
120
121
  plans, attempt state, item groups, redaction, the type registry, authoring drafts
@@ -63,6 +63,147 @@ declare function levenshteinDistance(a: string, b: string, max: number): number;
63
63
  */
64
64
  declare function matchText(input: string, accepted: string | readonly string[], policy?: TextMatchPolicy): TextMatchResult;
65
65
 
66
+ /**
67
+ * The return trip for deferred grading.
68
+ *
69
+ * `evaluate()` can say a submission is `deferred` — graded later by an AI or a
70
+ * human — but until now there was no type for the grade that comes BACK, so
71
+ * every consumer invented one. The shapes here are the intersection of two
72
+ * independent production graders (a rubric-based essay grader and a CEFR
73
+ * speaking grader) that converged on the same envelope: a normalised total,
74
+ * per-criterion scores with comments, grader-authored artefacts, a confidence
75
+ * signal, an explicit human-review flag, and provenance.
76
+ *
77
+ * The SDK models the SHAPE and the arithmetic. It never calls a model, never
78
+ * holds a key, and never decides what a rubric means — that stays yours.
79
+ */
80
+
81
+ /**
82
+ * Lifecycle of a deferred grade. A grade exists only in the `graded` state;
83
+ * every other state means "no grade, and here is why" — which is precisely the
84
+ * distinction that stops an ungraded submission being rendered as a zero.
85
+ */
86
+ type GradingState = 'queued' | 'running' | 'graded' | 'failed' | 'skipped';
87
+ /**
88
+ * Who produced a grade.
89
+ *
90
+ * - `'auto'` — a non-generative measurement engine, for example a
91
+ * pronunciation-assessment service.
92
+ * - `'ai'` — a generative model.
93
+ * - `'human'` — a person.
94
+ */
95
+ type GraderKind = 'ai' | 'human' | 'auto';
96
+ /** Provenance of a grade, so a re-grade two years later is explicable. */
97
+ interface Grader {
98
+ kind: GraderKind;
99
+ /** Identifier of the human grader, when `kind` is `'human'`. */
100
+ id?: string;
101
+ /** Model identifier, when `kind` is `'ai'` (e.g. a model name and version). */
102
+ model?: string;
103
+ /** Hash or id of the prompt/rubric revision used, for auditability. */
104
+ promptHash?: string;
105
+ }
106
+ /** Optional cost/usage telemetry emitted by an AI grader. */
107
+ interface GraderUsage {
108
+ promptTokens?: number;
109
+ completionTokens?: number;
110
+ costUsd?: number;
111
+ }
112
+ /** A grader's verdict on one rubric criterion. */
113
+ interface CriterionScore {
114
+ /** Matches a `WrittenResponseRubricCriterion.name` when a rubric is known. */
115
+ name: string;
116
+ /**
117
+ * The grader's score for this criterion, out of {@link CriterionScore.maxScore}
118
+ * (which defaults to 1, the SDK's scaled convention). Absent when the
119
+ * criterion is judged on an ordinal `band`.
120
+ */
121
+ score?: number;
122
+ /**
123
+ * What `score` is out of. Defaults to `1`.
124
+ *
125
+ * Real graders rarely work in [0,1]: rubrics come back out of 100, out of 9
126
+ * (CEFR/IELTS bands), or out of a points total that differs between
127
+ * criteria. Before this field the only way to use {@link gradeFromRubric}
128
+ * was to normalise by hand first — and a grader that skipped that step got
129
+ * its scores REJECTED, which pushed integrators back to letting the model
130
+ * compute the weighted total itself. That is precisely the arithmetic this
131
+ * SDK exists to take away from a language model.
132
+ *
133
+ * Set it per criterion, so a rubric mixing a /100 criterion with a /9 band
134
+ * score still composes. `gradeFromRubric` normalises `score / maxScore`
135
+ * before weighting; the resulting ratio must land in [0,1].
136
+ */
137
+ maxScore?: number;
138
+ /** Ordinal verdict when the rubric is banded rather than numeric (e.g. `"B1"`). */
139
+ band?: string;
140
+ /** The grader's comment for this criterion, addressed to the learner. */
141
+ comment?: string;
142
+ /**
143
+ * Weight actually applied, echoed from the rubric so the total is checkable.
144
+ * Defaults to `1`. Zero or more: `gradeFromRubric` refuses a negative weight
145
+ * rather than let it carry the total outside [0,1].
146
+ */
147
+ weight?: number;
148
+ /** Not applicable to this submission (e.g. interaction on a monologue task). */
149
+ notApplicable?: boolean;
150
+ }
151
+ /** A suggested correction anchored in the learner's own text. */
152
+ interface InlineCorrection {
153
+ /** Verbatim span from the submission. */
154
+ original: string;
155
+ /** Suggested replacement. */
156
+ corrected: string;
157
+ explanation?: string;
158
+ /** Character offsets into the submitted text, so a UI can anchor it inline. */
159
+ range?: {
160
+ start: number;
161
+ end: number;
162
+ };
163
+ /** Error-type tag (`"article"`, `"tense"`, `"register"`). */
164
+ category?: string;
165
+ }
166
+ /**
167
+ * A grade that came back from an asynchronous grader. `score` is scaled
168
+ * [0,1] against `maxScore`, matching every other score in the SDK. A grader
169
+ * that works in points does not have to normalise by hand: declare
170
+ * `maxScore` on each {@link CriterionScore} and let `gradeFromRubric` do the
171
+ * arithmetic, which is the whole point of handing it the judgements.
172
+ *
173
+ * `maxScore` is a positive, finite number and `score` a finite number from 0
174
+ * to it. `outcomeFromGrade` refuses a record that is not, rather than mirror
175
+ * numbers nobody can defend onto an outcome.
176
+ */
177
+ interface GradeRecord {
178
+ score: number;
179
+ maxScore: number;
180
+ passed: boolean;
181
+ /** Narrative feedback addressed to the learner. */
182
+ feedback: string | null;
183
+ /** Per-criterion breakdown, when the grader worked against a rubric. */
184
+ criteria?: CriterionScore[];
185
+ /**
186
+ * Per-item marks carried by a grade — for example the per-word marks of a
187
+ * read-aloud grade. `outcomeFromGrade` does not mirror them onto the
188
+ * outcome; read `outcome.grade.details`.
189
+ */
190
+ details?: ScoringDetail[];
191
+ /** Corrections anchored in the learner's text. */
192
+ corrections?: InlineCorrection[];
193
+ /** Supporting observations the grader cited. */
194
+ evidence?: string[];
195
+ /** How the grader reached this verdict. */
196
+ rationale?: string;
197
+ /** The grader's own confidence — a signal for routing to human review. */
198
+ confidence?: 'high' | 'medium' | 'low';
199
+ /** The grader is unsure or the submission is atypical; route to a human. */
200
+ requiresHumanReview?: boolean;
201
+ grader?: Grader;
202
+ usage?: GraderUsage;
203
+ /** ISO 8601 timestamp of when the grade was produced. */
204
+ gradedAt?: string;
205
+ }
206
+
66
207
  /**
67
208
  * The contract between an activity and a model the HOST runs, for help a
68
209
  * learner reads: an explanation of a graded answer, and hints before one.
@@ -72,6 +213,7 @@ declare function matchText(input: string, accepted: string | readonly string[],
72
213
  * SDK's own scorer — and checks what comes back. The model explains a verdict
73
214
  * the SDK reached; it never reaches one.
74
215
  */
216
+
75
217
  /**
76
218
  * What an author allows AI to do for one item. Absent, or a feature left out,
77
219
  * leaves the choice to the deployment. `false` switches a feature off for this
@@ -250,142 +392,20 @@ interface AiTextResult {
250
392
  /** For an explanation: the verdict the model explained. */
251
393
  verdict?: AiVerdict;
252
394
  provenance?: AiProvenance;
253
- }
254
- /** Why a result was not shown. */
255
- type AiRefusal = 'malformed' | 'empty' | 'too-long' | 'contradicts-grade' | 'reveals-answer';
256
-
257
- /**
258
- * The return trip for deferred grading.
259
- *
260
- * `evaluate()` can say a submission is `deferred` — graded later by an AI or a
261
- * human — but until now there was no type for the grade that comes BACK, so
262
- * every consumer invented one. The shapes here are the intersection of two
263
- * independent production graders (a rubric-based essay grader and a CEFR
264
- * speaking grader) that converged on the same envelope: a normalised total,
265
- * per-criterion scores with comments, grader-authored artefacts, a confidence
266
- * signal, an explicit human-review flag, and provenance.
267
- *
268
- * The SDK models the SHAPE and the arithmetic. It never calls a model, never
269
- * holds a key, and never decides what a rubric means — that stays yours.
270
- */
271
-
272
- /**
273
- * Lifecycle of a deferred grade. A grade exists only in the `graded` state;
274
- * every other state means "no grade, and here is why" — which is precisely the
275
- * distinction that stops an ungraded submission being rendered as a zero.
276
- */
277
- type GradingState = 'queued' | 'running' | 'graded' | 'failed' | 'skipped';
278
- /**
279
- * Who produced a grade.
280
- *
281
- * - `'auto'` — a non-generative measurement engine, for example a
282
- * pronunciation-assessment service.
283
- * - `'ai'` — a generative model.
284
- * - `'human'` — a person.
285
- */
286
- type GraderKind = 'ai' | 'human' | 'auto';
287
- /** Provenance of a grade, so a re-grade two years later is explicable. */
288
- interface Grader {
289
- kind: GraderKind;
290
- /** Identifier of the human grader, when `kind` is `'human'`. */
291
- id?: string;
292
- /** Model identifier, when `kind` is `'ai'` (e.g. a model name and version). */
293
- model?: string;
294
- /** Hash or id of the prompt/rubric revision used, for auditability. */
295
- promptHash?: string;
296
- }
297
- /** Optional cost/usage telemetry emitted by an AI grader. */
298
- interface GraderUsage {
299
- promptTokens?: number;
300
- completionTokens?: number;
301
- costUsd?: number;
302
- }
303
- /** A grader's verdict on one rubric criterion. */
304
- interface CriterionScore {
305
- /** Matches a `WrittenResponseRubricCriterion.name` when a rubric is known. */
306
- name: string;
307
395
  /**
308
- * The grader's score for this criterion, out of {@link CriterionScore.maxScore}
309
- * (which defaults to 1, the SDK's scaled convention). Absent when the
310
- * criterion is judged on an ordinal `band`.
311
- */
312
- score?: number;
313
- /**
314
- * What `score` is out of. Defaults to `1`.
315
- *
316
- * Real graders rarely work in [0,1]: rubrics come back out of 100, out of 9
317
- * (CEFR/IELTS bands), or out of a points total that differs between
318
- * criteria. Before this field the only way to use {@link gradeFromRubric}
319
- * was to normalise by hand first — and a grader that skipped that step got
320
- * its scores REJECTED, which pushed integrators back to letting the model
321
- * compute the weighted total itself. That is precisely the arithmetic this
322
- * SDK exists to take away from a language model.
396
+ * What the call cost, as the provider reported it to your port: the same
397
+ * {@link GraderUsage} a returned grade carries, so one shape covers help for
398
+ * a learner and marking by a grader.
323
399
  *
324
- * Set it per criterion, so a rubric mixing a /100 criterion with a /9 band
325
- * score still composes. `gradeFromRubric` normalises `score / maxScore`
326
- * before weighting; the resulting ratio must land in [0,1].
327
- */
328
- maxScore?: number;
329
- /** Ordinal verdict when the rubric is banded rather than numeric (e.g. `"B1"`). */
330
- band?: string;
331
- /** The grader's comment for this criterion, addressed to the learner. */
332
- comment?: string;
333
- /** Weight actually applied, echoed from the rubric so the total is checkable. */
334
- weight?: number;
335
- /** Not applicable to this submission (e.g. interaction on a monologue task). */
336
- notApplicable?: boolean;
337
- }
338
- /** A suggested correction anchored in the learner's own text. */
339
- interface InlineCorrection {
340
- /** Verbatim span from the submission. */
341
- original: string;
342
- /** Suggested replacement. */
343
- corrected: string;
344
- explanation?: string;
345
- /** Character offsets into the submitted text, so a UI can anchor it inline. */
346
- range?: {
347
- start: number;
348
- end: number;
349
- };
350
- /** Error-type tag (`"article"`, `"tense"`, `"register"`). */
351
- category?: string;
352
- }
353
- /**
354
- * A grade that came back from an asynchronous grader. `score` is scaled
355
- * [0,1] against `maxScore`, matching every other score in the SDK. A grader
356
- * that works in points does not have to normalise by hand: declare
357
- * `maxScore` on each {@link CriterionScore} and let `gradeFromRubric` do the
358
- * arithmetic, which is the whole point of handing it the judgements.
359
- */
360
- interface GradeRecord {
361
- score: number;
362
- maxScore: number;
363
- passed: boolean;
364
- /** Narrative feedback addressed to the learner. */
365
- feedback: string | null;
366
- /** Per-criterion breakdown, when the grader worked against a rubric. */
367
- criteria?: CriterionScore[];
368
- /**
369
- * Per-item marks carried by a grade — for example the per-word marks of a
370
- * read-aloud grade. `outcomeFromGrade` does not mirror them onto the
371
- * outcome; read `outcome.grade.details`.
400
+ * The SDK never estimates it and never adds it up. It carries what a port
401
+ * sends through to `onInteraction`, beside the provenance, so the record of
402
+ * what a learner was shown and the record of what it cost are the same
403
+ * record. Numbers that are not finite and zero or more are dropped.
372
404
  */
373
- details?: ScoringDetail[];
374
- /** Corrections anchored in the learner's text. */
375
- corrections?: InlineCorrection[];
376
- /** Supporting observations the grader cited. */
377
- evidence?: string[];
378
- /** How the grader reached this verdict. */
379
- rationale?: string;
380
- /** The grader's own confidence — a signal for routing to human review. */
381
- confidence?: 'high' | 'medium' | 'low';
382
- /** The grader is unsure or the submission is atypical; route to a human. */
383
- requiresHumanReview?: boolean;
384
- grader?: Grader;
385
405
  usage?: GraderUsage;
386
- /** ISO 8601 timestamp of when the grade was produced. */
387
- gradedAt?: string;
388
406
  }
407
+ /** Why a result was not shown. */
408
+ type AiRefusal = 'malformed' | 'empty' | 'too-long' | 'contradicts-grade' | 'reveals-answer';
389
409
 
390
410
  /**
391
411
  * An xAPI Activity object — the thing a statement is about.
@@ -1388,8 +1408,13 @@ interface DeferredScoringPartial {
1388
1408
  * a grade has not landed, or the attempt is still open has earned nothing
1389
1409
  * yet, and recording it as zero is how an incomplete attempt becomes a
1390
1410
  * plausible-looking fail.
1411
+ * - `grade_rejected` — a grade came back, and `outcomeFromGrade` refused it
1412
+ * because its numbers cannot be a grade (a `score` or `maxScore` that is not
1413
+ * a finite number, a `maxScore` of 0 or less, or a score outside 0 to
1414
+ * `maxScore`). The record is kept on `rejectedGrade`. A real grade is still
1415
+ * owed, so re-grade the slot: waiting will not bring one.
1391
1416
  */
1392
- type DeferredReason = 'requires_async_grading' | 'no_response_recorded';
1417
+ type DeferredReason = 'requires_async_grading' | 'no_response_recorded' | 'grade_rejected';
1393
1418
  /**
1394
1419
  * The outcome of evaluating a learner response against an activity — the
1395
1420
  * union `evaluate()` returns. Unlike {@link ScoringResult}, it can express
@@ -1416,6 +1441,12 @@ type ItemOutcome = {
1416
1441
  maxScore: number;
1417
1442
  /** Synchronously computable progress facts (word bounds, counts). */
1418
1443
  partial?: DeferredScoringPartial;
1444
+ /**
1445
+ * The record `outcomeFromGrade` refused, verbatim, when `reason` is
1446
+ * `grade_rejected` — kept for audit and for the re-grade. Nothing on it
1447
+ * is mirrored onto the outcome, and nothing reads it as a grade.
1448
+ */
1449
+ rejectedGrade?: GradeRecord;
1419
1450
  } | {
1420
1451
  status: 'graded';
1421
1452
  /**
@@ -1530,8 +1561,18 @@ type InteractionKind = 'option-selected' | 'option-deselected' | 'blank-filled'
1530
1561
  * is never shown and never reported here.
1531
1562
  */
1532
1563
  | 'ai-hint-shown'
1533
- /** An AI explanation of the graded answer was shown (`provenance` when sent). */
1534
- | 'ai-explanation-shown' | (string & {});
1564
+ /** An AI explanation of the graded answer was shown (`provenance`, `usage` when sent). */
1565
+ | 'ai-explanation-shown'
1566
+ /**
1567
+ * An AI answer was refused, and the learner was told help was not available
1568
+ * (`feature`, `reason` — an {@link AiRefusal} — and `hintNumber` for a hint).
1569
+ *
1570
+ * **The text itself is never carried**: a hint refused for revealing the
1571
+ * answer contains the answer, and this event is logged. It is how a host
1572
+ * watches its own model in production, where the development console warning
1573
+ * it accompanies is silent.
1574
+ */
1575
+ | 'ai-help-refused' | (string & {});
1535
1576
  /** Fired by activity components on every discrete learner interaction. */
1536
1577
  interface InteractionEvent {
1537
1578
  /** The kind of interaction that occurred. */