@intellectif/lk-core 0.13.1 → 0.14.1

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 (57) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/dist/{activity-DfAmJ1sl.d.cts → activity-BVkg9VDo.d.cts} +153 -4
  4. package/dist/{activity-DfAmJ1sl.d.ts → activity-BVkg9VDo.d.ts} +153 -4
  5. package/dist/{chunk-DQKJUMUW.cjs → chunk-2NIALQEH.cjs} +24 -19
  6. package/dist/chunk-2NIALQEH.cjs.map +1 -0
  7. package/dist/{chunk-XKKKWKRH.js → chunk-6RND47QE.js} +694 -126
  8. package/dist/chunk-6RND47QE.js.map +1 -0
  9. package/dist/{chunk-FT7SAC3X.cjs → chunk-E7LPIZXI.cjs} +13 -10
  10. package/dist/chunk-E7LPIZXI.cjs.map +1 -0
  11. package/dist/chunk-J5OIRIOW.cjs +1034 -0
  12. package/dist/chunk-J5OIRIOW.cjs.map +1 -0
  13. package/dist/{chunk-PYP3HCRQ.js → chunk-KTDOZR7Y.js} +7 -4
  14. package/dist/chunk-KTDOZR7Y.js.map +1 -0
  15. package/dist/{chunk-Y36S3X3L.js → chunk-NBADQ3JU.js} +7 -2
  16. package/dist/chunk-NBADQ3JU.js.map +1 -0
  17. package/dist/chunk-RTIR6R2U.js +1034 -0
  18. package/dist/chunk-RTIR6R2U.js.map +1 -0
  19. package/dist/{chunk-XONKWL6D.cjs → chunk-VCXIPLEW.cjs} +585 -17
  20. package/dist/chunk-VCXIPLEW.cjs.map +1 -0
  21. package/dist/{index-BefQ8FAS.d.cts → index-BE1bM2Wi.d.cts} +238 -16
  22. package/dist/{index-BiIvdASA.d.ts → index-v2_-Fyn3.d.ts} +238 -16
  23. package/dist/index.cjs +110 -80
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +45 -17
  26. package/dist/index.d.ts +45 -17
  27. package/dist/index.js +35 -5
  28. package/dist/index.js.map +1 -1
  29. package/dist/schemas.cjs +11 -3
  30. package/dist/schemas.cjs.map +1 -1
  31. package/dist/schemas.d.cts +2 -2
  32. package/dist/schemas.d.ts +2 -2
  33. package/dist/schemas.js +10 -2
  34. package/dist/scoring.cjs +23 -3
  35. package/dist/scoring.cjs.map +1 -1
  36. package/dist/scoring.d.cts +471 -16
  37. package/dist/scoring.d.ts +471 -16
  38. package/dist/scoring.js +24 -4
  39. package/dist/xapi.cjs +3 -3
  40. package/dist/xapi.d.cts +1 -1
  41. package/dist/xapi.d.ts +1 -1
  42. package/dist/xapi.js +2 -2
  43. package/package.json +1 -1
  44. package/vectors/README.md +63 -3
  45. package/vectors/replay.d.mts +7 -2
  46. package/vectors/replay.mjs +83 -1
  47. package/vectors/scoring.json +10875 -2
  48. package/dist/chunk-DQKJUMUW.cjs.map +0 -1
  49. package/dist/chunk-FGN4LLSP.js +0 -387
  50. package/dist/chunk-FGN4LLSP.js.map +0 -1
  51. package/dist/chunk-FT7SAC3X.cjs.map +0 -1
  52. package/dist/chunk-PYP3HCRQ.js.map +0 -1
  53. package/dist/chunk-UHQVIC3P.cjs +0 -387
  54. package/dist/chunk-UHQVIC3P.cjs.map +0 -1
  55. package/dist/chunk-XKKKWKRH.js.map +0 -1
  56. package/dist/chunk-XONKWL6D.cjs.map +0 -1
  57. package/dist/chunk-Y36S3X3L.js.map +0 -1
package/vectors/README.md CHANGED
@@ -70,7 +70,31 @@ a known hazard carry a `note` saying why they exist.
70
70
  edges, negative zero, half-even ties and near-ties, and bands that share a
71
71
  minimum.
72
72
  - **`countWords`**, **`seededShuffle`** in both versions, **`gradeFromRubric`**
73
- including its unscorable paths, and **`outcomeFromGrade`**.
73
+ including its unscorable paths, its opt-in `rounding` option on each of the
74
+ three ways a pass is decided, and **`outcomeFromGrade`**.
75
+ - **`gradeReadAloud`**: every reason a pronunciation assessment is refused
76
+ rather than scored — each `unscorable` code — the weighted total over the
77
+ authored dimensions, a blank take, a dimension scored `0` against one that was
78
+ not scored at all, the pass line with and without rounding, and the per-word
79
+ marks in `details`, which are for review and never feed the score.
80
+ - **`alignReadAloud`**, which decides the reference word each mark lands on:
81
+ omissions, insertions and mispronunciations, a repeated word, an assessor word
82
+ that normalises to no token or to two, punctuation that merges two words into
83
+ one, and both `miscue` settings. **`validateSpeechAssessment`** with the paths
84
+ and codes an adapter branches on — including the two totals that bound the
85
+ text of an assessment, at the bound and one character past it — and
86
+ **`outcomeFromUnscorable`**, which keeps a refusal's code in the stored
87
+ outcome.
88
+ - **`validateActivity`**, for the one rule that decides whether an item can be
89
+ stored at all rather than how an answer scores: a read-aloud `referenceText`
90
+ that survives normalisation as nothing. An item that marks no word grades a
91
+ silent take at 0 with no omission recorded and a perfect reading as a run of
92
+ insertions, so loosening that refusal would move grades.
93
+ - **`inspectWav`** and the **`READ_ALOUD_*`** and `SPEECH_ASSESSMENT_MAX_WORDS`
94
+ limits: what a recording's duration, peak level and voiced time are measured
95
+ as, across silence, a tone, two channels, two sample rates, a partial last
96
+ window, an odd chunk's pad byte and WAVE_FORMAT_EXTENSIBLE, and which files
97
+ are reported unread instead of guessed at.
74
98
  - **`scoredItemsFromPlan`**, which decides what a stored paper's denominator is
75
99
  when an outcome is missing, and **`composeAssessmentScore`** across weights,
76
100
  points, provisional and unscorable items, and section thresholds.
@@ -101,5 +125,41 @@ tagged: `{ "$number": "NaN" | "Infinity" | "-Infinity" | "-0" }` and
101
125
  `\uXXXX` escape, so a no-break space can never pass for an ordinary one.
102
126
  Comparison ignores object key order.
103
127
 
104
- `corpusVersion` changes only if this encoding does. Adding vectors does not
105
- change it, so always load the `replay.mjs` that sits beside the corpus.
128
+ A recording is bytes, which JSON cannot express either, so a `Uint8Array` is
129
+ tagged `{ "$bytes": "<base64>" }` and comes back from `decode` as a fresh one.
130
+ Any other view of an `ArrayBuffer` is refused rather than tagged: JSON would
131
+ store it as `{"0":82,"1":73}`, which replays as an ordinary object, and the call
132
+ under test would be handed something that is not a byte array at all. The base64
133
+ is computed in `replay.mjs` itself, so the file needs no `Buffer` and still runs
134
+ wherever your tests run.
135
+
136
+ `corpusVersion` changes only if this encoding does — it is **2** since the
137
+ `$bytes` tag was added, and no expectation changed with it. Adding vectors does
138
+ not change it, so always load the `replay.mjs` that sits beside the corpus: an
139
+ older one throws rather than replaying a corpus it cannot read.
140
+
141
+ ## `frozenFrom`
142
+
143
+ `frozenFrom` is **the version of `@intellectif/lk-core` the corpus was last
144
+ regenerated at** — the package version in `package.json` when someone ran
145
+ `node scripts/generate-vectors.mjs`. That is all it is.
146
+
147
+ **It is not the version this corpus ships in, and it is not expected to match
148
+ it.** `changeset version` bumps the package without regenerating the corpus, so
149
+ a release almost always publishes a corpus whose `frozenFrom` names the previous
150
+ version. That is correct and wanted: the string records when these expectations
151
+ were last recomputed, and a release that changes no expectation should not
152
+ rewrite them.
153
+
154
+ It is deliberately not asserted anywhere. `replay()` never reads it — it walks
155
+ `vectors` alone, comparing each frozen call with what your build returns — and
156
+ the generator's own grade-change check compares only each vector's call and
157
+ expectation, so a `frozenFrom` one version behind can never fail a grade gate.
158
+ (`corpusVersion` is different: `replay()` does check that one, and throws when
159
+ it does not recognise the encoding.) Read `frozenFrom` as a date stamp, not as a
160
+ package label. The version that matters for "which build produced this grade" is
161
+ the one you installed; record that yourself beside any grade you store.
162
+
163
+ Adding a regenerate-after-version step to the release was considered and
164
+ rejected: it would put a step that can fail *after* the version bump, which is
165
+ strictly worse than a string that reads one version behind.
@@ -1,7 +1,12 @@
1
1
  /** Types for `replay.mjs`. See that file for the contract and the encoding. */
2
2
 
3
- /** The encoding version this replayer reads. */
4
- export declare const CORPUS_VERSION: 1;
3
+ /**
4
+ * The encoding version this replayer reads. Version 2 added the `$bytes` tag:
5
+ * a `Uint8Array` is stored as `{ "$bytes": "<base64>" }` and decoded back into
6
+ * a fresh one, and any other view of an `ArrayBuffer` is refused rather than
7
+ * frozen as an object of indices.
8
+ */
9
+ export declare const CORPUS_VERSION: 2;
5
10
 
6
11
  /** One frozen call. Exactly one of `fn` / `const` is present. */
7
12
  export interface ScoringVector {
@@ -35,10 +35,83 @@
35
35
  * deliberately returns `0`, not `-0`, for a negative value that rounds to
36
36
  * zero, and JSON would erase the difference. A call that throws is recorded as
37
37
  * `{ "$throws": "<error.name>" }`.
38
+ *
39
+ * A recording is bytes, which JSON cannot express either, so a `Uint8Array` is
40
+ * tagged `{ "$bytes": "<base64>" }` and comes back as a fresh one. Any other
41
+ * view of an `ArrayBuffer` is refused rather than tagged: JSON would store it
42
+ * as `{"0":82,"1":73}`, which replays as an ordinary object, and a function
43
+ * that reads bytes would be handed something that is not a byte array at all —
44
+ * silently, and with a frozen expectation to match. The base64 is computed
45
+ * here, in plain JavaScript: `Buffer` is Node's alone, and this file runs
46
+ * wherever a consumer's tests run.
38
47
  */
39
48
 
40
49
  /** Bump only when the ENCODING changes. Adding vectors does not change it. */
41
- export const CORPUS_VERSION = 1;
50
+ export const CORPUS_VERSION = 2;
51
+
52
+ /** The base64 alphabet, RFC 4648 section 4, padded with `=`. */
53
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
54
+
55
+ /** Base64 of `bytes`, three bytes at a time, the last group padded. */
56
+ function toBase64(bytes) {
57
+ let text = '';
58
+ for (let at = 0; at < bytes.length; at += 3) {
59
+ const first = bytes[at];
60
+ const second = bytes[at + 1];
61
+ const third = bytes[at + 2];
62
+ text += BASE64[first >> 2];
63
+ text += BASE64[((first & 0x03) << 4) | ((second ?? 0) >> 4)];
64
+ text += second === undefined ? '=' : BASE64[((second & 0x0f) << 2) | ((third ?? 0) >> 6)];
65
+ text += third === undefined ? '=' : BASE64[third & 0x3f];
66
+ }
67
+ return text;
68
+ }
69
+
70
+ /** The bytes of a base64 string, as a fresh `Uint8Array`. */
71
+ function fromBase64(text) {
72
+ // Every refusal below is a plain `Error`, which no vector expects: a corpus
73
+ // whose bytes cannot be read must fail the gate, and a `TypeError` here would
74
+ // be indistinguishable from the one `inspectWav` throws for bytes that are not
75
+ // a `Uint8Array` — which seven vectors do expect.
76
+ if (typeof text !== 'string') {
77
+ // A tag that carries no string carried no bytes either. Substituting an
78
+ // empty array would replay green against every vector that expects a
79
+ // refusal, because an empty recording is refused too, with the bytes the
80
+ // vector froze gone.
81
+ throw new Error(
82
+ `A $bytes tag carries a base64 string, and this one is of type ${typeof text}.`,
83
+ );
84
+ }
85
+ const body = text.replace(/=+$/, '');
86
+ // Base64 spends four characters on three bytes, so a trailing group of one
87
+ // character encodes nothing: the string was cut short, and the bytes before
88
+ // the cut are not the bytes anybody froze.
89
+ if (body.length % 4 === 1) {
90
+ throw new Error(
91
+ `Invalid base64 in a $bytes tag: a body of ${body.length} characters ends in a group of one, which encodes no byte.`,
92
+ );
93
+ }
94
+ const bytes = new Uint8Array((body.length * 3) >> 2);
95
+ let held = 0;
96
+ let bits = 0;
97
+ let at = 0;
98
+ for (const character of body) {
99
+ const value = BASE64.indexOf(character);
100
+ if (value === -1) {
101
+ // A hand-edited corpus, or a file mangled in transit. Decoding it as far
102
+ // as it goes would hand the call under test bytes nobody froze.
103
+ throw new Error(`Invalid base64 in a $bytes tag: ${JSON.stringify(character)}.`);
104
+ }
105
+ held = (held << 6) | value;
106
+ bits += 6;
107
+ if (bits >= 8) {
108
+ bits -= 8;
109
+ bytes[at] = (held >> bits) & 0xff;
110
+ at += 1;
111
+ }
112
+ }
113
+ return bytes;
114
+ }
42
115
 
43
116
  /** Turns a runtime value into the corpus's JSON-safe form. */
44
117
  export function encode(value) {
@@ -50,6 +123,14 @@ export function encode(value) {
50
123
  return value;
51
124
  }
52
125
  if (value === undefined) return { $undefined: true };
126
+ if (ArrayBuffer.isView(value)) {
127
+ if (value instanceof Uint8Array) return { $bytes: toBase64(value) };
128
+ // A DataView or a wider typed array has no agreed byte order in JSON, and
129
+ // the object branch below would freeze a `Float32Array` as its indices.
130
+ throw new TypeError(
131
+ 'A vector can carry bytes as a Uint8Array only; convert any other view of an ArrayBuffer first.',
132
+ );
133
+ }
53
134
  if (Array.isArray(value)) return value.map(encode);
54
135
  if (value !== null && typeof value === 'object') {
55
136
  const out = {};
@@ -77,6 +158,7 @@ export function decode(value) {
77
158
  }[value.$number];
78
159
  }
79
160
  if (keys.length === 1 && keys[0] === '$undefined') return undefined;
161
+ if (keys.length === 1 && keys[0] === '$bytes') return fromBase64(value.$bytes);
80
162
  const out = {};
81
163
  for (const [key, entry] of Object.entries(value)) out[key] = decode(entry);
82
164
  return out;