@conform-ed/pci-math-entry 0.0.13
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/README.md +58 -0
- package/dist/checker.js +6 -0
- package/dist/index-4z8t2fk0.js +60 -0
- package/dist/index-k2p6zcy8.js +35 -0
- package/dist/index-n2b9z4x4.js +72 -0
- package/dist/index.js +18 -0
- package/dist/module-mathlive.js +32 -0
- package/dist/operator.js +9 -0
- package/package.json +51 -0
- package/src/checker.ts +142 -0
- package/src/index.ts +5 -0
- package/src/mathlive-input.ts +32 -0
- package/src/module-mathlive.ts +14 -0
- package/src/module.ts +113 -0
- package/src/operator.ts +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @conform-ed/pci-math-entry
|
|
2
|
+
|
|
3
|
+
The reference PCI for the conform-ed QTI stack: math entry with MathLive input and
|
|
4
|
+
compute-engine checking. It exists to be a real, end-to-end example of what a PCI
|
|
5
|
+
package looks like for consumers of `@conform-ed/qti-react` — interaction module,
|
|
6
|
+
pure checker, and response-processing operator, each on its own subpath.
|
|
7
|
+
|
|
8
|
+
## The response contract
|
|
9
|
+
|
|
10
|
+
The response of record is a PCI JSON record:
|
|
11
|
+
|
|
12
|
+
- `expression` — the learner's LaTeX, exactly as written. Always present.
|
|
13
|
+
- `verdict` — an **advisory** boolean computed client-side by the same checker the
|
|
14
|
+
platform re-scores with. Present only when the item carries checker config
|
|
15
|
+
(`data-correct`, optional `data-mode`, `data-tolerance`); a high-stakes delivery
|
|
16
|
+
redacts those properties and the module degrades to expression-only. Unjudgeable
|
|
17
|
+
input omits the verdict — never a guess.
|
|
18
|
+
|
|
19
|
+
Items score portably with plain QTI (`qti-field-value` over `verdict`); platforms
|
|
20
|
+
holding the expression re-score it server-side for the authoritative result.
|
|
21
|
+
|
|
22
|
+
## Subpaths
|
|
23
|
+
|
|
24
|
+
- `.` — checker, operator, module core (injectable input), type identifier.
|
|
25
|
+
- `./checker` — the pure function only (compute-engine; no qti-react, no DOM).
|
|
26
|
+
- `./operator` — `org.conform-ed.mathEquivalent` for `QtiRuntimeConfig.customOperators`.
|
|
27
|
+
- `./module` — the browser-ready module (MathLive + compute-engine). Heavy by design;
|
|
28
|
+
load it lazily (ADR-0007: descriptors eager, implementations lazy):
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const { mathEntryModule } = await import("@conform-ed/pci-math-entry/module");
|
|
32
|
+
registry.registerModule("math-entry", mathEntryModule);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Checking modes
|
|
36
|
+
|
|
37
|
+
- `equivalent` (default) — symbolic equality, any mathematically equal form passes;
|
|
38
|
+
optional absolute `tolerance` for float answers.
|
|
39
|
+
- `literal` — the written form matters (EqualComAss-style: `\frac{2}{4}` and `0.5`
|
|
40
|
+
fail against `\frac{1}{2}`; reordered terms pass).
|
|
41
|
+
|
|
42
|
+
## Server-side re-scoring
|
|
43
|
+
|
|
44
|
+
The checker is a pure function — no adapters, usable from any API:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { checkMathExpression } from "@conform-ed/pci-math-entry/checker";
|
|
48
|
+
|
|
49
|
+
Bun.serve({
|
|
50
|
+
port: 3000,
|
|
51
|
+
fetch: async (request) => {
|
|
52
|
+
const { expression, correct, mode, tolerance } = await request.json();
|
|
53
|
+
const result = checkMathExpression(expression, correct, { mode, tolerance });
|
|
54
|
+
|
|
55
|
+
return Response.json(result); // { verdict: boolean | null, reason? }
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
```
|
package/dist/checker.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkMathExpression
|
|
3
|
+
} from "./index-n2b9z4x4.js";
|
|
4
|
+
|
|
5
|
+
// src/module.ts
|
|
6
|
+
var mathEntryTypeIdentifier = "urn:conform-ed:pci:math-entry";
|
|
7
|
+
function restoredExpression(state) {
|
|
8
|
+
if (state === undefined || state === "") {
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(state);
|
|
13
|
+
return typeof parsed.expression === "string" ? parsed.expression : "";
|
|
14
|
+
} catch {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function checkerOptions(properties) {
|
|
19
|
+
const mode = properties["mode"];
|
|
20
|
+
const tolerance = Number(properties["tolerance"]);
|
|
21
|
+
return {
|
|
22
|
+
...mode === "equivalent" || mode === "literal" ? { mode } : {},
|
|
23
|
+
...properties["tolerance"] !== undefined && Number.isFinite(tolerance) ? { tolerance } : {}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function createMathEntryModule(input) {
|
|
27
|
+
return {
|
|
28
|
+
typeIdentifier: mathEntryTypeIdentifier,
|
|
29
|
+
getInstance(dom, configuration, state) {
|
|
30
|
+
const properties = configuration.properties ?? {};
|
|
31
|
+
const container = dom.ownerDocument.createElement("div");
|
|
32
|
+
container.className = "math-entry-pci";
|
|
33
|
+
dom.appendChild(container);
|
|
34
|
+
const handle = input(container, { initialLatex: restoredExpression(state) });
|
|
35
|
+
const instance = {
|
|
36
|
+
typeIdentifier: mathEntryTypeIdentifier,
|
|
37
|
+
getResponse() {
|
|
38
|
+
const latex = handle.getValue().trim();
|
|
39
|
+
const expressionEntry = latex === "" ? { name: "expression", base: null } : { name: "expression", base: { string: latex } };
|
|
40
|
+
const correct = properties["correct"];
|
|
41
|
+
if (latex === "" || correct === undefined) {
|
|
42
|
+
return { record: [expressionEntry] };
|
|
43
|
+
}
|
|
44
|
+
const { verdict } = checkMathExpression(latex, correct, checkerOptions(properties));
|
|
45
|
+
return verdict === null ? { record: [expressionEntry] } : { record: [expressionEntry, { name: "verdict", base: { boolean: verdict } }] };
|
|
46
|
+
},
|
|
47
|
+
getState() {
|
|
48
|
+
return JSON.stringify({ expression: handle.getValue() });
|
|
49
|
+
},
|
|
50
|
+
oncompleted() {
|
|
51
|
+
handle.destroy?.();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
configuration.onready?.(instance, state);
|
|
55
|
+
return instance;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { mathEntryTypeIdentifier, createMathEntryModule };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkMathExpression
|
|
3
|
+
} from "./index-n2b9z4x4.js";
|
|
4
|
+
|
|
5
|
+
// src/operator.ts
|
|
6
|
+
var mathEquivalentClass = "org.conform-ed.mathEquivalent";
|
|
7
|
+
function singleString(value) {
|
|
8
|
+
if (value === null || value.values.length !== 1) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const member = value.values[0];
|
|
12
|
+
return typeof member === "string" ? member : typeof member === "number" ? String(member) : null;
|
|
13
|
+
}
|
|
14
|
+
function parseMode(value) {
|
|
15
|
+
const mode = singleString(value);
|
|
16
|
+
return mode === "equivalent" || mode === "literal" ? mode : undefined;
|
|
17
|
+
}
|
|
18
|
+
var mathEquivalentOperator = (args) => {
|
|
19
|
+
const candidate = singleString(args[0] ?? null);
|
|
20
|
+
const correct = singleString(args[1] ?? null);
|
|
21
|
+
if (candidate === null || correct === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const mode = parseMode(args[2] ?? null);
|
|
25
|
+
const toleranceMember = args[3]?.values[0];
|
|
26
|
+
const tolerance = typeof toleranceMember === "number" ? toleranceMember : undefined;
|
|
27
|
+
const options = {
|
|
28
|
+
...mode === undefined ? {} : { mode },
|
|
29
|
+
...tolerance === undefined ? {} : { tolerance }
|
|
30
|
+
};
|
|
31
|
+
const result = checkMathExpression(candidate, correct, options);
|
|
32
|
+
return result.verdict === null ? null : { cardinality: "single", baseType: "boolean", values: [result.verdict] };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export { mathEquivalentClass, mathEquivalentOperator };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/checker.ts
|
|
2
|
+
import { ComputeEngine } from "@cortex-js/compute-engine";
|
|
3
|
+
var ce = new ComputeEngine;
|
|
4
|
+
function fingerprintRationalLiterals(latex) {
|
|
5
|
+
return latex.replace(/\\frac\s*\{\s*(-?\d+)\s*\}\s*\{\s*(-?\d+)\s*\}/gu, "\\operatorname{ratlit}($1,$2)").replace(/\\frac\s*(\d)\s*(\d)/gu, "\\operatorname{ratlit}($1,$2)").replace(/(?<![\w.])(-?\d+)\s*\/\s*(\d+)(?![\w.])/gu, "\\operatorname{ratlit}($1,$2)");
|
|
6
|
+
}
|
|
7
|
+
var commutativeHeads = new Set(["Add", "Multiply"]);
|
|
8
|
+
function normalizeOperandOrder(node) {
|
|
9
|
+
if (!Array.isArray(node)) {
|
|
10
|
+
return node;
|
|
11
|
+
}
|
|
12
|
+
const [head, ...operands] = node;
|
|
13
|
+
const mapped = operands.map(normalizeOperandOrder);
|
|
14
|
+
if (typeof head === "string" && commutativeHeads.has(head)) {
|
|
15
|
+
mapped.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
16
|
+
}
|
|
17
|
+
return [head, ...mapped];
|
|
18
|
+
}
|
|
19
|
+
function sameJson(a, b) {
|
|
20
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
21
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((member, i) => sameJson(member, b[i]));
|
|
22
|
+
}
|
|
23
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
|
24
|
+
const left = a;
|
|
25
|
+
const right = b;
|
|
26
|
+
const keys = Object.keys(left);
|
|
27
|
+
return keys.length === Object.keys(right).length && keys.every((key) => sameJson(left[key], right[key]));
|
|
28
|
+
}
|
|
29
|
+
return a === b;
|
|
30
|
+
}
|
|
31
|
+
function parseLatex(latex, form) {
|
|
32
|
+
if (latex.trim() === "") {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const expression = ce.parse(latex, { form });
|
|
36
|
+
return expression.isValid ? expression : null;
|
|
37
|
+
}
|
|
38
|
+
function realValue(expression) {
|
|
39
|
+
const numeric = expression.N();
|
|
40
|
+
const real = numeric.re;
|
|
41
|
+
const imaginary = numeric.im;
|
|
42
|
+
return Number.isFinite(real) && imaginary === 0 ? real : null;
|
|
43
|
+
}
|
|
44
|
+
function checkMathExpression(candidate, correct, options) {
|
|
45
|
+
const mode = options?.mode ?? "equivalent";
|
|
46
|
+
const form = mode === "equivalent" ? "canonical" : "structural";
|
|
47
|
+
const prepare = mode === "literal" ? fingerprintRationalLiterals : (latex) => latex;
|
|
48
|
+
const candidateExpression = parseLatex(prepare(candidate), form);
|
|
49
|
+
if (candidateExpression === null) {
|
|
50
|
+
return { verdict: null, reason: "candidate-parse-error" };
|
|
51
|
+
}
|
|
52
|
+
const correctExpression = parseLatex(prepare(correct), form);
|
|
53
|
+
if (correctExpression === null) {
|
|
54
|
+
return { verdict: null, reason: "correct-parse-error" };
|
|
55
|
+
}
|
|
56
|
+
if (mode === "literal") {
|
|
57
|
+
return {
|
|
58
|
+
verdict: sameJson(normalizeOperandOrder(candidateExpression.json), normalizeOperandOrder(correctExpression.json))
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (options?.tolerance !== undefined) {
|
|
62
|
+
const candidateValue = realValue(candidateExpression);
|
|
63
|
+
const correctValue = realValue(correctExpression);
|
|
64
|
+
if (candidateValue !== null && correctValue !== null) {
|
|
65
|
+
return { verdict: Math.abs(candidateValue - correctValue) <= options.tolerance };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const equal = candidateExpression.isEqual(correctExpression);
|
|
69
|
+
return equal === undefined ? { verdict: null, reason: "undecidable" } : { verdict: equal };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { checkMathExpression };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mathEquivalentClass,
|
|
3
|
+
mathEquivalentOperator
|
|
4
|
+
} from "./index-k2p6zcy8.js";
|
|
5
|
+
import {
|
|
6
|
+
createMathEntryModule,
|
|
7
|
+
mathEntryTypeIdentifier
|
|
8
|
+
} from "./index-4z8t2fk0.js";
|
|
9
|
+
import {
|
|
10
|
+
checkMathExpression
|
|
11
|
+
} from "./index-n2b9z4x4.js";
|
|
12
|
+
export {
|
|
13
|
+
mathEquivalentOperator,
|
|
14
|
+
mathEquivalentClass,
|
|
15
|
+
mathEntryTypeIdentifier,
|
|
16
|
+
createMathEntryModule,
|
|
17
|
+
checkMathExpression
|
|
18
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMathEntryModule,
|
|
3
|
+
mathEntryTypeIdentifier
|
|
4
|
+
} from "./index-4z8t2fk0.js";
|
|
5
|
+
import"./index-n2b9z4x4.js";
|
|
6
|
+
|
|
7
|
+
// src/mathlive-input.ts
|
|
8
|
+
import { MathfieldElement } from "mathlive";
|
|
9
|
+
var mathLiveInput = (container, options) => {
|
|
10
|
+
const field = new MathfieldElement;
|
|
11
|
+
field.value = options.initialLatex;
|
|
12
|
+
if (options.disabled === true) {
|
|
13
|
+
field.readOnly = true;
|
|
14
|
+
}
|
|
15
|
+
container.appendChild(field);
|
|
16
|
+
return {
|
|
17
|
+
getValue: () => field.value,
|
|
18
|
+
setValue: (latex) => {
|
|
19
|
+
field.value = latex;
|
|
20
|
+
},
|
|
21
|
+
destroy: () => {
|
|
22
|
+
field.remove();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/module-mathlive.ts
|
|
28
|
+
var mathEntryModule = createMathEntryModule(mathLiveInput);
|
|
29
|
+
export {
|
|
30
|
+
mathEntryTypeIdentifier,
|
|
31
|
+
mathEntryModule
|
|
32
|
+
};
|
package/dist/operator.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@conform-ed/pci-math-entry",
|
|
3
|
+
"version": "0.0.13",
|
|
4
|
+
"files": [
|
|
5
|
+
"src",
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"module": "src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./checker": {
|
|
16
|
+
"types": "./src/checker.ts",
|
|
17
|
+
"import": "./dist/checker.js"
|
|
18
|
+
},
|
|
19
|
+
"./operator": {
|
|
20
|
+
"types": "./src/operator.ts",
|
|
21
|
+
"import": "./dist/operator.js"
|
|
22
|
+
},
|
|
23
|
+
"./module": {
|
|
24
|
+
"types": "./src/module-mathlive.ts",
|
|
25
|
+
"import": "./dist/module-mathlive.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "bun build ./src/index.ts ./src/checker.ts ./src/operator.ts ./src/module-mathlive.ts --outdir dist --format esm --target browser --splitting --external mathlive --external @cortex-js/compute-engine --external @conform-ed/qti-react",
|
|
30
|
+
"typecheck": "tsgo --noEmit",
|
|
31
|
+
"lint": "oxlint --config ../../.oxlintrc.jsonc .",
|
|
32
|
+
"format": "oxfmt --config ../../.oxfmtrc.jsonc --check .",
|
|
33
|
+
"test": "bun test"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@cortex-js/compute-engine": "^0.59.0",
|
|
37
|
+
"mathlive": "^0.110.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@conform-ed/qti-react": "0.0.13",
|
|
41
|
+
"happy-dom": "^20.10.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@conform-ed/qti-react": ">=0.0.12"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@conform-ed/qti-react": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/checker.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure math checker: LaTeX in, verdict out (compute-engine under the hood).
|
|
3
|
+
* Framework-free and I/O-free by design — the identical function produces the PCI's
|
|
4
|
+
* advisory client-side verdict and the platform's authoritative re-score, so the two
|
|
5
|
+
* can only disagree across package versions, never across code paths.
|
|
6
|
+
*
|
|
7
|
+
* Modes: "equivalent" accepts any mathematically equal form (symbolic equality with
|
|
8
|
+
* compute-engine's numeric probing); "literal" compares the non-canonical parse trees,
|
|
9
|
+
* so the written form matters (\frac{2}{4} ≠ \frac{1}{2}). An absolute `tolerance`
|
|
10
|
+
* widens numeric comparison in equivalent mode (float answers like the sine-rule item).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ComputeEngine } from "@cortex-js/compute-engine";
|
|
14
|
+
import type { BoxedExpression } from "@cortex-js/compute-engine";
|
|
15
|
+
|
|
16
|
+
export type MathCheckMode = "equivalent" | "literal";
|
|
17
|
+
|
|
18
|
+
export interface MathCheckOptions {
|
|
19
|
+
/** Default "equivalent". */
|
|
20
|
+
readonly mode?: MathCheckMode;
|
|
21
|
+
/** Absolute numeric window; only meaningful in equivalent mode. */
|
|
22
|
+
readonly tolerance?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MathCheckReason = "candidate-parse-error" | "correct-parse-error" | "undecidable";
|
|
26
|
+
|
|
27
|
+
export interface MathCheckResult {
|
|
28
|
+
/** true/false when judged; null when the input could not be judged at all. */
|
|
29
|
+
readonly verdict: boolean | null;
|
|
30
|
+
readonly reason?: MathCheckReason;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ce = new ComputeEngine();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Literal mode compares the written form, but compute-engine's parser eagerly reduces
|
|
37
|
+
* rational literals in every mode (\frac{2}{4} parses as 1/2 even non-canonically), so
|
|
38
|
+
* digit fractions are rewritten to an opaque marker before parsing. The resulting
|
|
39
|
+
* trees are form fingerprints, not mathematics — they exist only to be compared.
|
|
40
|
+
*/
|
|
41
|
+
function fingerprintRationalLiterals(latex: string): string {
|
|
42
|
+
return latex
|
|
43
|
+
.replace(/\\frac\s*\{\s*(-?\d+)\s*\}\s*\{\s*(-?\d+)\s*\}/gu, "\\operatorname{ratlit}($1,$2)")
|
|
44
|
+
.replace(/\\frac\s*(\d)\s*(\d)/gu, "\\operatorname{ratlit}($1,$2)")
|
|
45
|
+
.replace(/(?<![\w.])(-?\d+)\s*\/\s*(\d+)(?![\w.])/gu, "\\operatorname{ratlit}($1,$2)");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Literal verdicts are EqualComAss-style (the STACK convention): the written *form*
|
|
50
|
+
* matters, term order does not — rejecting "1+x" for "x+1" is never the pedagogy.
|
|
51
|
+
* Operands of commutative heads sort before comparison.
|
|
52
|
+
*/
|
|
53
|
+
const commutativeHeads = new Set(["Add", "Multiply"]);
|
|
54
|
+
|
|
55
|
+
function normalizeOperandOrder(node: unknown): unknown {
|
|
56
|
+
if (!Array.isArray(node)) {
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [head, ...operands] = node as unknown[];
|
|
61
|
+
const mapped = operands.map(normalizeOperandOrder);
|
|
62
|
+
|
|
63
|
+
if (typeof head === "string" && commutativeHeads.has(head)) {
|
|
64
|
+
mapped.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [head, ...mapped];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Structural equality over MathJSON (isSame compares number literals numerically). */
|
|
71
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
72
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
73
|
+
return (
|
|
74
|
+
Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((member, i) => sameJson(member, b[i]))
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
|
79
|
+
const left = a as Record<string, unknown>;
|
|
80
|
+
const right = b as Record<string, unknown>;
|
|
81
|
+
const keys = Object.keys(left);
|
|
82
|
+
|
|
83
|
+
return keys.length === Object.keys(right).length && keys.every((key) => sameJson(left[key], right[key]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return a === b;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseLatex(latex: string, form: "canonical" | "structural"): BoxedExpression | null {
|
|
90
|
+
if (latex.trim() === "") {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const expression = ce.parse(latex, { form });
|
|
95
|
+
|
|
96
|
+
return expression.isValid ? expression : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Finite real value of an expression, or null when it is not plainly numeric. */
|
|
100
|
+
function realValue(expression: BoxedExpression): number | null {
|
|
101
|
+
const numeric = expression.N();
|
|
102
|
+
const real = numeric.re;
|
|
103
|
+
const imaginary = numeric.im;
|
|
104
|
+
|
|
105
|
+
return Number.isFinite(real) && imaginary === 0 ? real : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function checkMathExpression(candidate: string, correct: string, options?: MathCheckOptions): MathCheckResult {
|
|
109
|
+
const mode = options?.mode ?? "equivalent";
|
|
110
|
+
const form = mode === "equivalent" ? "canonical" : "structural";
|
|
111
|
+
const prepare = mode === "literal" ? fingerprintRationalLiterals : (latex: string) => latex;
|
|
112
|
+
const candidateExpression = parseLatex(prepare(candidate), form);
|
|
113
|
+
|
|
114
|
+
if (candidateExpression === null) {
|
|
115
|
+
return { verdict: null, reason: "candidate-parse-error" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const correctExpression = parseLatex(prepare(correct), form);
|
|
119
|
+
|
|
120
|
+
if (correctExpression === null) {
|
|
121
|
+
return { verdict: null, reason: "correct-parse-error" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (mode === "literal") {
|
|
125
|
+
return {
|
|
126
|
+
verdict: sameJson(normalizeOperandOrder(candidateExpression.json), normalizeOperandOrder(correctExpression.json)),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (options?.tolerance !== undefined) {
|
|
131
|
+
const candidateValue = realValue(candidateExpression);
|
|
132
|
+
const correctValue = realValue(correctExpression);
|
|
133
|
+
|
|
134
|
+
if (candidateValue !== null && correctValue !== null) {
|
|
135
|
+
return { verdict: Math.abs(candidateValue - correctValue) <= options.tolerance };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const equal = candidateExpression.isEqual(correctExpression);
|
|
140
|
+
|
|
141
|
+
return equal === undefined ? { verdict: null, reason: "undecidable" } : { verdict: equal };
|
|
142
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { checkMathExpression } from "./checker";
|
|
2
|
+
export type { MathCheckMode, MathCheckOptions, MathCheckReason, MathCheckResult } from "./checker";
|
|
3
|
+
export { mathEquivalentClass, mathEquivalentOperator } from "./operator";
|
|
4
|
+
export { createMathEntryModule, mathEntryTypeIdentifier } from "./module";
|
|
5
|
+
export type { MathInputFactory, MathInputHandle, MathInputOptions } from "./module";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MathLive input adapter: a <math-field> custom element inside the module-owned
|
|
3
|
+
* container. Importing this file registers the element and pulls MathLive's full
|
|
4
|
+
* bundle — browser-only and heavy by design; consumers reach it through the ./module
|
|
5
|
+
* subpath via lazy import() (ADR-0007: descriptors eager, implementations lazy).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MathfieldElement } from "mathlive";
|
|
9
|
+
|
|
10
|
+
import type { MathInputFactory } from "./module";
|
|
11
|
+
|
|
12
|
+
export const mathLiveInput: MathInputFactory = (container, options) => {
|
|
13
|
+
const field = new MathfieldElement();
|
|
14
|
+
|
|
15
|
+
field.value = options.initialLatex;
|
|
16
|
+
|
|
17
|
+
if (options.disabled === true) {
|
|
18
|
+
field.readOnly = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
container.appendChild(field);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
getValue: () => field.value,
|
|
25
|
+
setValue: (latex) => {
|
|
26
|
+
field.value = latex;
|
|
27
|
+
},
|
|
28
|
+
destroy: () => {
|
|
29
|
+
field.remove();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser-ready math-entry PCI: MathLive input + checker verdicts. Heavy by
|
|
3
|
+
* design (MathLive + compute-engine) — load via lazy import() just before an item
|
|
4
|
+
* using the interaction mounts, and register with the consumer's PCI registry:
|
|
5
|
+
*
|
|
6
|
+
* const { mathEntryModule } = await import("@conform-ed/pci-math-entry/module");
|
|
7
|
+
* registry.registerModule("math-entry", mathEntryModule);
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mathLiveInput } from "./mathlive-input";
|
|
11
|
+
import { createMathEntryModule } from "./module";
|
|
12
|
+
|
|
13
|
+
export const mathEntryModule = createMathEntryModule(mathLiveInput);
|
|
14
|
+
export { mathEntryTypeIdentifier } from "./module";
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The math-entry PCI module (IMS PCI v1 against the qti-react host contract). The
|
|
3
|
+
* input widget is injected (`MathInputFactory`) so the module core stays testable and
|
|
4
|
+
* DOM-library-free; the MathLive adapter lives in ./mathlive-input and the bundled
|
|
5
|
+
* browser module in ./module-mathlive.
|
|
6
|
+
*
|
|
7
|
+
* Response of record (design decisions, BACKLOG #1): a PCI JSON record with the
|
|
8
|
+
* learner's LaTeX `expression` plus an advisory `verdict` computed by the same pure
|
|
9
|
+
* checker the platform re-scores with. The verdict exists only when the item carries
|
|
10
|
+
* checker config (`correct`, optional `mode`/`tolerance` properties — authored as
|
|
11
|
+
* data-* attributes); high-stakes deliveries redact those properties and the module
|
|
12
|
+
* degrades to expression-only, leaving scoring entirely server-side. An unjudgeable
|
|
13
|
+
* expression omits the verdict rather than guessing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PciConfiguration, PciInstance, PciModule } from "@conform-ed/qti-react";
|
|
17
|
+
|
|
18
|
+
import { checkMathExpression } from "./checker";
|
|
19
|
+
import type { MathCheckMode, MathCheckOptions } from "./checker";
|
|
20
|
+
|
|
21
|
+
export const mathEntryTypeIdentifier = "urn:conform-ed:pci:math-entry";
|
|
22
|
+
|
|
23
|
+
export interface MathInputOptions {
|
|
24
|
+
readonly initialLatex: string;
|
|
25
|
+
readonly disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MathInputHandle {
|
|
29
|
+
readonly getValue: () => string;
|
|
30
|
+
readonly setValue: (latex: string) => void;
|
|
31
|
+
readonly destroy?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Creates the actual input widget inside the module-owned container. */
|
|
35
|
+
export type MathInputFactory = (container: Element, options: MathInputOptions) => MathInputHandle;
|
|
36
|
+
|
|
37
|
+
interface MathEntryState {
|
|
38
|
+
readonly expression?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function restoredExpression(state: string | undefined): string {
|
|
42
|
+
if (state === undefined || state === "") {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(state) as MathEntryState;
|
|
48
|
+
|
|
49
|
+
return typeof parsed.expression === "string" ? parsed.expression : "";
|
|
50
|
+
} catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function checkerOptions(properties: Readonly<Record<string, string>>): MathCheckOptions {
|
|
56
|
+
const mode = properties["mode"];
|
|
57
|
+
const tolerance = Number(properties["tolerance"]);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...(mode === "equivalent" || mode === "literal" ? { mode: mode as MathCheckMode } : {}),
|
|
61
|
+
...(properties["tolerance"] !== undefined && Number.isFinite(tolerance) ? { tolerance } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createMathEntryModule(input: MathInputFactory): PciModule {
|
|
66
|
+
return {
|
|
67
|
+
typeIdentifier: mathEntryTypeIdentifier,
|
|
68
|
+
|
|
69
|
+
getInstance(dom: Element, configuration: PciConfiguration, state: string | undefined): PciInstance {
|
|
70
|
+
const properties = configuration.properties ?? {};
|
|
71
|
+
const container = dom.ownerDocument.createElement("div");
|
|
72
|
+
|
|
73
|
+
container.className = "math-entry-pci";
|
|
74
|
+
dom.appendChild(container);
|
|
75
|
+
|
|
76
|
+
const handle = input(container, { initialLatex: restoredExpression(state) });
|
|
77
|
+
|
|
78
|
+
const instance: PciInstance = {
|
|
79
|
+
typeIdentifier: mathEntryTypeIdentifier,
|
|
80
|
+
|
|
81
|
+
getResponse(): unknown {
|
|
82
|
+
const latex = handle.getValue().trim();
|
|
83
|
+
const expressionEntry =
|
|
84
|
+
latex === "" ? { name: "expression", base: null } : { name: "expression", base: { string: latex } };
|
|
85
|
+
const correct = properties["correct"];
|
|
86
|
+
|
|
87
|
+
if (latex === "" || correct === undefined) {
|
|
88
|
+
return { record: [expressionEntry] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { verdict } = checkMathExpression(latex, correct, checkerOptions(properties));
|
|
92
|
+
|
|
93
|
+
// A null verdict is unjudgeable input: omit the field, never guess.
|
|
94
|
+
return verdict === null
|
|
95
|
+
? { record: [expressionEntry] }
|
|
96
|
+
: { record: [expressionEntry, { name: "verdict", base: { boolean: verdict } }] };
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
getState(): string {
|
|
100
|
+
return JSON.stringify({ expression: handle.getValue() });
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
oncompleted(): void {
|
|
104
|
+
handle.destroy?.();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
configuration.onready?.(instance, state);
|
|
109
|
+
|
|
110
|
+
return instance;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/operator.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The math checker exposed to QTI response processing as a `customOperator`
|
|
3
|
+
* (qti-react extension seam, ADR-0007 vocabulary). Positional arguments, all
|
|
4
|
+
* authorable as plain `qti-base-value`:
|
|
5
|
+
*
|
|
6
|
+
* 1. candidate LaTeX (usually `qti-variable` / `qti-field-value`)
|
|
7
|
+
* 2. correct LaTeX
|
|
8
|
+
* 3. optional mode: "equivalent" (default) | "literal"
|
|
9
|
+
* 4. optional absolute tolerance (float)
|
|
10
|
+
*
|
|
11
|
+
* NULL propagates per QTI convention; an unjudgeable verdict is NULL, never a guess.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CustomOperatorImplementation, MaybeRpValue } from "@conform-ed/qti-react";
|
|
15
|
+
|
|
16
|
+
import { checkMathExpression } from "./checker";
|
|
17
|
+
import type { MathCheckMode, MathCheckOptions } from "./checker";
|
|
18
|
+
|
|
19
|
+
export const mathEquivalentClass = "org.conform-ed.mathEquivalent";
|
|
20
|
+
|
|
21
|
+
function singleString(value: MaybeRpValue): string | null {
|
|
22
|
+
if (value === null || value.values.length !== 1) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const member = value.values[0];
|
|
27
|
+
|
|
28
|
+
return typeof member === "string" ? member : typeof member === "number" ? String(member) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseMode(value: MaybeRpValue): MathCheckMode | undefined {
|
|
32
|
+
const mode = singleString(value);
|
|
33
|
+
|
|
34
|
+
return mode === "equivalent" || mode === "literal" ? mode : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const mathEquivalentOperator: CustomOperatorImplementation = (args) => {
|
|
38
|
+
const candidate = singleString(args[0] ?? null);
|
|
39
|
+
const correct = singleString(args[1] ?? null);
|
|
40
|
+
|
|
41
|
+
if (candidate === null || correct === null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mode = parseMode(args[2] ?? null);
|
|
46
|
+
const toleranceMember = args[3]?.values[0];
|
|
47
|
+
const tolerance = typeof toleranceMember === "number" ? toleranceMember : undefined;
|
|
48
|
+
const options: MathCheckOptions = {
|
|
49
|
+
...(mode === undefined ? {} : { mode }),
|
|
50
|
+
...(tolerance === undefined ? {} : { tolerance }),
|
|
51
|
+
};
|
|
52
|
+
const result = checkMathExpression(candidate, correct, options);
|
|
53
|
+
|
|
54
|
+
return result.verdict === null ? null : { cardinality: "single", baseType: "boolean", values: [result.verdict] };
|
|
55
|
+
};
|