@intellectif/lk-core 0.8.1 → 0.9.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 +50 -0
- package/README.md +8 -6
- package/dist/{chunk-2M76F32Y.js → chunk-6DM2H6BD.js} +2 -2
- package/dist/{chunk-HCOWSGEZ.js → chunk-AHEUYOT3.js} +705 -17
- package/dist/chunk-AHEUYOT3.js.map +1 -0
- package/dist/{chunk-FKH4YT5X.cjs → chunk-CVRALNUK.cjs} +706 -18
- package/dist/chunk-CVRALNUK.cjs.map +1 -0
- package/dist/{chunk-NHGXOOZ2.js → chunk-ELTP5P6V.js} +2 -2
- package/dist/{chunk-YJZY5TPY.cjs → chunk-MOLL5KXO.cjs} +8 -8
- package/dist/{chunk-YJZY5TPY.cjs.map → chunk-MOLL5KXO.cjs.map} +1 -1
- package/dist/{chunk-LNA33IK7.js → chunk-QX7P3CHP.js} +2 -2
- package/dist/{chunk-7ACNBDSF.cjs → chunk-RL2PQLCY.cjs} +4 -4
- package/dist/{chunk-7ACNBDSF.cjs.map → chunk-RL2PQLCY.cjs.map} +1 -1
- package/dist/{chunk-FVLKIL6W.cjs → chunk-YVZCFUGP.cjs} +12 -12
- package/dist/{chunk-FVLKIL6W.cjs.map → chunk-YVZCFUGP.cjs.map} +1 -1
- package/dist/index.cjs +134 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +157 -6
- package/dist/index.d.ts +157 -6
- package/dist/index.js +115 -23
- package/dist/index.js.map +1 -1
- package/dist/schemas.cjs +3 -3
- package/dist/schemas.js +2 -2
- package/dist/scoring.cjs +3 -3
- package/dist/scoring.js +2 -2
- package/dist/xapi.cjs +3 -3
- package/dist/xapi.js +2 -2
- package/package.json +2 -1
- package/vectors/README.md +97 -0
- package/vectors/replay.d.mts +44 -0
- package/vectors/replay.mjs +159 -0
- package/vectors/scoring.json +7698 -0
- package/dist/chunk-FKH4YT5X.cjs.map +0 -1
- package/dist/chunk-HCOWSGEZ.js.map +0 -1
- /package/dist/{chunk-2M76F32Y.js.map → chunk-6DM2H6BD.js.map} +0 -0
- /package/dist/{chunk-NHGXOOZ2.js.map → chunk-ELTP5P6V.js.map} +0 -0
- /package/dist/{chunk-LNA33IK7.js.map → chunk-QX7P3CHP.js.map} +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Grade-stability vectors
|
|
2
|
+
|
|
3
|
+
`scoring.json` is lk-core's binding rule written down as data:
|
|
4
|
+
|
|
5
|
+
> Anything that can change a historical grade is a package **major**, always.
|
|
6
|
+
|
|
7
|
+
Each vector is a call to a scoring function and the exact value that call
|
|
8
|
+
returned when the vector was frozen. If a release of `@intellectif/lk-core`
|
|
9
|
+
returns anything different for any vector, a grade you stored would come out
|
|
10
|
+
differently today than on the day you recorded it — and that release is not
|
|
11
|
+
allowed to be a minor or a patch.
|
|
12
|
+
|
|
13
|
+
The SDK replays this corpus against its own source and against its built
|
|
14
|
+
package, CommonJS and ES module, on every pull request, on the Node version
|
|
15
|
+
consumers run and on the one the release is built with. It ships in the tarball
|
|
16
|
+
so you can replay it against the build **you** install.
|
|
17
|
+
|
|
18
|
+
## Replaying it in your own test suite
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
import * as core from '@intellectif/lk-core';
|
|
26
|
+
|
|
27
|
+
const root = dirname(createRequire(import.meta.url).resolve('@intellectif/lk-core/package.json'));
|
|
28
|
+
const { replay } = await import(pathToFileURL(join(root, 'vectors/replay.mjs')).href);
|
|
29
|
+
const corpus = JSON.parse(readFileSync(join(root, 'vectors/scoring.json'), 'utf8'));
|
|
30
|
+
|
|
31
|
+
test('lk-core still grades exactly as it did', () => {
|
|
32
|
+
const changed = replay(core, corpus).filter((result) => !result.ok);
|
|
33
|
+
expect(changed).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
These files are deliberately **not** package exports. An export would be new
|
|
38
|
+
public API — a lk-core minor, which forces a lk-react major — for what is test
|
|
39
|
+
data. They are reached by resolving `package.json`, which lk-core does export.
|
|
40
|
+
|
|
41
|
+
## If a vector fails
|
|
42
|
+
|
|
43
|
+
Do not change the expectation to make it pass. A failing vector means an
|
|
44
|
+
upgrade changed how a learner's answer scores. Decide whether that change is
|
|
45
|
+
wanted, and if it is, work out what it does to grades you already hold before
|
|
46
|
+
you accept the new number. Every failure names its vector, and the ones that pin
|
|
47
|
+
a known hazard carry a `note` saying why they exist.
|
|
48
|
+
|
|
49
|
+
## What it covers
|
|
50
|
+
|
|
51
|
+
- **`score` and `evaluate`** for every built-in activity type, including each
|
|
52
|
+
default a future release could plausibly "improve" — diacritic folding, Unicode
|
|
53
|
+
normalisation, empty-input handling — the legacy per-blank `caseSensitive` and
|
|
54
|
+
`trimWhitespace` flags and the way `match` overrides them, and the refusal to
|
|
55
|
+
score redacted data or an answer key that cannot produce a finite score.
|
|
56
|
+
- **Written-response word counts**, recomputed from the text rather than taken
|
|
57
|
+
from the client's `wordCount`, with inclusive `minWords` / `maxWords` bounds.
|
|
58
|
+
- **`matchText` and `levenshteinDistance`** across every authored policy.
|
|
59
|
+
- **`computePassThreshold` and `DEFAULT_PASS_THRESHOLD`**, including an authored
|
|
60
|
+
threshold of `0`.
|
|
61
|
+
- **`roundGrade`, `gte` and `classifyBand`** in every mode, including float
|
|
62
|
+
edges, negative zero, half-even ties and near-ties, and bands that share a
|
|
63
|
+
minimum.
|
|
64
|
+
- **`countWords`**, **`seededShuffle`** in both versions, **`gradeFromRubric`**
|
|
65
|
+
including its unscorable paths, and **`outcomeFromGrade`**.
|
|
66
|
+
- **`scoredItemsFromPlan`**, which decides what a stored paper's denominator is
|
|
67
|
+
when an outcome is missing, and **`composeAssessmentScore`** across weights,
|
|
68
|
+
points, provisional and unscorable items, and section thresholds.
|
|
69
|
+
|
|
70
|
+
## What it does not cover
|
|
71
|
+
|
|
72
|
+
- **Planning an attempt** (`planAttempt`, `planHash`, `verifyAttemptPlan`) and
|
|
73
|
+
**redaction** (`redact`). A plan's hashes are identity rather than a grade, and
|
|
74
|
+
both are verified by their own tests. How a stored plan becomes scored items is
|
|
75
|
+
covered, above.
|
|
76
|
+
- **Custom missing-outcome policies.** `scoredItemsFromPlan` accepts a function
|
|
77
|
+
for `missing`; a function cannot be stored as data, so only the built-in
|
|
78
|
+
policies are pinned.
|
|
79
|
+
- **xAPI statements**, and **anything `@intellectif/lk-react` renders**.
|
|
80
|
+
- **Your own arithmetic.** If you round, aggregate or canonicalise answers
|
|
81
|
+
yourself, this corpus says nothing about that code.
|
|
82
|
+
- **Text of developer-facing diagnostics.** A vector can mark a field such as an
|
|
83
|
+
`unscorable` reason as ignored, because rewording a diagnostic is not a grade
|
|
84
|
+
change. Codes that are contract, like `'requires_async_grading'`, are never
|
|
85
|
+
ignored.
|
|
86
|
+
|
|
87
|
+
## Encoding
|
|
88
|
+
|
|
89
|
+
JSON cannot express four values these functions produce or accept, so they are
|
|
90
|
+
tagged: `{ "$number": "NaN" | "Infinity" | "-Infinity" | "-0" }` and
|
|
91
|
+
`{ "$undefined": true }`. A call that throws is recorded as
|
|
92
|
+
`{ "$throws": "<error.name>" }`. Every non-ASCII character is stored as a
|
|
93
|
+
`\uXXXX` escape, so a no-break space can never pass for an ordinary one.
|
|
94
|
+
Comparison ignores object key order.
|
|
95
|
+
|
|
96
|
+
`corpusVersion` changes only if this encoding does. Adding vectors does not
|
|
97
|
+
change it, so always load the `replay.mjs` that sits beside the corpus.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Types for `replay.mjs`. See that file for the contract and the encoding. */
|
|
2
|
+
|
|
3
|
+
/** The encoding version this replayer reads. */
|
|
4
|
+
export declare const CORPUS_VERSION: 1;
|
|
5
|
+
|
|
6
|
+
/** One frozen call. Exactly one of `fn` / `const` is present. */
|
|
7
|
+
export interface ScoringVector {
|
|
8
|
+
id: string;
|
|
9
|
+
fn?: string;
|
|
10
|
+
const?: string;
|
|
11
|
+
/** Encoded arguments. */
|
|
12
|
+
args?: unknown[];
|
|
13
|
+
/** Encoded return value, or `{ $throws: "<error.name>" }`. */
|
|
14
|
+
expect: unknown;
|
|
15
|
+
/** Keys excluded from comparison: developer-facing diagnostics only. */
|
|
16
|
+
ignore?: string[];
|
|
17
|
+
/** Why this vector exists, when that is not obvious from its id. */
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScoringCorpus {
|
|
22
|
+
$comment?: string;
|
|
23
|
+
corpusVersion: number;
|
|
24
|
+
frozenFrom: string;
|
|
25
|
+
vectors: ScoringVector[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VectorResult {
|
|
29
|
+
id: string;
|
|
30
|
+
ok: boolean;
|
|
31
|
+
/** Canonical JSON of the frozen expectation. */
|
|
32
|
+
expected: string;
|
|
33
|
+
/** Canonical JSON of what this build returned. */
|
|
34
|
+
actual: string;
|
|
35
|
+
note?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export declare function encode(value: unknown): unknown;
|
|
39
|
+
export declare function decode(value: unknown): unknown;
|
|
40
|
+
export declare function canonical(value: unknown): string;
|
|
41
|
+
/** `core` is the lk-core module namespace: `import * as core from '@intellectif/lk-core'`. */
|
|
42
|
+
export declare function callVector(core: object, vector: ScoringVector): unknown;
|
|
43
|
+
export declare function runVector(core: object, vector: ScoringVector): VectorResult;
|
|
44
|
+
export declare function replay(core: object, corpus: ScoringCorpus): VectorResult[];
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replays the grade-stability corpus (`scoring.json`) against an lk-core build.
|
|
3
|
+
*
|
|
4
|
+
* The corpus is the SDK's binding rule — "anything that can change a historical
|
|
5
|
+
* grade is a package major" — written down as data. Each vector is a call and
|
|
6
|
+
* the exact value that call returned when the vector was frozen. If a release
|
|
7
|
+
* returns anything else, a stored grade would score differently today than on
|
|
8
|
+
* the day it was recorded, and that release is not allowed to be a minor.
|
|
9
|
+
*
|
|
10
|
+
* This file ships in the package so a consumer can run the same corpus against
|
|
11
|
+
* the build they actually install, without re-implementing the comparison:
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { createRequire } from 'node:module';
|
|
15
|
+
* import { dirname, join } from 'node:path';
|
|
16
|
+
* import { pathToFileURL } from 'node:url';
|
|
17
|
+
* import { readFileSync } from 'node:fs';
|
|
18
|
+
* import * as core from '@intellectif/lk-core';
|
|
19
|
+
*
|
|
20
|
+
* const root = dirname(createRequire(import.meta.url).resolve('@intellectif/lk-core/package.json'));
|
|
21
|
+
* const { replay } = await import(pathToFileURL(join(root, 'vectors/replay.mjs')).href);
|
|
22
|
+
* const corpus = JSON.parse(readFileSync(join(root, 'vectors/scoring.json'), 'utf8'));
|
|
23
|
+
* const failures = replay(core, corpus).filter((result) => !result.ok);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* It is plain ESM with no imports, so it runs unchanged under Node, Vitest and
|
|
27
|
+
* Jest, and it is deliberately NOT a package export: an export would be new API
|
|
28
|
+
* and a lk-core minor, which forces a lk-react major for a file of test data.
|
|
29
|
+
*
|
|
30
|
+
* ## Encoding
|
|
31
|
+
*
|
|
32
|
+
* JSON cannot express four values the scoring functions genuinely produce or
|
|
33
|
+
* accept, so they are tagged: `{ "$number": "NaN" | "Infinity" | "-Infinity" |
|
|
34
|
+
* "-0" }` and `{ "$undefined": true }`. `-0` is tagged because `roundGrade`
|
|
35
|
+
* deliberately returns `0`, not `-0`, for a negative value that rounds to
|
|
36
|
+
* zero, and JSON would erase the difference. A call that throws is recorded as
|
|
37
|
+
* `{ "$throws": "<error.name>" }`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Bump only when the ENCODING changes. Adding vectors does not change it. */
|
|
41
|
+
export const CORPUS_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
/** Turns a runtime value into the corpus's JSON-safe form. */
|
|
44
|
+
export function encode(value) {
|
|
45
|
+
if (typeof value === 'number') {
|
|
46
|
+
if (Number.isNaN(value)) return { $number: 'NaN' };
|
|
47
|
+
if (value === Number.POSITIVE_INFINITY) return { $number: 'Infinity' };
|
|
48
|
+
if (value === Number.NEGATIVE_INFINITY) return { $number: '-Infinity' };
|
|
49
|
+
if (Object.is(value, -0)) return { $number: '-0' };
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
if (value === undefined) return { $undefined: true };
|
|
53
|
+
if (Array.isArray(value)) return value.map(encode);
|
|
54
|
+
if (value !== null && typeof value === 'object') {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
57
|
+
// An absent key and a key set to `undefined` serialise identically in
|
|
58
|
+
// JSON; recording both as absent keeps the corpus honest about that.
|
|
59
|
+
if (entry !== undefined) out[key] = encode(entry);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Inverse of {@link encode}. Always returns fresh objects. */
|
|
67
|
+
export function decode(value) {
|
|
68
|
+
if (Array.isArray(value)) return value.map(decode);
|
|
69
|
+
if (value !== null && typeof value === 'object') {
|
|
70
|
+
const keys = Object.keys(value);
|
|
71
|
+
if (keys.length === 1 && keys[0] === '$number') {
|
|
72
|
+
return {
|
|
73
|
+
NaN: Number.NaN,
|
|
74
|
+
Infinity: Number.POSITIVE_INFINITY,
|
|
75
|
+
'-Infinity': Number.NEGATIVE_INFINITY,
|
|
76
|
+
'-0': -0,
|
|
77
|
+
}[value.$number];
|
|
78
|
+
}
|
|
79
|
+
if (keys.length === 1 && keys[0] === '$undefined') return undefined;
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [key, entry] of Object.entries(value)) out[key] = decode(entry);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* JSON with keys sorted at every depth. Object key ORDER is not a grade: a
|
|
89
|
+
* refactor that reorders an object literal must not fail the gate, or people
|
|
90
|
+
* learn to regenerate the corpus to make it pass — which is the one habit that
|
|
91
|
+
* would turn this tripwire into a formality.
|
|
92
|
+
*/
|
|
93
|
+
export function canonical(value) {
|
|
94
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
95
|
+
if (value !== null && typeof value === 'object') {
|
|
96
|
+
return `{${Object.keys(value)
|
|
97
|
+
.sort()
|
|
98
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
99
|
+
.join(',')}}`;
|
|
100
|
+
}
|
|
101
|
+
return JSON.stringify(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Removes the named keys at every depth. Used only where a vector opts in. */
|
|
105
|
+
function without(value, ignore) {
|
|
106
|
+
if (ignore.size === 0) return value;
|
|
107
|
+
if (Array.isArray(value)) return value.map((entry) => without(entry, ignore));
|
|
108
|
+
if (value !== null && typeof value === 'object') {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
111
|
+
if (!ignore.has(key)) out[key] = without(entry, ignore);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Executes one vector against `core` and returns the encoded result. */
|
|
119
|
+
export function callVector(core, vector) {
|
|
120
|
+
if (vector.const !== undefined) {
|
|
121
|
+
return core[vector.const] === undefined
|
|
122
|
+
? { $missing: vector.const }
|
|
123
|
+
: encode(core[vector.const]);
|
|
124
|
+
}
|
|
125
|
+
const fn = core[vector.fn];
|
|
126
|
+
if (typeof fn !== 'function') return { $missing: vector.fn };
|
|
127
|
+
try {
|
|
128
|
+
return encode(fn(...vector.args.map(decode)));
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { $throws: error?.name ?? 'Error' };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Executes one vector and compares it with its frozen expectation.
|
|
136
|
+
*
|
|
137
|
+
* `ignore` exists for exactly one kind of field: a developer-facing diagnostic
|
|
138
|
+
* sentence such as an `unscorable` reason. Rewording one of those does not
|
|
139
|
+
* change a grade, and pinning its text would make copy edits fail a gate that
|
|
140
|
+
* exists for grades. Codes that ARE contract — a deferred outcome's
|
|
141
|
+
* `'requires_async_grading'` — are never ignored.
|
|
142
|
+
*/
|
|
143
|
+
export function runVector(core, vector) {
|
|
144
|
+
const ignore = new Set(vector.ignore ?? []);
|
|
145
|
+
const actual = canonical(without(callVector(core, vector), ignore));
|
|
146
|
+
const expected = canonical(without(vector.expect, ignore));
|
|
147
|
+
return { id: vector.id, ok: actual === expected, expected, actual, note: vector.note };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Replays a whole corpus. Throws if the corpus uses an encoding this file does not speak. */
|
|
151
|
+
export function replay(core, corpus) {
|
|
152
|
+
if (corpus?.corpusVersion !== CORPUS_VERSION) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`scoring.json declares corpusVersion ${String(corpus?.corpusVersion)}, but this replay.mjs reads ` +
|
|
155
|
+
`version ${CORPUS_VERSION}. Load the replay.mjs shipped beside the corpus.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return corpus.vectors.map((vector) => runVector(core, vector));
|
|
159
|
+
}
|