@holoscript/plugin-education-lms 2.0.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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +14 -0
- package/src/__tests__/educationsolver.test.ts +424 -0
- package/src/educationsolver.ts +481 -0
- package/src/index.ts +16 -0
- package/src/traits/CourseTrait.ts +22 -0
- package/src/traits/EnrollmentTrait.ts +22 -0
- package/src/traits/GradeTrait.ts +27 -0
- package/src/traits/LessonTrait.ts +20 -0
- package/src/traits/QuizTrait.ts +30 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +10 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-education-lms",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "HoloScript domain plugin for education-lms",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"@holoscript/core": "8.0.6"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run --passWithNoTests",
|
|
12
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Education LMS solver tests — education-lms-plugin
|
|
3
|
+
*
|
|
4
|
+
* Reference values verified against:
|
|
5
|
+
* - Lord FM (1980) Applications of Item Response Theory. ETS.
|
|
6
|
+
* - Wozniak P (1990) Optimization of Learning. SuperMemo SM-2.
|
|
7
|
+
* - Bloom BS et al. (1956) Taxonomy of Educational Objectives.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
irtAbilityEstimate,
|
|
13
|
+
sm2Review,
|
|
14
|
+
learningPathOptimizer,
|
|
15
|
+
gradePredictor,
|
|
16
|
+
bloomClassifier,
|
|
17
|
+
quizPsychometrics,
|
|
18
|
+
buildEducationReceipt,
|
|
19
|
+
} from '../educationsolver';
|
|
20
|
+
|
|
21
|
+
// ─── IRT 3PL ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('irtAbilityEstimate', () => {
|
|
24
|
+
/**
|
|
25
|
+
* Simple 1PL (a=1, c=0): student answers all correctly.
|
|
26
|
+
* MLE θ → unbounded; clamped to +6.
|
|
27
|
+
*/
|
|
28
|
+
it('all correct → high positive ability estimate', () => {
|
|
29
|
+
const items = [
|
|
30
|
+
{ id: 'i1', b: -1 },
|
|
31
|
+
{ id: 'i2', b: 0 },
|
|
32
|
+
{ id: 'i3', b: 1 },
|
|
33
|
+
];
|
|
34
|
+
const responses = [
|
|
35
|
+
{ itemId: 'i1', correct: true },
|
|
36
|
+
{ itemId: 'i2', correct: true },
|
|
37
|
+
{ itemId: 'i3', correct: true },
|
|
38
|
+
];
|
|
39
|
+
const r = irtAbilityEstimate(items, responses);
|
|
40
|
+
expect(r.abilityTheta).toBeGreaterThan(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('all incorrect → low negative ability estimate', () => {
|
|
44
|
+
const items = [
|
|
45
|
+
{ id: 'i1', b: -1 },
|
|
46
|
+
{ id: 'i2', b: 0 },
|
|
47
|
+
{ id: 'i3', b: 1 },
|
|
48
|
+
];
|
|
49
|
+
const responses = [
|
|
50
|
+
{ itemId: 'i1', correct: false },
|
|
51
|
+
{ itemId: 'i2', correct: false },
|
|
52
|
+
{ itemId: 'i3', correct: false },
|
|
53
|
+
];
|
|
54
|
+
const r = irtAbilityEstimate(items, responses);
|
|
55
|
+
expect(r.abilityTheta).toBeLessThan(-1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ability near item difficulty for 50/50 pattern', () => {
|
|
59
|
+
// Items with difficulties -1, +1; student gets easy correct, hard wrong
|
|
60
|
+
const items = [{ id: 'easy', b: -2 }, { id: 'hard', b: 2 }];
|
|
61
|
+
const responses = [{ itemId: 'easy', correct: true }, { itemId: 'hard', correct: false }];
|
|
62
|
+
const r = irtAbilityEstimate(items, responses);
|
|
63
|
+
// Ability should be in middle range
|
|
64
|
+
expect(r.abilityTheta).toBeGreaterThan(-2);
|
|
65
|
+
expect(r.abilityTheta).toBeLessThan(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('testInformation = sum of itemInformation', () => {
|
|
69
|
+
const items = [{ id: 'i1', b: 0 }, { id: 'i2', b: 1 }];
|
|
70
|
+
const responses = [{ itemId: 'i1', correct: true }, { itemId: 'i2', correct: false }];
|
|
71
|
+
const r = irtAbilityEstimate(items, responses);
|
|
72
|
+
const manualSum = r.itemInformation.reduce((s, x) => s + x.information, 0);
|
|
73
|
+
expect(r.testInformation).toBeCloseTo(manualSum, 6);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('standardError = 1/sqrt(testInformation)', () => {
|
|
77
|
+
const items = [{ id: 'i1', b: 0 }, { id: 'i2', b: 0.5 }];
|
|
78
|
+
const responses = [{ itemId: 'i1', correct: true }, { itemId: 'i2', correct: true }];
|
|
79
|
+
const r = irtAbilityEstimate(items, responses);
|
|
80
|
+
expect(r.standardError).toBeCloseTo(1 / Math.sqrt(r.testInformation), 4);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('itemFit length matches items length', () => {
|
|
84
|
+
const items = [{ id: 'i1', b: 0 }, { id: 'i2', b: 1 }, { id: 'i3', b: -1 }];
|
|
85
|
+
const responses = items.map(it => ({ itemId: it.id, correct: true }));
|
|
86
|
+
const r = irtAbilityEstimate(items, responses);
|
|
87
|
+
expect(r.itemFit).toHaveLength(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('higher discrimination a → more information near item difficulty', () => {
|
|
91
|
+
// Use mixed responses: easy item correct, hard item wrong → theta stays near 0
|
|
92
|
+
// where discrimination parameter a is most influential
|
|
93
|
+
const itemsLow = [{ id: 'easy', a: 0.5, b: -2 }, { id: 'hard', a: 0.5, b: 2 }];
|
|
94
|
+
const itemsHigh = [{ id: 'easy', a: 2.0, b: -2 }, { id: 'hard', a: 2.0, b: 2 }];
|
|
95
|
+
const responsesLow = [{ itemId: 'easy', correct: true }, { itemId: 'hard', correct: false }];
|
|
96
|
+
const responsesHigh = [{ itemId: 'easy', correct: true }, { itemId: 'hard', correct: false }];
|
|
97
|
+
const rLow = irtAbilityEstimate(itemsLow, responsesLow);
|
|
98
|
+
const rHigh = irtAbilityEstimate(itemsHigh, responsesHigh);
|
|
99
|
+
// Higher discrimination → higher test information at estimated theta
|
|
100
|
+
expect(rHigh.testInformation).toBeGreaterThan(rLow.testInformation);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('throws for empty items', () => {
|
|
104
|
+
expect(() => irtAbilityEstimate([], [])).toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws when responses length ≠ items length', () => {
|
|
108
|
+
const items = [{ id: 'i1', b: 0 }];
|
|
109
|
+
expect(() => irtAbilityEstimate(items, [])).toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─── SM-2 ─────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe('sm2Review', () => {
|
|
116
|
+
/**
|
|
117
|
+
* First review quality=5 (perfect recall):
|
|
118
|
+
* EF' = 2.5 + (0.1 - (5-5)(0.08 + 0)) = 2.5
|
|
119
|
+
* rep=1 → interval=1
|
|
120
|
+
*/
|
|
121
|
+
it('first review quality=5 → interval=1, repetitions=1', () => {
|
|
122
|
+
const card = { id: 'c1' };
|
|
123
|
+
const r = sm2Review(card, 5);
|
|
124
|
+
expect(r.interval).toBe(1);
|
|
125
|
+
expect(r.repetitions).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('second review quality=5 → interval=6', () => {
|
|
129
|
+
const card = { id: 'c1', interval: 1, repetitions: 1, ef: 2.5 };
|
|
130
|
+
const r = sm2Review(card, 5);
|
|
131
|
+
expect(r.interval).toBe(6);
|
|
132
|
+
expect(r.repetitions).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('third review quality=5 → interval = round(6 × EF)', () => {
|
|
136
|
+
const card = { id: 'c1', interval: 6, repetitions: 2, ef: 2.5 };
|
|
137
|
+
const r = sm2Review(card, 5);
|
|
138
|
+
expect(r.interval).toBe(Math.round(6 * 2.5));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('quality < 3 resets repetitions to 0 and interval to 1', () => {
|
|
142
|
+
const card = { id: 'c1', interval: 10, repetitions: 4, ef: 2.3 };
|
|
143
|
+
const r = sm2Review(card, 2);
|
|
144
|
+
expect(r.repetitions).toBe(0);
|
|
145
|
+
expect(r.interval).toBe(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('EF decreases on poor recall (quality=2)', () => {
|
|
149
|
+
const card = { id: 'c1', ef: 2.5 };
|
|
150
|
+
const r = sm2Review(card, 2);
|
|
151
|
+
expect(r.ef).toBeLessThan(2.5);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('EF clamped to [1.3, 2.5]', () => {
|
|
155
|
+
const card = { id: 'c1', ef: 1.3 };
|
|
156
|
+
const r0 = sm2Review(card, 0);
|
|
157
|
+
expect(r0.ef).toBeGreaterThanOrEqual(1.3);
|
|
158
|
+
const cardHigh = { id: 'c1', ef: 2.5 };
|
|
159
|
+
const r5 = sm2Review(cardHigh, 5);
|
|
160
|
+
expect(r5.ef).toBeLessThanOrEqual(2.5);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('dueDays equals interval', () => {
|
|
164
|
+
const card = { id: 'c1', interval: 6, repetitions: 2, ef: 2.5 };
|
|
165
|
+
const r = sm2Review(card, 4);
|
|
166
|
+
expect(r.dueDays).toBe(r.interval);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('throws for quality out of [0,5]', () => {
|
|
170
|
+
// @ts-expect-error testing invalid input
|
|
171
|
+
expect(() => sm2Review({ id: 'c1' }, 6)).toThrow();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── Learning Path Optimizer ──────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('learningPathOptimizer', () => {
|
|
178
|
+
const nodes = [
|
|
179
|
+
{ id: 'A', label: 'Algebra', prerequisites: [], estimatedMinutes: 60 },
|
|
180
|
+
{ id: 'B', label: 'Calculus', prerequisites: ['A'], estimatedMinutes: 90 },
|
|
181
|
+
{ id: 'C', label: 'Statistics', prerequisites: ['A'], estimatedMinutes: 45 },
|
|
182
|
+
{ id: 'D', label: 'ML Basics', prerequisites: ['B','C'], estimatedMinutes: 120 },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
it('path is in topological order (prerequisites before dependents)', () => {
|
|
186
|
+
const mastery: { nodeId: string; masteryScore: number }[] = [];
|
|
187
|
+
const r = learningPathOptimizer(nodes, mastery);
|
|
188
|
+
const pos = (id: string) => r.path.indexOf(id);
|
|
189
|
+
// A before B and C (if both appear)
|
|
190
|
+
if (pos('A') !== -1 && pos('B') !== -1) expect(pos('A')).toBeLessThan(pos('B'));
|
|
191
|
+
if (pos('A') !== -1 && pos('C') !== -1) expect(pos('A')).toBeLessThan(pos('C'));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('mastered nodes excluded from path', () => {
|
|
195
|
+
const mastery = [{ nodeId: 'A', masteryScore: 0.95 }];
|
|
196
|
+
const r = learningPathOptimizer(nodes, mastery);
|
|
197
|
+
expect(r.path).not.toContain('A');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('totalMinutes = sum of estimatedMinutes for unmastered path nodes', () => {
|
|
201
|
+
const mastery: { nodeId: string; masteryScore: number }[] = [];
|
|
202
|
+
const r = learningPathOptimizer(nodes, mastery);
|
|
203
|
+
const expected = r.path.reduce((acc, id) => {
|
|
204
|
+
const n = nodes.find(x => x.id === id);
|
|
205
|
+
return acc + (n?.estimatedMinutes ?? 30);
|
|
206
|
+
}, 0);
|
|
207
|
+
expect(r.totalMinutes).toBe(expected);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('feasible=true for acyclic DAG', () => {
|
|
211
|
+
const r = learningPathOptimizer(nodes, []);
|
|
212
|
+
expect(r.feasible).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('throws if prerequisite node not found', () => {
|
|
216
|
+
const badNodes = [
|
|
217
|
+
{ id: 'X', label: 'X', prerequisites: ['MISSING'], estimatedMinutes: 30 },
|
|
218
|
+
];
|
|
219
|
+
expect(() => learningPathOptimizer(badNodes, [])).toThrow();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('throws for empty nodes', () => {
|
|
223
|
+
expect(() => learningPathOptimizer([], [])).toThrow();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ─── Grade Predictor ──────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe('gradePredictor', () => {
|
|
230
|
+
it('uniform grades → predictedGrade ≈ score', () => {
|
|
231
|
+
const grades = [
|
|
232
|
+
{ weight: 1, score: 0.85, recencyIndex: 1 },
|
|
233
|
+
{ weight: 1, score: 0.85, recencyIndex: 2 },
|
|
234
|
+
{ weight: 1, score: 0.85, recencyIndex: 3 },
|
|
235
|
+
];
|
|
236
|
+
const r = gradePredictor(grades);
|
|
237
|
+
expect(r.predictedGrade).toBeCloseTo(0.85, 2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('recent grades weighted more heavily with decay < 1', () => {
|
|
241
|
+
const grades = [
|
|
242
|
+
{ weight: 1, score: 0.50, recencyIndex: 1 }, // old, low
|
|
243
|
+
{ weight: 1, score: 0.95, recencyIndex: 5 }, // recent, high
|
|
244
|
+
];
|
|
245
|
+
const r = gradePredictor(grades, 0.5);
|
|
246
|
+
// Should be closer to 0.95 than to plain average
|
|
247
|
+
expect(r.predictedGrade).toBeGreaterThan(0.70);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('predictedGrade 0.93+ → letter A, gpa=4.0', () => {
|
|
251
|
+
const grades = [{ weight: 1, score: 0.95, recencyIndex: 1 }];
|
|
252
|
+
const r = gradePredictor(grades);
|
|
253
|
+
expect(r.letterGrade).toBe('A');
|
|
254
|
+
expect(r.gpa).toBe(4.0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('predictedGrade below 0.60 → letter F, gpa=0.0', () => {
|
|
258
|
+
const grades = [{ weight: 1, score: 0.50, recencyIndex: 1 }];
|
|
259
|
+
const r = gradePredictor(grades);
|
|
260
|
+
expect(r.letterGrade).toBe('F');
|
|
261
|
+
expect(r.gpa).toBe(0.0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('throws for empty grades', () => {
|
|
265
|
+
expect(() => gradePredictor([])).toThrow();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('throws for decay > 1', () => {
|
|
269
|
+
const grades = [{ weight: 1, score: 0.8, recencyIndex: 1 }];
|
|
270
|
+
expect(() => gradePredictor(grades, 1.5)).toThrow();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ─── Bloom Classifier ─────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
describe('bloomClassifier', () => {
|
|
277
|
+
it('"define the term" → L1 Remember', () => {
|
|
278
|
+
const r = bloomClassifier('define the term photosynthesis');
|
|
279
|
+
expect(r.level).toBe(1);
|
|
280
|
+
expect(r.label).toBe('Remember');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('"explain how the algorithm works" → L2 Understand', () => {
|
|
284
|
+
const r = bloomClassifier('explain how the algorithm works and summarize results');
|
|
285
|
+
expect(r.level).toBe(2);
|
|
286
|
+
expect(r.label).toBe('Understand');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('"apply the formula to solve the equation" → L3 Apply', () => {
|
|
290
|
+
const r = bloomClassifier('apply the formula to calculate and solve the problem');
|
|
291
|
+
expect(r.level).toBe(3);
|
|
292
|
+
expect(r.label).toBe('Apply');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('"analyze and differentiate" → L4 Analyze', () => {
|
|
296
|
+
const r = bloomClassifier('analyze the data and differentiate between approaches');
|
|
297
|
+
expect(r.level).toBe(4);
|
|
298
|
+
expect(r.label).toBe('Analyze');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('"evaluate and critique the design" → L5 Evaluate', () => {
|
|
302
|
+
const r = bloomClassifier('evaluate and critique the design choices and assess tradeoffs');
|
|
303
|
+
expect(r.level).toBe(5);
|
|
304
|
+
expect(r.label).toBe('Evaluate');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('"design and create a new system" → L6 Create', () => {
|
|
308
|
+
const r = bloomClassifier('design and create a new system, then develop the plan');
|
|
309
|
+
expect(r.level).toBe(6);
|
|
310
|
+
expect(r.label).toBe('Create');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('unrecognized text → L1 with confidence=0', () => {
|
|
314
|
+
const r = bloomClassifier('the quick brown fox');
|
|
315
|
+
expect(r.level).toBe(1);
|
|
316
|
+
expect(r.confidence).toBe(0);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── Quiz Psychometrics ───────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
describe('quizPsychometrics', () => {
|
|
323
|
+
/**
|
|
324
|
+
* 5 students, 3 items
|
|
325
|
+
* Easy item (p=1), medium (p=0.6), hard (p=0.2)
|
|
326
|
+
*/
|
|
327
|
+
const responses = [
|
|
328
|
+
[1, 1, 1], // student 1: 3/3
|
|
329
|
+
[1, 1, 0], // student 2: 2/3
|
|
330
|
+
[1, 1, 0], // student 3: 2/3
|
|
331
|
+
[1, 0, 0], // student 4: 1/3
|
|
332
|
+
[1, 0, 0], // student 5: 1/3
|
|
333
|
+
];
|
|
334
|
+
const itemIds = ['easy', 'medium', 'hard'];
|
|
335
|
+
|
|
336
|
+
it('pValues correct for each item', () => {
|
|
337
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
338
|
+
expect(r.pValues[0]).toBeCloseTo(1.0, 4); // all got easy
|
|
339
|
+
expect(r.pValues[1]).toBeCloseTo(0.6, 4); // 3/5
|
|
340
|
+
expect(r.pValues[2]).toBeCloseTo(0.2, 4); // 1/5
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('meanScore = average total score', () => {
|
|
344
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
345
|
+
const expected = (3 + 2 + 2 + 1 + 1) / 5;
|
|
346
|
+
expect(r.meanScore).toBeCloseTo(expected, 4);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('cronbachAlpha in [0, 1] for valid quiz', () => {
|
|
350
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
351
|
+
expect(r.cronbachAlpha).toBeGreaterThanOrEqual(0);
|
|
352
|
+
expect(r.cronbachAlpha).toBeLessThanOrEqual(1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('perfect item (p=1) has 0 discrimination index', () => {
|
|
356
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
357
|
+
// Easy item everyone got right → no discrimination
|
|
358
|
+
expect(r.discriminationIndices[0]).toBeCloseTo(0, 4);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('hard item shows positive discrimination', () => {
|
|
362
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
363
|
+
// High scorers got hard item; low scorers did not → positive D
|
|
364
|
+
expect(r.discriminationIndices[2]).toBeGreaterThan(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('itemCount and n correct', () => {
|
|
368
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
369
|
+
expect(r.itemCount).toBe(3);
|
|
370
|
+
expect(r.n).toBe(5);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('poorItems includes items with D < 0.2', () => {
|
|
374
|
+
const r = quizPsychometrics(responses, itemIds);
|
|
375
|
+
for (const id of r.poorItems) {
|
|
376
|
+
const idx = itemIds.indexOf(id);
|
|
377
|
+
expect(r.discriminationIndices[idx]).toBeLessThan(0.2);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('throws for < 2 respondents', () => {
|
|
382
|
+
expect(() => quizPsychometrics([[1, 0]], itemIds)).toThrow();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ─── Receipt ─────────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
describe('buildEducationReceipt', () => {
|
|
389
|
+
it('plugin=education-lms and CAEL event correct', () => {
|
|
390
|
+
const items = [{ id: 'i1', b: 0 }];
|
|
391
|
+
const irt = irtAbilityEstimate(items, [{ itemId: 'i1', correct: true }]);
|
|
392
|
+
const receipt = buildEducationReceipt({ irt, converged: true });
|
|
393
|
+
expect(receipt.plugin).toBe('education-lms');
|
|
394
|
+
expect(receipt.cael.event).toBe('education_lms.learning_analysis');
|
|
395
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('accepted=true for converged result', () => {
|
|
399
|
+
const receipt = buildEducationReceipt({ converged: true });
|
|
400
|
+
expect(receipt.acceptance.accepted).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('accepted=false when quiz has poor reliability (α < 0.70)', () => {
|
|
404
|
+
// Psychometrics with very low cronbach alpha triggers a violation
|
|
405
|
+
const badPsychometrics = {
|
|
406
|
+
itemCount: 3,
|
|
407
|
+
n: 10,
|
|
408
|
+
pValues: [0.5, 0.5, 0.5],
|
|
409
|
+
discriminationIndices: [0.05, 0.05, 0.05], // all poor → > 50% poor items
|
|
410
|
+
cronbachAlpha: 0.30, // below 0.70 threshold
|
|
411
|
+
meanScore: 1.5,
|
|
412
|
+
stdDevScore: 0.5,
|
|
413
|
+
poorItems: ['i1', 'i2', 'i3'],
|
|
414
|
+
};
|
|
415
|
+
const receipt = buildEducationReceipt({ psychometrics: badPsychometrics, converged: true });
|
|
416
|
+
expect(receipt.acceptance.accepted).toBe(false);
|
|
417
|
+
expect(receipt.acceptance.violations.length).toBeGreaterThan(0);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('uses provided runId', () => {
|
|
421
|
+
const receipt = buildEducationReceipt({ converged: true }, { runId: 'edu-run-99' });
|
|
422
|
+
expect(receipt.runId).toBe('edu-run-99');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learning analytics solvers — education-lms-plugin
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - Item Response Theory (3PL logistic model, MLE ability estimation)
|
|
6
|
+
* - SM-2 spaced repetition scheduler (Wozniak 1990)
|
|
7
|
+
* - Knowledge space prerequisite graph + mastery tracking
|
|
8
|
+
* - Grade prediction (recency-weighted moving average)
|
|
9
|
+
* - Learning path optimization (Dijkstra on prerequisite DAG)
|
|
10
|
+
* - Bloom's taxonomy classification (verb heuristic)
|
|
11
|
+
* - Quiz psychometrics (p-value, discrimination index, Cronbach's α)
|
|
12
|
+
*
|
|
13
|
+
* References:
|
|
14
|
+
* - Lord FM (1980) Applications of Item Response Theory. ETS.
|
|
15
|
+
* - Wozniak P (1990) Optimization of Learning. SuperMemo SM-2.
|
|
16
|
+
* - Bloom BS et al. (1956) Taxonomy of Educational Objectives.
|
|
17
|
+
* - Doignon JP, Falmagne JC (1985) Psych.Rev. 92:201-224
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@holoscript/core';
|
|
21
|
+
|
|
22
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface IRTItem {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Discrimination parameter a > 0 (default 1.0) */
|
|
27
|
+
a?: number;
|
|
28
|
+
/** Difficulty parameter b (logit scale, ~-3 to +3) */
|
|
29
|
+
b: number;
|
|
30
|
+
/** Pseudo-guessing parameter c ∈ [0,1] (default 0) */
|
|
31
|
+
c?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IRTResponse {
|
|
35
|
+
itemId: string;
|
|
36
|
+
correct: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IRTResult {
|
|
40
|
+
/** Estimated ability θ (logit scale) */
|
|
41
|
+
abilityTheta: number;
|
|
42
|
+
/** Standard error of estimate */
|
|
43
|
+
standardError: number;
|
|
44
|
+
/** Item information at estimated θ */
|
|
45
|
+
itemInformation: Array<{ itemId: string; information: number }>;
|
|
46
|
+
/** Test information (sum) */
|
|
47
|
+
testInformation: number;
|
|
48
|
+
/** Item fit residuals */
|
|
49
|
+
itemFit: Array<{ itemId: string; residual: number }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SM2Card {
|
|
53
|
+
id: string;
|
|
54
|
+
/** Ease factor EF ∈ [1.3, 2.5], default 2.5 */
|
|
55
|
+
ef?: number;
|
|
56
|
+
/** Current interval days */
|
|
57
|
+
interval?: number;
|
|
58
|
+
/** Total repetitions */
|
|
59
|
+
repetitions?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SM2Result {
|
|
63
|
+
id: string;
|
|
64
|
+
/** New ease factor */
|
|
65
|
+
ef: number;
|
|
66
|
+
/** Next review interval days */
|
|
67
|
+
interval: number;
|
|
68
|
+
/** Total repetitions after this review */
|
|
69
|
+
repetitions: number;
|
|
70
|
+
/** Due date (days from now) */
|
|
71
|
+
dueDays: number;
|
|
72
|
+
/** Quality of recall 0-5 */
|
|
73
|
+
quality: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface KnowledgeNode {
|
|
77
|
+
id: string;
|
|
78
|
+
label: string;
|
|
79
|
+
/** IDs of prerequisite nodes */
|
|
80
|
+
prerequisites: string[];
|
|
81
|
+
/** Estimated minutes to master */
|
|
82
|
+
estimatedMinutes?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MasteryState {
|
|
86
|
+
nodeId: string;
|
|
87
|
+
masteryScore: number; // 0–1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface LearningPathResult {
|
|
91
|
+
/** Ordered list of node IDs to study */
|
|
92
|
+
path: string[];
|
|
93
|
+
/** Total estimated minutes */
|
|
94
|
+
totalMinutes: number;
|
|
95
|
+
/** Whether all prerequisites are satisfiable */
|
|
96
|
+
feasible: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface QuizPsychometrics {
|
|
100
|
+
/** Number of items */
|
|
101
|
+
itemCount: number;
|
|
102
|
+
/** Number of respondents */
|
|
103
|
+
n: number;
|
|
104
|
+
/** Per-item p-value (proportion correct) */
|
|
105
|
+
pValues: number[];
|
|
106
|
+
/** Per-item discrimination index D = (upper27% - lower27%) correct rate */
|
|
107
|
+
discriminationIndices: number[];
|
|
108
|
+
/** Cronbach's alpha */
|
|
109
|
+
cronbachAlpha: number;
|
|
110
|
+
/** Mean score */
|
|
111
|
+
meanScore: number;
|
|
112
|
+
/** Score standard deviation */
|
|
113
|
+
stdDevScore: number;
|
|
114
|
+
/** Items flagged for poor discrimination (D < 0.2) */
|
|
115
|
+
poorItems: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface BloomLevel {
|
|
119
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
120
|
+
label: 'Remember' | 'Understand' | 'Apply' | 'Analyze' | 'Evaluate' | 'Create';
|
|
121
|
+
confidence: number; // 0–1, fraction of matched verbs
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface EducationReceiptOptions {
|
|
125
|
+
runId?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── IRT 3-Parameter Logistic Model ──────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** P(correct | θ, a, b, c) = c + (1-c) / (1 + exp(-a(θ-b))) */
|
|
131
|
+
function irt3PL(theta: number, a: number, b: number, c: number): number {
|
|
132
|
+
return c + (1 - c) / (1 + Math.exp(-a * (theta - b)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Item information I(θ) = a²(1-P)(P-c)² / ((1-c)²P) */
|
|
136
|
+
function itemInfo(theta: number, a: number, b: number, c: number): number {
|
|
137
|
+
const P = irt3PL(theta, a, b, c);
|
|
138
|
+
const Q = 1 - P;
|
|
139
|
+
if (P <= c || P >= 1) return 0;
|
|
140
|
+
return (a * a * Q * (P - c) * (P - c)) / ((1 - c) * (1 - c) * P);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Estimate student ability θ via Newton-Raphson MLE.
|
|
145
|
+
* Iterates until convergence or max 50 steps.
|
|
146
|
+
*/
|
|
147
|
+
export function irtAbilityEstimate(
|
|
148
|
+
items: IRTItem[],
|
|
149
|
+
responses: IRTResponse[],
|
|
150
|
+
): IRTResult {
|
|
151
|
+
if (items.length === 0) throw new Error('No items provided');
|
|
152
|
+
if (responses.length !== items.length) throw new Error('responses must match items length');
|
|
153
|
+
|
|
154
|
+
const itemMap = new Map(items.map(it => [it.id, it]));
|
|
155
|
+
const responseMap = new Map(responses.map(r => [r.itemId, r.correct ? 1 : 0]));
|
|
156
|
+
|
|
157
|
+
// Newton-Raphson MLE
|
|
158
|
+
let theta = 0.0;
|
|
159
|
+
for (let iter = 0; iter < 50; iter++) {
|
|
160
|
+
let firstDeriv = 0, secondDeriv = 0;
|
|
161
|
+
for (const item of items) {
|
|
162
|
+
const a = item.a ?? 1.0, b = item.b, c = item.c ?? 0;
|
|
163
|
+
const P = irt3PL(theta, a, b, c);
|
|
164
|
+
const u = responseMap.get(item.id) ?? 0;
|
|
165
|
+
const W = (P - c) / ((1 - c) * P * (1 - P) + 1e-15);
|
|
166
|
+
const dP = a * (P - c) * (1 - P) / (1 - c + 1e-15);
|
|
167
|
+
firstDeriv += (u - P) * dP / (P * (1 - P) + 1e-15);
|
|
168
|
+
secondDeriv -= dP * dP / (P * (1 - P) + 1e-15);
|
|
169
|
+
}
|
|
170
|
+
if (Math.abs(secondDeriv) < 1e-12) break;
|
|
171
|
+
const step = firstDeriv / secondDeriv;
|
|
172
|
+
theta -= step;
|
|
173
|
+
theta = Math.max(-6, Math.min(6, theta));
|
|
174
|
+
if (Math.abs(step) < 1e-6) break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Standard error = 1 / sqrt(test information)
|
|
178
|
+
const infoArr = items.map(item => {
|
|
179
|
+
const a = item.a ?? 1.0, b = item.b, c = item.c ?? 0;
|
|
180
|
+
return { itemId: item.id, information: itemInfo(theta, a, b, c) };
|
|
181
|
+
});
|
|
182
|
+
const testInformation = infoArr.reduce((s, x) => s + x.information, 0);
|
|
183
|
+
const standardError = testInformation > 0 ? 1 / Math.sqrt(testInformation) : Infinity;
|
|
184
|
+
|
|
185
|
+
// Item fit residuals
|
|
186
|
+
const itemFit = items.map(item => {
|
|
187
|
+
const a = item.a ?? 1.0, b = item.b, c = item.c ?? 0;
|
|
188
|
+
const P = irt3PL(theta, a, b, c);
|
|
189
|
+
const u = responseMap.get(item.id) ?? 0;
|
|
190
|
+
const residual = (u - P) / Math.sqrt(P * (1 - P) + 1e-15);
|
|
191
|
+
return { itemId: item.id, residual };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { abilityTheta: theta, standardError, itemInformation: infoArr, testInformation, itemFit };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── SM-2 Spaced Repetition ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Apply one SM-2 review to a flashcard.
|
|
201
|
+
* quality: 0=complete blackout, 5=perfect recall
|
|
202
|
+
* EF' = EF + (0.1 - (5-q)(0.08 + (5-q)×0.02))
|
|
203
|
+
* If quality < 3: reset repetitions and interval.
|
|
204
|
+
*/
|
|
205
|
+
export function sm2Review(card: SM2Card, quality: 0 | 1 | 2 | 3 | 4 | 5): SM2Result {
|
|
206
|
+
if (quality < 0 || quality > 5) throw new Error('quality must be 0-5');
|
|
207
|
+
|
|
208
|
+
let ef = card.ef ?? 2.5;
|
|
209
|
+
let rep = card.repetitions ?? 0;
|
|
210
|
+
let interval = card.interval ?? 1;
|
|
211
|
+
|
|
212
|
+
// Update EF (clamp to [1.3, 2.5])
|
|
213
|
+
ef = Math.max(1.3, Math.min(2.5, ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))));
|
|
214
|
+
|
|
215
|
+
if (quality < 3) {
|
|
216
|
+
// Failure — restart
|
|
217
|
+
rep = 0;
|
|
218
|
+
interval = 1;
|
|
219
|
+
} else {
|
|
220
|
+
rep++;
|
|
221
|
+
if (rep === 1) interval = 1;
|
|
222
|
+
else if (rep === 2) interval = 6;
|
|
223
|
+
else interval = Math.round(interval * ef);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { id: card.id, ef, interval, repetitions: rep, dueDays: interval, quality };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Knowledge Space + Learning Path ─────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Topological sort of prerequisite DAG.
|
|
233
|
+
* Returns nodes in study order starting from unmastered leaves.
|
|
234
|
+
*/
|
|
235
|
+
export function learningPathOptimizer(
|
|
236
|
+
nodes: KnowledgeNode[],
|
|
237
|
+
masteryStates: MasteryState[],
|
|
238
|
+
masteryThreshold = 0.80,
|
|
239
|
+
): LearningPathResult {
|
|
240
|
+
if (nodes.length === 0) throw new Error('No knowledge nodes provided');
|
|
241
|
+
|
|
242
|
+
const masteryMap = new Map(masteryStates.map(m => [m.nodeId, m.masteryScore]));
|
|
243
|
+
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
244
|
+
|
|
245
|
+
// Validate prerequisites exist
|
|
246
|
+
for (const node of nodes) {
|
|
247
|
+
for (const prereq of node.prerequisites) {
|
|
248
|
+
if (!nodeMap.has(prereq)) throw new Error(`Prerequisite ${prereq} not found for node ${node.id}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Kahn's topological sort
|
|
253
|
+
const inDegree = new Map(nodes.map(n => [n.id, 0]));
|
|
254
|
+
for (const node of nodes) {
|
|
255
|
+
for (const prereq of node.prerequisites) {
|
|
256
|
+
inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const queue: string[] = nodes.filter(n => (inDegree.get(n.id) ?? 0) === 0).map(n => n.id);
|
|
261
|
+
const topoOrder: string[] = [];
|
|
262
|
+
const adj = new Map<string, string[]>();
|
|
263
|
+
for (const node of nodes) {
|
|
264
|
+
for (const prereq of node.prerequisites) {
|
|
265
|
+
if (!adj.has(prereq)) adj.set(prereq, []);
|
|
266
|
+
adj.get(prereq)!.push(node.id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
while (queue.length > 0) {
|
|
271
|
+
const cur = queue.shift()!;
|
|
272
|
+
topoOrder.push(cur);
|
|
273
|
+
for (const child of (adj.get(cur) ?? [])) {
|
|
274
|
+
const newDeg = (inDegree.get(child) ?? 0) - 1;
|
|
275
|
+
inDegree.set(child, newDeg);
|
|
276
|
+
if (newDeg === 0) queue.push(child);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const feasible = topoOrder.length === nodes.length;
|
|
281
|
+
|
|
282
|
+
// Filter to nodes that need study (mastery < threshold)
|
|
283
|
+
const path = topoOrder.filter(id => (masteryMap.get(id) ?? 0) < masteryThreshold);
|
|
284
|
+
const totalMinutes = path.reduce((acc, id) => acc + (nodeMap.get(id)?.estimatedMinutes ?? 30), 0);
|
|
285
|
+
|
|
286
|
+
return { path, totalMinutes, feasible };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Grade prediction ─────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export interface GradeEntry {
|
|
292
|
+
/** Assignment weight */
|
|
293
|
+
weight: number;
|
|
294
|
+
/** Score as fraction [0,1] */
|
|
295
|
+
score: number;
|
|
296
|
+
/** Recency index (higher = more recent) */
|
|
297
|
+
recencyIndex: number;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Predict final grade using recency-weighted moving average.
|
|
302
|
+
* decay: exponential decay factor per recency unit (default 0.85)
|
|
303
|
+
*/
|
|
304
|
+
export function gradePredictor(
|
|
305
|
+
grades: GradeEntry[],
|
|
306
|
+
decay = 0.85,
|
|
307
|
+
): { predictedGrade: number; letterGrade: string; gpa: number } {
|
|
308
|
+
if (grades.length === 0) throw new Error('No grade entries provided');
|
|
309
|
+
if (decay <= 0 || decay > 1) throw new Error('decay must be in (0, 1]');
|
|
310
|
+
|
|
311
|
+
const maxRecency = Math.max(...grades.map(g => g.recencyIndex));
|
|
312
|
+
let weightedSum = 0, totalWeight = 0;
|
|
313
|
+
for (const g of grades) {
|
|
314
|
+
const recencyWeight = Math.pow(decay, maxRecency - g.recencyIndex);
|
|
315
|
+
const w = g.weight * recencyWeight;
|
|
316
|
+
weightedSum += g.score * w;
|
|
317
|
+
totalWeight += w;
|
|
318
|
+
}
|
|
319
|
+
const predictedGrade = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
320
|
+
|
|
321
|
+
// Letter grade (US standard)
|
|
322
|
+
const letter =
|
|
323
|
+
predictedGrade >= 0.93 ? 'A' :
|
|
324
|
+
predictedGrade >= 0.90 ? 'A-' :
|
|
325
|
+
predictedGrade >= 0.87 ? 'B+' :
|
|
326
|
+
predictedGrade >= 0.83 ? 'B' :
|
|
327
|
+
predictedGrade >= 0.80 ? 'B-' :
|
|
328
|
+
predictedGrade >= 0.77 ? 'C+' :
|
|
329
|
+
predictedGrade >= 0.73 ? 'C' :
|
|
330
|
+
predictedGrade >= 0.70 ? 'C-' :
|
|
331
|
+
predictedGrade >= 0.67 ? 'D+' :
|
|
332
|
+
predictedGrade >= 0.60 ? 'D' : 'F';
|
|
333
|
+
|
|
334
|
+
const gpaMap: Record<string, number> = {
|
|
335
|
+
'A':4.0,'A-':3.7,'B+':3.3,'B':3.0,'B-':2.7,'C+':2.3,'C':2.0,'C-':1.7,'D+':1.3,'D':1.0,'F':0.0,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return { predictedGrade, letterGrade: letter, gpa: gpaMap[letter] };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Bloom's Taxonomy classifier ──────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
const BLOOM_VERBS: Record<string, number> = {
|
|
344
|
+
// L1 Remember
|
|
345
|
+
define:1, list:1, recall:1, recognize:1, identify:1, name:1, state:1, describe:1, memorize:1,
|
|
346
|
+
// L2 Understand
|
|
347
|
+
explain:2, summarize:2, paraphrase:2, classify:2, compare:2, interpret:2, discuss:2, review:2,
|
|
348
|
+
// L3 Apply
|
|
349
|
+
apply:3, use:3, demonstrate:3, solve:3, implement:3, compute:3, execute:3, calculate:3, practice:3,
|
|
350
|
+
// L4 Analyze
|
|
351
|
+
analyze:4, differentiate:4, examine:4, distinguish:4, break:4, deconstruct:4, investigate:4, inspect:4,
|
|
352
|
+
// L5 Evaluate
|
|
353
|
+
evaluate:5, judge:5, critique:5, justify:5, assess:5, argue:5, defend:5, prioritize:5, rank:5,
|
|
354
|
+
// L6 Create
|
|
355
|
+
create:6, design:6, construct:6, develop:6, formulate:6, compose:6, plan:6, produce:6, generate:6,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const BLOOM_LABELS = ['','Remember','Understand','Apply','Analyze','Evaluate','Create'] as const;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Classify learning objective text into Bloom's taxonomy level.
|
|
362
|
+
*/
|
|
363
|
+
export function bloomClassifier(objectiveText: string): BloomLevel {
|
|
364
|
+
const words = objectiveText.toLowerCase().replace(/[^a-z\s]/g, '').split(/\s+/);
|
|
365
|
+
const levelCounts = [0,0,0,0,0,0,0];
|
|
366
|
+
let matched = 0;
|
|
367
|
+
for (const word of words) {
|
|
368
|
+
const lvl = BLOOM_VERBS[word];
|
|
369
|
+
if (lvl) { levelCounts[lvl]++; matched++; }
|
|
370
|
+
}
|
|
371
|
+
if (matched === 0) {
|
|
372
|
+
return { level: 1, label: 'Remember', confidence: 0 };
|
|
373
|
+
}
|
|
374
|
+
let bestLevel = 1;
|
|
375
|
+
for (let l = 2; l <= 6; l++) if (levelCounts[l] > levelCounts[bestLevel]) bestLevel = l;
|
|
376
|
+
const level = bestLevel as 1|2|3|4|5|6;
|
|
377
|
+
return { level, label: BLOOM_LABELS[level], confidence: levelCounts[level] / words.length };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Quiz psychometrics ───────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Compute classical test theory statistics for a quiz.
|
|
384
|
+
* responses: matrix [student][item] = 0/1
|
|
385
|
+
* itemIds: label per item
|
|
386
|
+
*/
|
|
387
|
+
export function quizPsychometrics(
|
|
388
|
+
responses: number[][],
|
|
389
|
+
itemIds: string[],
|
|
390
|
+
): QuizPsychometrics {
|
|
391
|
+
const n = responses.length;
|
|
392
|
+
if (n < 2) throw new Error('At least 2 respondents required');
|
|
393
|
+
const k = itemIds.length;
|
|
394
|
+
if (responses.some(row => row.length !== k)) throw new Error('All response rows must match itemIds length');
|
|
395
|
+
|
|
396
|
+
// P-values
|
|
397
|
+
const pValues = itemIds.map((_, j) => {
|
|
398
|
+
const correct = responses.filter(r => r[j] === 1).length;
|
|
399
|
+
return correct / n;
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Total scores
|
|
403
|
+
const scores = responses.map(row => row.reduce((a, b) => a + b, 0));
|
|
404
|
+
const meanScore = scores.reduce((a, b) => a + b, 0) / n;
|
|
405
|
+
const variance = scores.reduce((acc, s) => acc + (s - meanScore) ** 2, 0) / (n - 1);
|
|
406
|
+
const stdDevScore = Math.sqrt(variance);
|
|
407
|
+
|
|
408
|
+
// Discrimination indices (upper/lower 27%)
|
|
409
|
+
const sortedIndices = [...scores.keys()].sort((a, b) => scores[b] - scores[a]);
|
|
410
|
+
const k27 = Math.max(1, Math.floor(0.27 * n));
|
|
411
|
+
const upper = sortedIndices.slice(0, k27);
|
|
412
|
+
const lower = sortedIndices.slice(n - k27);
|
|
413
|
+
|
|
414
|
+
const discriminationIndices = itemIds.map((_, j) => {
|
|
415
|
+
const pU = upper.filter(i => responses[i][j] === 1).length / k27;
|
|
416
|
+
const pL = lower.filter(i => responses[i][j] === 1).length / k27;
|
|
417
|
+
return pU - pL;
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Cronbach's alpha: α = (k/(k-1)) × (1 − Σvar_i / var_total)
|
|
421
|
+
const itemVariances = itemIds.map((_, j) => {
|
|
422
|
+
const p = pValues[j];
|
|
423
|
+
return p * (1 - p);
|
|
424
|
+
});
|
|
425
|
+
const sumItemVar = itemVariances.reduce((a, b) => a + b, 0);
|
|
426
|
+
const cronbachAlpha = k > 1 && variance > 0
|
|
427
|
+
? (k / (k - 1)) * (1 - sumItemVar / variance)
|
|
428
|
+
: 0;
|
|
429
|
+
|
|
430
|
+
const poorItems = itemIds.filter((_, j) => discriminationIndices[j] < 0.2);
|
|
431
|
+
|
|
432
|
+
return { itemCount: k, n, pValues, discriminationIndices, cronbachAlpha, meanScore, stdDevScore, poorItems };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ─── Receipt ──────────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
export interface EducationAnalysisResult {
|
|
438
|
+
irt?: IRTResult;
|
|
439
|
+
sm2?: SM2Result[];
|
|
440
|
+
learningPath?: LearningPathResult;
|
|
441
|
+
grade?: ReturnType<typeof gradePredictor>;
|
|
442
|
+
bloom?: BloomLevel;
|
|
443
|
+
psychometrics?: QuizPsychometrics;
|
|
444
|
+
converged: true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function buildEducationReceipt(
|
|
448
|
+
result: EducationAnalysisResult,
|
|
449
|
+
options?: EducationReceiptOptions,
|
|
450
|
+
): DomainSimulationReceipt {
|
|
451
|
+
const violations: Array<{ criterion: string; message: string }> = [];
|
|
452
|
+
|
|
453
|
+
if (result.psychometrics) {
|
|
454
|
+
const { cronbachAlpha, poorItems } = result.psychometrics;
|
|
455
|
+
if (cronbachAlpha < 0.70) {
|
|
456
|
+
violations.push({ criterion: 'reliability', message: `Cronbach's α ${cronbachAlpha.toFixed(3)} < 0.70 — quiz reliability is poor` });
|
|
457
|
+
}
|
|
458
|
+
if (poorItems.length > result.psychometrics.itemCount / 2) {
|
|
459
|
+
violations.push({ criterion: 'discrimination', message: `${poorItems.length}/${result.psychometrics.itemCount} items have poor discrimination (D < 0.2)` });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (result.learningPath && !result.learningPath.feasible) {
|
|
463
|
+
violations.push({ criterion: 'prerequisite_cycle', message: 'Prerequisite graph contains a cycle — learning path is infeasible' });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return buildDomainSimulationReceipt({
|
|
467
|
+
plugin: 'education-lms',
|
|
468
|
+
pluginVersion: '1.0.0',
|
|
469
|
+
runId: options?.runId ?? `edu-${Date.now().toString(36)}`,
|
|
470
|
+
solverConfig: { solverType: 'learning-analytics', scale: 'course' },
|
|
471
|
+
resultSummary: {
|
|
472
|
+
abilityTheta: result.irt?.abilityTheta ?? null,
|
|
473
|
+
cronbachAlpha: result.psychometrics?.cronbachAlpha ?? null,
|
|
474
|
+
predictedGrade: result.grade?.predictedGrade ?? null,
|
|
475
|
+
learningPathNodes: result.learningPath?.path.length ?? null,
|
|
476
|
+
bloomLevel: result.bloom?.level ?? null,
|
|
477
|
+
},
|
|
478
|
+
cael: { version: 'cael.v1', event: 'education_lms.learning_analysis', solverType: 'education-lms.irt-3pl' },
|
|
479
|
+
acceptance: { accepted: violations.length === 0, violations },
|
|
480
|
+
});
|
|
481
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './educationsolver';
|
|
2
|
+
export { createCourseHandler, type CourseConfig, type CourseState, type Difficulty } from './traits/CourseTrait';
|
|
3
|
+
export { createLessonHandler, type LessonConfig, type ContentType } from './traits/LessonTrait';
|
|
4
|
+
export { createGradeHandler, type GradeConfig, type GradingScale } from './traits/GradeTrait';
|
|
5
|
+
export { createEnrollmentHandler, type EnrollmentConfig, type EnrollmentStatus } from './traits/EnrollmentTrait';
|
|
6
|
+
export { createQuizHandler, type QuizConfig, type QuizQuestion, type QuestionType } from './traits/QuizTrait';
|
|
7
|
+
export * from './traits/types';
|
|
8
|
+
|
|
9
|
+
import { createCourseHandler } from './traits/CourseTrait';
|
|
10
|
+
import { createLessonHandler } from './traits/LessonTrait';
|
|
11
|
+
import { createGradeHandler } from './traits/GradeTrait';
|
|
12
|
+
import { createEnrollmentHandler } from './traits/EnrollmentTrait';
|
|
13
|
+
import { createQuizHandler } from './traits/QuizTrait';
|
|
14
|
+
|
|
15
|
+
export const pluginMeta = { name: '@holoscript/plugin-education-lms', version: '1.0.0', traits: ['course', 'lesson', 'grade', 'enrollment', 'quiz'] };
|
|
16
|
+
export const traitHandlers = [createCourseHandler(), createLessonHandler(), createGradeHandler(), createEnrollmentHandler(), createQuizHandler()];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** @course Trait — Course definition and management. @trait course */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type Difficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
5
|
+
export interface CourseConfig { title: string; description: string; instructor: string; durationHours: number; difficulty: Difficulty; prerequisites: string[]; modules: string[]; maxEnrollment: number; }
|
|
6
|
+
export interface CourseState { enrolledCount: number; completionRate: number; averageScore: number; isPublished: boolean; }
|
|
7
|
+
|
|
8
|
+
const defaultConfig: CourseConfig = { title: '', description: '', instructor: '', durationHours: 1, difficulty: 'beginner', prerequisites: [], modules: [], maxEnrollment: 100 };
|
|
9
|
+
|
|
10
|
+
export function createCourseHandler(): TraitHandler<CourseConfig> {
|
|
11
|
+
return {
|
|
12
|
+
name: 'course', defaultConfig,
|
|
13
|
+
onAttach(node: HSPlusNode, config: CourseConfig, ctx: TraitContext) { node.__courseState = { enrolledCount: 0, completionRate: 0, averageScore: 0, isPublished: false }; ctx.emit?.('course:created', { title: config.title }); },
|
|
14
|
+
onDetach(node: HSPlusNode, _c: CourseConfig, ctx: TraitContext) { delete node.__courseState; ctx.emit?.('course:removed'); },
|
|
15
|
+
onUpdate() {},
|
|
16
|
+
onEvent(node: HSPlusNode, _c: CourseConfig, ctx: TraitContext, event: TraitEvent) {
|
|
17
|
+
const s = node.__courseState as CourseState | undefined; if (!s) return;
|
|
18
|
+
if (event.type === 'course:publish') { s.isPublished = true; ctx.emit?.('course:published'); }
|
|
19
|
+
if (event.type === 'course:enroll') { s.enrolledCount++; ctx.emit?.('course:enrollment_updated', { count: s.enrolledCount }); }
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** @enrollment Trait — Student enrollment tracking. @trait enrollment */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type EnrollmentStatus = 'enrolled' | 'completed' | 'dropped' | 'waitlisted' | 'suspended';
|
|
5
|
+
export interface EnrollmentConfig { studentId: string; courseId: string; status: EnrollmentStatus; enrolledDate: string; progressPercent: number; }
|
|
6
|
+
|
|
7
|
+
const defaultConfig: EnrollmentConfig = { studentId: '', courseId: '', status: 'enrolled', enrolledDate: '', progressPercent: 0 };
|
|
8
|
+
|
|
9
|
+
export function createEnrollmentHandler(): TraitHandler<EnrollmentConfig> {
|
|
10
|
+
return {
|
|
11
|
+
name: 'enrollment', defaultConfig,
|
|
12
|
+
onAttach(node: HSPlusNode, config: EnrollmentConfig, ctx: TraitContext) { node.__enrollState = { ...config }; ctx.emit?.('enrollment:created', { studentId: config.studentId, courseId: config.courseId }); },
|
|
13
|
+
onDetach(node: HSPlusNode, _c: EnrollmentConfig, ctx: TraitContext) { delete node.__enrollState; ctx.emit?.('enrollment:removed'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(node: HSPlusNode, _c: EnrollmentConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
+
const s = node.__enrollState as EnrollmentConfig | undefined; if (!s) return;
|
|
17
|
+
if (event.type === 'enrollment:update_progress') { s.progressPercent = event.payload?.progress as number; ctx.emit?.('enrollment:progress', { progress: s.progressPercent }); }
|
|
18
|
+
if (event.type === 'enrollment:complete') { s.status = 'completed'; s.progressPercent = 100; ctx.emit?.('enrollment:completed'); }
|
|
19
|
+
if (event.type === 'enrollment:drop') { s.status = 'dropped'; ctx.emit?.('enrollment:dropped'); }
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** @grade Trait — Grade and scoring. @trait grade */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type GradingScale = 'letter' | 'percentage' | 'pass_fail' | 'points' | 'gpa';
|
|
5
|
+
export interface GradeConfig { score: number; maxScore: number; weight: number; gradingScale: GradingScale; rubric?: string; }
|
|
6
|
+
|
|
7
|
+
const defaultConfig: GradeConfig = { score: 0, maxScore: 100, weight: 1, gradingScale: 'percentage' };
|
|
8
|
+
|
|
9
|
+
export function createGradeHandler(): TraitHandler<GradeConfig> {
|
|
10
|
+
return {
|
|
11
|
+
name: 'grade', defaultConfig,
|
|
12
|
+
onAttach(node: HSPlusNode, config: GradeConfig, ctx: TraitContext) { node.__gradeState = { ...config, letterGrade: computeLetter(config.score, config.maxScore) }; ctx.emit?.('grade:assigned'); },
|
|
13
|
+
onDetach(node: HSPlusNode, _c: GradeConfig, ctx: TraitContext) { delete node.__gradeState; ctx.emit?.('grade:removed'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(node: HSPlusNode, _c: GradeConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
+
const s = node.__gradeState as Record<string, unknown> | undefined; if (!s) return;
|
|
17
|
+
if (event.type === 'grade:update') { s.score = event.payload?.score as number; s.letterGrade = computeLetter(s.score as number, s.maxScore as number); ctx.emit?.('grade:updated', { score: s.score }); }
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function computeLetter(score: number, max: number): string {
|
|
23
|
+
const pct = max > 0 ? (score / max) * 100 : 0;
|
|
24
|
+
if (pct >= 93) return 'A'; if (pct >= 90) return 'A-'; if (pct >= 87) return 'B+'; if (pct >= 83) return 'B';
|
|
25
|
+
if (pct >= 80) return 'B-'; if (pct >= 77) return 'C+'; if (pct >= 73) return 'C'; if (pct >= 70) return 'C-';
|
|
26
|
+
if (pct >= 60) return 'D'; return 'F';
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** @lesson Trait — Lesson content unit. @trait lesson */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type ContentType = 'video' | 'text' | 'interactive' | 'quiz' | 'assignment' | 'discussion';
|
|
5
|
+
export interface LessonConfig { title: string; contentType: ContentType; durationMinutes: number; order: number; completionCriteria: 'view' | 'score' | 'submit'; passingScore?: number; }
|
|
6
|
+
|
|
7
|
+
const defaultConfig: LessonConfig = { title: '', contentType: 'text', durationMinutes: 15, order: 0, completionCriteria: 'view' };
|
|
8
|
+
|
|
9
|
+
export function createLessonHandler(): TraitHandler<LessonConfig> {
|
|
10
|
+
return {
|
|
11
|
+
name: 'lesson', defaultConfig,
|
|
12
|
+
onAttach(node: HSPlusNode, config: LessonConfig, ctx: TraitContext) { node.__lessonState = { isComplete: false, timeSpentMs: 0, score: null }; ctx.emit?.('lesson:attached', { title: config.title }); },
|
|
13
|
+
onDetach(node: HSPlusNode, _c: LessonConfig, ctx: TraitContext) { delete node.__lessonState; ctx.emit?.('lesson:detached'); },
|
|
14
|
+
onUpdate(node: HSPlusNode, _c: LessonConfig, _ctx: TraitContext, delta: number) { const s = node.__lessonState as Record<string, unknown> | undefined; if (s) s.timeSpentMs = ((s.timeSpentMs as number) || 0) + delta; },
|
|
15
|
+
onEvent(node: HSPlusNode, config: LessonConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
+
const s = node.__lessonState as Record<string, unknown> | undefined; if (!s) return;
|
|
17
|
+
if (event.type === 'lesson:complete') { s.isComplete = true; ctx.emit?.('lesson:completed', { title: config.title, timeSpent: s.timeSpentMs }); }
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** @quiz Trait — Assessment with multiple question types. @trait quiz */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type QuestionType = 'multiple_choice' | 'true_false' | 'short_answer' | 'matching' | 'fill_blank';
|
|
5
|
+
export interface QuizQuestion { id: string; type: QuestionType; text: string; options?: string[]; correctAnswer: string | string[]; points: number; }
|
|
6
|
+
export interface QuizConfig { questions: QuizQuestion[]; timeLimitMinutes: number | null; attemptsAllowed: number; passingScore: number; shuffle: boolean; showCorrectAnswers: boolean; }
|
|
7
|
+
export interface QuizState { currentQuestion: number; answers: Record<string, string>; score: number; attemptsUsed: number; isComplete: boolean; startedAt: number | null; }
|
|
8
|
+
|
|
9
|
+
const defaultConfig: QuizConfig = { questions: [], timeLimitMinutes: null, attemptsAllowed: 3, passingScore: 70, shuffle: false, showCorrectAnswers: true };
|
|
10
|
+
|
|
11
|
+
export function createQuizHandler(): TraitHandler<QuizConfig> {
|
|
12
|
+
return {
|
|
13
|
+
name: 'quiz', defaultConfig,
|
|
14
|
+
onAttach(node: HSPlusNode, config: QuizConfig, ctx: TraitContext) { node.__quizState = { currentQuestion: 0, answers: {}, score: 0, attemptsUsed: 0, isComplete: false, startedAt: null }; ctx.emit?.('quiz:ready', { questionCount: config.questions.length }); },
|
|
15
|
+
onDetach(node: HSPlusNode, _c: QuizConfig, ctx: TraitContext) { delete node.__quizState; ctx.emit?.('quiz:detached'); },
|
|
16
|
+
onUpdate() {},
|
|
17
|
+
onEvent(node: HSPlusNode, config: QuizConfig, ctx: TraitContext, event: TraitEvent) {
|
|
18
|
+
const s = node.__quizState as QuizState | undefined; if (!s) return;
|
|
19
|
+
if (event.type === 'quiz:start') { s.startedAt = Date.now(); s.attemptsUsed++; ctx.emit?.('quiz:started', { attempt: s.attemptsUsed }); }
|
|
20
|
+
if (event.type === 'quiz:answer') { const qId = event.payload?.questionId as string; const ans = event.payload?.answer as string; s.answers[qId] = ans; }
|
|
21
|
+
if (event.type === 'quiz:submit') {
|
|
22
|
+
let correct = 0;
|
|
23
|
+
for (const q of config.questions) { if (s.answers[q.id] === q.correctAnswer) correct += q.points; }
|
|
24
|
+
const total = config.questions.reduce((sum, q) => sum + q.points, 0);
|
|
25
|
+
s.score = total > 0 ? (correct / total) * 100 : 0; s.isComplete = true;
|
|
26
|
+
ctx.emit?.('quiz:submitted', { score: s.score, passed: s.score >= config.passingScore });
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
|
|
2
|
+
export interface TraitContext { emit?: (event: string, payload?: unknown) => void; getState?: () => Record<string, unknown>; setState?: (updates: Record<string, unknown>) => void; [key: string]: unknown; }
|
|
3
|
+
export interface TraitEvent { type: string; source?: string; payload?: Record<string, unknown>; [key: string]: unknown; }
|
|
4
|
+
export interface TraitHandler<TConfig = unknown> { name: string; defaultConfig: TConfig; onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void; onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void; }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true }, "include": ["src"] }
|