@intellectif/lk-core 0.8.1 → 0.8.2
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 +28 -0
- package/README.md +3 -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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @intellectif/lk-core
|
|
2
2
|
|
|
3
|
+
## 0.8.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e1b9bb9: Grade-stability vectors: lk-core's grading, frozen as data and shipped in the package.
|
|
8
|
+
|
|
9
|
+
The standing rule for this package is that anything which can change a historical grade is a major release. The unit suite already asserted scoring numbers, but against source, and those assertions change alongside the code they test. Nothing froze them independently, and nothing checked them against the package you actually install. Now something does.
|
|
10
|
+
|
|
11
|
+
`vectors/scoring.json` records 231 calls across every path that decides a grade — `score` and `evaluate` for each built-in activity type (including the legacy per-blank `caseSensitive` / `trimWhitespace` flags, and the refusal to score redacted data), written-response word counts recomputed from the text rather than taken from the client, `matchText` across every authored policy, rounding in every mode, `countWords`, `seededShuffle`, rubric grading, how a stored attempt plan becomes scored items, and whole-assessment composition — together with the exact value each returned. The values were frozen by executing 0.8.1, so **nothing about grading changes in this release**: the corpus records what already is.
|
|
12
|
+
|
|
13
|
+
- **It is replayed on every change.** `pnpm test` replays it against source; `check-packaging` replays it against the built package, CJS and ESM; and CI runs both on Node 22 and on Node 24, the Node the release is built with. A vector that returns anything different fails the build, by name.
|
|
14
|
+
- **It cannot drift from its inputs unnoticed.** `pnpm test` also fails when a case was never generated, a vector was deleted by hand, or a frozen call's arguments no longer match its case. The generator adds vectors freely but refuses to rewrite or remove one without `--accept-grade-change`.
|
|
15
|
+
- **Its coverage was tested rather than assumed.** The scoring source was deliberately broken — comparisons flipped, defaults changed, guards removed — and every one of those breaks that can reach a stored grade now fails at least one vector. The breaks that still pass change no grade: they touch feedback prose, xAPI patterns or a hook nothing in this package calls, have no observable effect, or sit exactly on a floating-point epsilon that only the epsilon itself can reach.
|
|
16
|
+
- **You can replay it against the build you install.** It ships in the tarball beside `vectors/replay.mjs`, which does the comparison, so a test suite that pins lk-core's arithmetic no longer has to restate it. `vectors/README.md` has the recipe.
|
|
17
|
+
- **It is deliberately not an export.** An export would be new public API — a minor, which would force a `@intellectif/lk-react` major for a file of test data. The corpus is reached by resolving `@intellectif/lk-core/package.json`, which is already exported.
|
|
18
|
+
|
|
19
|
+
Several vectors pin sharp edges on purpose, each with a note saying why. None of them changes in this release:
|
|
20
|
+
|
|
21
|
+
- rubric weights are read from each criterion and never joined from the activity's rubric;
|
|
22
|
+
- with no activity, `gradeFromRubric` compares against a literal `0.7` rather than `DEFAULT_PASS_THRESHOLD`;
|
|
23
|
+
- an unscorable item leaves the denominator of a composed score, which stays final;
|
|
24
|
+
- a paper whose graded sections all weigh 0 records a final 0 and a fail;
|
|
25
|
+
- `ignorePunctuation` on its own leaves the space a removed mark stood beside, so `"hola !"` does not match `"hola"` unless `collapseInnerWhitespace` is also set;
|
|
26
|
+
- band classification absorbs float noise just below a boundary, but not a genuine shortfall;
|
|
27
|
+
- `levenshtein: 1` against a one-character answer accepts any single character.
|
|
28
|
+
|
|
29
|
+
Also: the README now states the real browser floor — the built code needs `Object.hasOwn`, so Chrome and Edge 93, Firefox 92, Safari 15.4 — and the contributor docs no longer describe a Node 20 CI matrix that was retired.
|
|
30
|
+
|
|
3
31
|
## 0.8.1
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Framework-free TypeScript core of [learning-kit](https://github.com/intellectif/learning-kit): Zod-based **activity schemas**, a pure **scoring engine**, **rubric and deferred grading**, **weighted assessment composition**, reproducible **exam attempt plans**, fail-closed **redaction**, and an **xAPI 1.0.3 statement builder + validator**. Zero React, zero DOM — usable in Node, browsers, edge, workers, or as the data layer behind `@intellectif/lk-react`.
|
|
4
4
|
|
|
5
|
-
It is built for **summative** assessment as much as practice: nothing here grades on the client, "not marked yet" is a first-class outcome that is never conflated with a zero, and every behavioural change that could move a historical grade is opt-in.
|
|
5
|
+
It is built for **summative** assessment as much as practice: nothing here grades on the client, "not marked yet" is a first-class outcome that is never conflated with a zero, and every behavioural change that could move a historical grade is opt-in. A frozen corpus of grading vectors ships in the package, fails CI if any of them moves, and can be replayed against the build you install.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
pnpm add @intellectif/lk-core
|
|
@@ -117,13 +117,14 @@ The barrel re-exports everything, and it is the **only** entry point for attempt
|
|
|
117
117
|
plans, attempt state, item groups, redaction, the type registry and content
|
|
118
118
|
hashing — those have no subpath of their own.
|
|
119
119
|
|
|
120
|
-
All exports ship as ESM + CJS with `.d.ts` types. Tree-shakeable; `sideEffects: false`. Node >= 20.
|
|
120
|
+
All exports ship as ESM + CJS with `.d.ts` types. Tree-shakeable; `sideEffects: false`. Node >= 20. In browsers, the SDK's own code needs `Object.hasOwn` — Chrome and Edge 93, Firefox 92, Safari 15.4 — because output targets ES2022 and nothing is polyfilled.
|
|
121
121
|
Zod is the single runtime dependency: the package depends on `zod@^3.25` and imports the **Zod 4 API** from its `zod/v4` subpath, so it coexists with an app still on Zod 3.
|
|
122
122
|
|
|
123
123
|
## Documentation
|
|
124
124
|
|
|
125
125
|
- [Upgrading](https://github.com/intellectif/learning-kit/blob/main/docs/upgrading.md) — start here on any upgrade from 0.3.x, 0.4.x or 0.5.x.
|
|
126
126
|
- [Changelog](https://github.com/intellectif/learning-kit/blob/main/packages/lk-core/CHANGELOG.md) — every release, with the reasoning.
|
|
127
|
+
- [Grade-stability vectors](https://github.com/intellectif/learning-kit/blob/main/packages/lk-core/vectors/README.md) — the package's grading frozen as data; replay it against the build you install.
|
|
127
128
|
- [Authoring & content storage](https://github.com/intellectif/learning-kit/blob/main/docs/authoring.md) — data model, validation, fetch → validate → render flow.
|
|
128
129
|
- [Project README](https://github.com/intellectif/learning-kit#readme) — the full picture, including the React renderers.
|
|
129
130
|
- [Contributing](https://github.com/intellectif/learning-kit/blob/main/CONTRIBUTING.md) — adding a new activity type.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intellectif/lk-core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Framework-free assessment core: Zod activity schemas, pure scoring, rubric & deferred grading, weighted assessment composition, exam attempt plans, and xAPI 1.0.3.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assessment",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"types": "./dist/index.d.ts",
|
|
77
77
|
"files": [
|
|
78
78
|
"dist",
|
|
79
|
+
"vectors",
|
|
79
80
|
"CHANGELOG.md",
|
|
80
81
|
"LICENSE"
|
|
81
82
|
],
|
|
@@ -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
|
+
}
|