@holoscript/plugin-education-lms 2.0.1 → 2.0.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/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@holoscript/plugin-education-lms",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "HoloScript domain plugin for education-lms",
5
5
  "main": "src/index.ts",
6
6
  "peerDependencies": {
7
- "@holoscript/core": "8.0.6"
7
+ "@holoscript/core": ">=8.0.0"
8
8
  },
9
9
  "license": "MIT",
10
10
  "scripts": {
11
11
  "test": "vitest run --passWithNoTests",
12
12
  "test:coverage": "vitest run --coverage --passWithNoTests"
13
13
  }
14
- }
14
+ }
@@ -28,8 +28,8 @@ describe('irtAbilityEstimate', () => {
28
28
  it('all correct → high positive ability estimate', () => {
29
29
  const items = [
30
30
  { id: 'i1', b: -1 },
31
- { id: 'i2', b: 0 },
32
- { id: 'i3', b: 1 },
31
+ { id: 'i2', b: 0 },
32
+ { id: 'i3', b: 1 },
33
33
  ];
34
34
  const responses = [
35
35
  { itemId: 'i1', correct: true },
@@ -43,8 +43,8 @@ describe('irtAbilityEstimate', () => {
43
43
  it('all incorrect → low negative ability estimate', () => {
44
44
  const items = [
45
45
  { id: 'i1', b: -1 },
46
- { id: 'i2', b: 0 },
47
- { id: 'i3', b: 1 },
46
+ { id: 'i2', b: 0 },
47
+ { id: 'i3', b: 1 },
48
48
  ];
49
49
  const responses = [
50
50
  { itemId: 'i1', correct: false },
@@ -57,8 +57,14 @@ describe('irtAbilityEstimate', () => {
57
57
 
58
58
  it('ability near item difficulty for 50/50 pattern', () => {
59
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 }];
60
+ const items = [
61
+ { id: 'easy', b: -2 },
62
+ { id: 'hard', b: 2 },
63
+ ];
64
+ const responses = [
65
+ { itemId: 'easy', correct: true },
66
+ { itemId: 'hard', correct: false },
67
+ ];
62
68
  const r = irtAbilityEstimate(items, responses);
63
69
  // Ability should be in middle range
64
70
  expect(r.abilityTheta).toBeGreaterThan(-2);
@@ -66,23 +72,39 @@ describe('irtAbilityEstimate', () => {
66
72
  });
67
73
 
68
74
  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 }];
75
+ const items = [
76
+ { id: 'i1', b: 0 },
77
+ { id: 'i2', b: 1 },
78
+ ];
79
+ const responses = [
80
+ { itemId: 'i1', correct: true },
81
+ { itemId: 'i2', correct: false },
82
+ ];
71
83
  const r = irtAbilityEstimate(items, responses);
72
84
  const manualSum = r.itemInformation.reduce((s, x) => s + x.information, 0);
73
85
  expect(r.testInformation).toBeCloseTo(manualSum, 6);
74
86
  });
75
87
 
76
88
  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 }];
89
+ const items = [
90
+ { id: 'i1', b: 0 },
91
+ { id: 'i2', b: 0.5 },
92
+ ];
93
+ const responses = [
94
+ { itemId: 'i1', correct: true },
95
+ { itemId: 'i2', correct: true },
96
+ ];
79
97
  const r = irtAbilityEstimate(items, responses);
80
98
  expect(r.standardError).toBeCloseTo(1 / Math.sqrt(r.testInformation), 4);
81
99
  });
82
100
 
83
101
  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 }));
102
+ const items = [
103
+ { id: 'i1', b: 0 },
104
+ { id: 'i2', b: 1 },
105
+ { id: 'i3', b: -1 },
106
+ ];
107
+ const responses = items.map((it) => ({ itemId: it.id, correct: true }));
86
108
  const r = irtAbilityEstimate(items, responses);
87
109
  expect(r.itemFit).toHaveLength(3);
88
110
  });
@@ -90,11 +112,23 @@ describe('irtAbilityEstimate', () => {
90
112
  it('higher discrimination a → more information near item difficulty', () => {
91
113
  // Use mixed responses: easy item correct, hard item wrong → theta stays near 0
92
114
  // 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);
115
+ const itemsLow = [
116
+ { id: 'easy', a: 0.5, b: -2 },
117
+ { id: 'hard', a: 0.5, b: 2 },
118
+ ];
119
+ const itemsHigh = [
120
+ { id: 'easy', a: 2.0, b: -2 },
121
+ { id: 'hard', a: 2.0, b: 2 },
122
+ ];
123
+ const responsesLow = [
124
+ { itemId: 'easy', correct: true },
125
+ { itemId: 'hard', correct: false },
126
+ ];
127
+ const responsesHigh = [
128
+ { itemId: 'easy', correct: true },
129
+ { itemId: 'hard', correct: false },
130
+ ];
131
+ const rLow = irtAbilityEstimate(itemsLow, responsesLow);
98
132
  const rHigh = irtAbilityEstimate(itemsHigh, responsesHigh);
99
133
  // Higher discrimination → higher test information at estimated theta
100
134
  expect(rHigh.testInformation).toBeGreaterThan(rLow.testInformation);
@@ -176,10 +210,10 @@ describe('sm2Review', () => {
176
210
 
177
211
  describe('learningPathOptimizer', () => {
178
212
  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 },
213
+ { id: 'A', label: 'Algebra', prerequisites: [], estimatedMinutes: 60 },
214
+ { id: 'B', label: 'Calculus', prerequisites: ['A'], estimatedMinutes: 90 },
215
+ { id: 'C', label: 'Statistics', prerequisites: ['A'], estimatedMinutes: 45 },
216
+ { id: 'D', label: 'ML Basics', prerequisites: ['B', 'C'], estimatedMinutes: 120 },
183
217
  ];
184
218
 
185
219
  it('path is in topological order (prerequisites before dependents)', () => {
@@ -201,7 +235,7 @@ describe('learningPathOptimizer', () => {
201
235
  const mastery: { nodeId: string; masteryScore: number }[] = [];
202
236
  const r = learningPathOptimizer(nodes, mastery);
203
237
  const expected = r.path.reduce((acc, id) => {
204
- const n = nodes.find(x => x.id === id);
238
+ const n = nodes.find((x) => x.id === id);
205
239
  return acc + (n?.estimatedMinutes ?? 30);
206
240
  }, 0);
207
241
  expect(r.totalMinutes).toBe(expected);
@@ -213,9 +247,7 @@ describe('learningPathOptimizer', () => {
213
247
  });
214
248
 
215
249
  it('throws if prerequisite node not found', () => {
216
- const badNodes = [
217
- { id: 'X', label: 'X', prerequisites: ['MISSING'], estimatedMinutes: 30 },
218
- ];
250
+ const badNodes = [{ id: 'X', label: 'X', prerequisites: ['MISSING'], estimatedMinutes: 30 }];
219
251
  expect(() => learningPathOptimizer(badNodes, [])).toThrow();
220
252
  });
221
253
 
@@ -239,12 +271,12 @@ describe('gradePredictor', () => {
239
271
 
240
272
  it('recent grades weighted more heavily with decay < 1', () => {
241
273
  const grades = [
242
- { weight: 1, score: 0.50, recencyIndex: 1 }, // old, low
274
+ { weight: 1, score: 0.5, recencyIndex: 1 }, // old, low
243
275
  { weight: 1, score: 0.95, recencyIndex: 5 }, // recent, high
244
276
  ];
245
277
  const r = gradePredictor(grades, 0.5);
246
278
  // Should be closer to 0.95 than to plain average
247
- expect(r.predictedGrade).toBeGreaterThan(0.70);
279
+ expect(r.predictedGrade).toBeGreaterThan(0.7);
248
280
  });
249
281
 
250
282
  it('predictedGrade 0.93+ → letter A, gpa=4.0', () => {
@@ -255,7 +287,7 @@ describe('gradePredictor', () => {
255
287
  });
256
288
 
257
289
  it('predictedGrade below 0.60 → letter F, gpa=0.0', () => {
258
- const grades = [{ weight: 1, score: 0.50, recencyIndex: 1 }];
290
+ const grades = [{ weight: 1, score: 0.5, recencyIndex: 1 }];
259
291
  const r = gradePredictor(grades);
260
292
  expect(r.letterGrade).toBe('F');
261
293
  expect(r.gpa).toBe(0.0);
@@ -406,8 +438,8 @@ describe('buildEducationReceipt', () => {
406
438
  itemCount: 3,
407
439
  n: 10,
408
440
  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
441
+ discriminationIndices: [0.05, 0.05, 0.05], // all poor → > 50% poor items
442
+ cronbachAlpha: 0.3, // below 0.70 threshold
411
443
  meanScore: 1.5,
412
444
  stdDevScore: 0.5,
413
445
  poorItems: ['i1', 'i2', 'i3'],
@@ -144,28 +144,28 @@ function itemInfo(theta: number, a: number, b: number, c: number): number {
144
144
  * Estimate student ability θ via Newton-Raphson MLE.
145
145
  * Iterates until convergence or max 50 steps.
146
146
  */
147
- export function irtAbilityEstimate(
148
- items: IRTItem[],
149
- responses: IRTResponse[],
150
- ): IRTResult {
147
+ export function irtAbilityEstimate(items: IRTItem[], responses: IRTResponse[]): IRTResult {
151
148
  if (items.length === 0) throw new Error('No items provided');
152
149
  if (responses.length !== items.length) throw new Error('responses must match items length');
153
150
 
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]));
151
+ const itemMap = new Map(items.map((it) => [it.id, it]));
152
+ const responseMap = new Map(responses.map((r) => [r.itemId, r.correct ? 1 : 0]));
156
153
 
157
154
  // Newton-Raphson MLE
158
155
  let theta = 0.0;
159
156
  for (let iter = 0; iter < 50; iter++) {
160
- let firstDeriv = 0, secondDeriv = 0;
157
+ let firstDeriv = 0,
158
+ secondDeriv = 0;
161
159
  for (const item of items) {
162
- const a = item.a ?? 1.0, b = item.b, c = item.c ?? 0;
160
+ const a = item.a ?? 1.0,
161
+ b = item.b,
162
+ c = item.c ?? 0;
163
163
  const P = irt3PL(theta, a, b, c);
164
164
  const u = responseMap.get(item.id) ?? 0;
165
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);
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
169
  }
170
170
  if (Math.abs(secondDeriv) < 1e-12) break;
171
171
  const step = firstDeriv / secondDeriv;
@@ -175,16 +175,20 @@ export function irtAbilityEstimate(
175
175
  }
176
176
 
177
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;
178
+ const infoArr = items.map((item) => {
179
+ const a = item.a ?? 1.0,
180
+ b = item.b,
181
+ c = item.c ?? 0;
180
182
  return { itemId: item.id, information: itemInfo(theta, a, b, c) };
181
183
  });
182
184
  const testInformation = infoArr.reduce((s, x) => s + x.information, 0);
183
185
  const standardError = testInformation > 0 ? 1 / Math.sqrt(testInformation) : Infinity;
184
186
 
185
187
  // 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 itemFit = items.map((item) => {
189
+ const a = item.a ?? 1.0,
190
+ b = item.b,
191
+ c = item.c ?? 0;
188
192
  const P = irt3PL(theta, a, b, c);
189
193
  const u = responseMap.get(item.id) ?? 0;
190
194
  const residual = (u - P) / Math.sqrt(P * (1 - P) + 1e-15);
@@ -205,7 +209,7 @@ export function irtAbilityEstimate(
205
209
  export function sm2Review(card: SM2Card, quality: 0 | 1 | 2 | 3 | 4 | 5): SM2Result {
206
210
  if (quality < 0 || quality > 5) throw new Error('quality must be 0-5');
207
211
 
208
- let ef = card.ef ?? 2.5;
212
+ let ef = card.ef ?? 2.5;
209
213
  let rep = card.repetitions ?? 0;
210
214
  let interval = card.interval ?? 1;
211
215
 
@@ -235,29 +239,30 @@ export function sm2Review(card: SM2Card, quality: 0 | 1 | 2 | 3 | 4 | 5): SM2Res
235
239
  export function learningPathOptimizer(
236
240
  nodes: KnowledgeNode[],
237
241
  masteryStates: MasteryState[],
238
- masteryThreshold = 0.80,
242
+ masteryThreshold = 0.8
239
243
  ): LearningPathResult {
240
244
  if (nodes.length === 0) throw new Error('No knowledge nodes provided');
241
245
 
242
- const masteryMap = new Map(masteryStates.map(m => [m.nodeId, m.masteryScore]));
243
- const nodeMap = new Map(nodes.map(n => [n.id, n]));
246
+ const masteryMap = new Map(masteryStates.map((m) => [m.nodeId, m.masteryScore]));
247
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
244
248
 
245
249
  // Validate prerequisites exist
246
250
  for (const node of nodes) {
247
251
  for (const prereq of node.prerequisites) {
248
- if (!nodeMap.has(prereq)) throw new Error(`Prerequisite ${prereq} not found for node ${node.id}`);
252
+ if (!nodeMap.has(prereq))
253
+ throw new Error(`Prerequisite ${prereq} not found for node ${node.id}`);
249
254
  }
250
255
  }
251
256
 
252
257
  // Kahn's topological sort
253
- const inDegree = new Map(nodes.map(n => [n.id, 0]));
258
+ const inDegree = new Map(nodes.map((n) => [n.id, 0]));
254
259
  for (const node of nodes) {
255
260
  for (const prereq of node.prerequisites) {
256
261
  inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1);
257
262
  }
258
263
  }
259
264
 
260
- const queue: string[] = nodes.filter(n => (inDegree.get(n.id) ?? 0) === 0).map(n => n.id);
265
+ const queue: string[] = nodes.filter((n) => (inDegree.get(n.id) ?? 0) === 0).map((n) => n.id);
261
266
  const topoOrder: string[] = [];
262
267
  const adj = new Map<string, string[]>();
263
268
  for (const node of nodes) {
@@ -270,7 +275,7 @@ export function learningPathOptimizer(
270
275
  while (queue.length > 0) {
271
276
  const cur = queue.shift()!;
272
277
  topoOrder.push(cur);
273
- for (const child of (adj.get(cur) ?? [])) {
278
+ for (const child of adj.get(cur) ?? []) {
274
279
  const newDeg = (inDegree.get(child) ?? 0) - 1;
275
280
  inDegree.set(child, newDeg);
276
281
  if (newDeg === 0) queue.push(child);
@@ -280,7 +285,7 @@ export function learningPathOptimizer(
280
285
  const feasible = topoOrder.length === nodes.length;
281
286
 
282
287
  // Filter to nodes that need study (mastery < threshold)
283
- const path = topoOrder.filter(id => (masteryMap.get(id) ?? 0) < masteryThreshold);
288
+ const path = topoOrder.filter((id) => (masteryMap.get(id) ?? 0) < masteryThreshold);
284
289
  const totalMinutes = path.reduce((acc, id) => acc + (nodeMap.get(id)?.estimatedMinutes ?? 30), 0);
285
290
 
286
291
  return { path, totalMinutes, feasible };
@@ -303,13 +308,14 @@ export interface GradeEntry {
303
308
  */
304
309
  export function gradePredictor(
305
310
  grades: GradeEntry[],
306
- decay = 0.85,
311
+ decay = 0.85
307
312
  ): { predictedGrade: number; letterGrade: string; gpa: number } {
308
313
  if (grades.length === 0) throw new Error('No grade entries provided');
309
314
  if (decay <= 0 || decay > 1) throw new Error('decay must be in (0, 1]');
310
315
 
311
- const maxRecency = Math.max(...grades.map(g => g.recencyIndex));
312
- let weightedSum = 0, totalWeight = 0;
316
+ const maxRecency = Math.max(...grades.map((g) => g.recencyIndex));
317
+ let weightedSum = 0,
318
+ totalWeight = 0;
313
319
  for (const g of grades) {
314
320
  const recencyWeight = Math.pow(decay, maxRecency - g.recencyIndex);
315
321
  const w = g.weight * recencyWeight;
@@ -320,19 +326,40 @@ export function gradePredictor(
320
326
 
321
327
  // Letter grade (US standard)
322
328
  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';
329
+ predictedGrade >= 0.93
330
+ ? 'A'
331
+ : predictedGrade >= 0.9
332
+ ? 'A-'
333
+ : predictedGrade >= 0.87
334
+ ? 'B+'
335
+ : predictedGrade >= 0.83
336
+ ? 'B'
337
+ : predictedGrade >= 0.8
338
+ ? 'B-'
339
+ : predictedGrade >= 0.77
340
+ ? 'C+'
341
+ : predictedGrade >= 0.73
342
+ ? 'C'
343
+ : predictedGrade >= 0.7
344
+ ? 'C-'
345
+ : predictedGrade >= 0.67
346
+ ? 'D+'
347
+ : predictedGrade >= 0.6
348
+ ? 'D'
349
+ : 'F';
333
350
 
334
351
  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,
352
+ A: 4.0,
353
+ 'A-': 3.7,
354
+ 'B+': 3.3,
355
+ B: 3.0,
356
+ 'B-': 2.7,
357
+ 'C+': 2.3,
358
+ C: 2.0,
359
+ 'C-': 1.7,
360
+ 'D+': 1.3,
361
+ D: 1.0,
362
+ F: 0.0,
336
363
  };
337
364
 
338
365
  return { predictedGrade, letterGrade: letter, gpa: gpaMap[letter] };
@@ -342,38 +369,98 @@ export function gradePredictor(
342
369
 
343
370
  const BLOOM_VERBS: Record<string, number> = {
344
371
  // L1 Remember
345
- define:1, list:1, recall:1, recognize:1, identify:1, name:1, state:1, describe:1, memorize:1,
372
+ define: 1,
373
+ list: 1,
374
+ recall: 1,
375
+ recognize: 1,
376
+ identify: 1,
377
+ name: 1,
378
+ state: 1,
379
+ describe: 1,
380
+ memorize: 1,
346
381
  // L2 Understand
347
- explain:2, summarize:2, paraphrase:2, classify:2, compare:2, interpret:2, discuss:2, review:2,
382
+ explain: 2,
383
+ summarize: 2,
384
+ paraphrase: 2,
385
+ classify: 2,
386
+ compare: 2,
387
+ interpret: 2,
388
+ discuss: 2,
389
+ review: 2,
348
390
  // L3 Apply
349
- apply:3, use:3, demonstrate:3, solve:3, implement:3, compute:3, execute:3, calculate:3, practice:3,
391
+ apply: 3,
392
+ use: 3,
393
+ demonstrate: 3,
394
+ solve: 3,
395
+ implement: 3,
396
+ compute: 3,
397
+ execute: 3,
398
+ calculate: 3,
399
+ practice: 3,
350
400
  // L4 Analyze
351
- analyze:4, differentiate:4, examine:4, distinguish:4, break:4, deconstruct:4, investigate:4, inspect:4,
401
+ analyze: 4,
402
+ differentiate: 4,
403
+ examine: 4,
404
+ distinguish: 4,
405
+ break: 4,
406
+ deconstruct: 4,
407
+ investigate: 4,
408
+ inspect: 4,
352
409
  // L5 Evaluate
353
- evaluate:5, judge:5, critique:5, justify:5, assess:5, argue:5, defend:5, prioritize:5, rank:5,
410
+ evaluate: 5,
411
+ judge: 5,
412
+ critique: 5,
413
+ justify: 5,
414
+ assess: 5,
415
+ argue: 5,
416
+ defend: 5,
417
+ prioritize: 5,
418
+ rank: 5,
354
419
  // L6 Create
355
- create:6, design:6, construct:6, develop:6, formulate:6, compose:6, plan:6, produce:6, generate:6,
420
+ create: 6,
421
+ design: 6,
422
+ construct: 6,
423
+ develop: 6,
424
+ formulate: 6,
425
+ compose: 6,
426
+ plan: 6,
427
+ produce: 6,
428
+ generate: 6,
356
429
  };
357
430
 
358
- const BLOOM_LABELS = ['','Remember','Understand','Apply','Analyze','Evaluate','Create'] as const;
431
+ const BLOOM_LABELS = [
432
+ '',
433
+ 'Remember',
434
+ 'Understand',
435
+ 'Apply',
436
+ 'Analyze',
437
+ 'Evaluate',
438
+ 'Create',
439
+ ] as const;
359
440
 
360
441
  /**
361
442
  * Classify learning objective text into Bloom's taxonomy level.
362
443
  */
363
444
  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];
445
+ const words = objectiveText
446
+ .toLowerCase()
447
+ .replace(/[^a-z\s]/g, '')
448
+ .split(/\s+/);
449
+ const levelCounts = [0, 0, 0, 0, 0, 0, 0];
366
450
  let matched = 0;
367
451
  for (const word of words) {
368
452
  const lvl = BLOOM_VERBS[word];
369
- if (lvl) { levelCounts[lvl]++; matched++; }
453
+ if (lvl) {
454
+ levelCounts[lvl]++;
455
+ matched++;
456
+ }
370
457
  }
371
458
  if (matched === 0) {
372
459
  return { level: 1, label: 'Remember', confidence: 0 };
373
460
  }
374
461
  let bestLevel = 1;
375
462
  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;
463
+ const level = bestLevel as 1 | 2 | 3 | 4 | 5 | 6;
377
464
  return { level, label: BLOOM_LABELS[level], confidence: levelCounts[level] / words.length };
378
465
  }
379
466
 
@@ -384,25 +471,23 @@ export function bloomClassifier(objectiveText: string): BloomLevel {
384
471
  * responses: matrix [student][item] = 0/1
385
472
  * itemIds: label per item
386
473
  */
387
- export function quizPsychometrics(
388
- responses: number[][],
389
- itemIds: string[],
390
- ): QuizPsychometrics {
474
+ export function quizPsychometrics(responses: number[][], itemIds: string[]): QuizPsychometrics {
391
475
  const n = responses.length;
392
476
  if (n < 2) throw new Error('At least 2 respondents required');
393
477
  const k = itemIds.length;
394
- if (responses.some(row => row.length !== k)) throw new Error('All response rows must match itemIds length');
478
+ if (responses.some((row) => row.length !== k))
479
+ throw new Error('All response rows must match itemIds length');
395
480
 
396
481
  // P-values
397
482
  const pValues = itemIds.map((_, j) => {
398
- const correct = responses.filter(r => r[j] === 1).length;
483
+ const correct = responses.filter((r) => r[j] === 1).length;
399
484
  return correct / n;
400
485
  });
401
486
 
402
487
  // Total scores
403
- const scores = responses.map(row => row.reduce((a, b) => a + b, 0));
488
+ const scores = responses.map((row) => row.reduce((a, b) => a + b, 0));
404
489
  const meanScore = scores.reduce((a, b) => a + b, 0) / n;
405
- const variance = scores.reduce((acc, s) => acc + (s - meanScore) ** 2, 0) / (n - 1);
490
+ const variance = scores.reduce((acc, s) => acc + (s - meanScore) ** 2, 0) / (n - 1);
406
491
  const stdDevScore = Math.sqrt(variance);
407
492
 
408
493
  // Discrimination indices (upper/lower 27%)
@@ -412,8 +497,8 @@ export function quizPsychometrics(
412
497
  const lower = sortedIndices.slice(n - k27);
413
498
 
414
499
  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;
500
+ const pU = upper.filter((i) => responses[i][j] === 1).length / k27;
501
+ const pL = lower.filter((i) => responses[i][j] === 1).length / k27;
417
502
  return pU - pL;
418
503
  });
419
504
 
@@ -423,13 +508,20 @@ export function quizPsychometrics(
423
508
  return p * (1 - p);
424
509
  });
425
510
  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;
511
+ const cronbachAlpha = k > 1 && variance > 0 ? (k / (k - 1)) * (1 - sumItemVar / variance) : 0;
429
512
 
430
513
  const poorItems = itemIds.filter((_, j) => discriminationIndices[j] < 0.2);
431
514
 
432
- return { itemCount: k, n, pValues, discriminationIndices, cronbachAlpha, meanScore, stdDevScore, poorItems };
515
+ return {
516
+ itemCount: k,
517
+ n,
518
+ pValues,
519
+ discriminationIndices,
520
+ cronbachAlpha,
521
+ meanScore,
522
+ stdDevScore,
523
+ poorItems,
524
+ };
433
525
  }
434
526
 
435
527
  // ─── Receipt ──────────────────────────────────────────────────────────────────
@@ -446,21 +538,30 @@ export interface EducationAnalysisResult {
446
538
 
447
539
  export function buildEducationReceipt(
448
540
  result: EducationAnalysisResult,
449
- options?: EducationReceiptOptions,
541
+ options?: EducationReceiptOptions
450
542
  ): DomainSimulationReceipt {
451
543
  const violations: Array<{ criterion: string; message: string }> = [];
452
544
 
453
545
  if (result.psychometrics) {
454
546
  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` });
547
+ if (cronbachAlpha < 0.7) {
548
+ violations.push({
549
+ criterion: 'reliability',
550
+ message: `Cronbach's α ${cronbachAlpha.toFixed(3)} < 0.70 — quiz reliability is poor`,
551
+ });
457
552
  }
458
553
  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)` });
554
+ violations.push({
555
+ criterion: 'discrimination',
556
+ message: `${poorItems.length}/${result.psychometrics.itemCount} items have poor discrimination (D < 0.2)`,
557
+ });
460
558
  }
461
559
  }
462
560
  if (result.learningPath && !result.learningPath.feasible) {
463
- violations.push({ criterion: 'prerequisite_cycle', message: 'Prerequisite graph contains a cycle — learning path is infeasible' });
561
+ violations.push({
562
+ criterion: 'prerequisite_cycle',
563
+ message: 'Prerequisite graph contains a cycle — learning path is infeasible',
564
+ });
464
565
  }
465
566
 
466
567
  return buildDomainSimulationReceipt({
@@ -475,7 +576,11 @@ export function buildEducationReceipt(
475
576
  learningPathNodes: result.learningPath?.path.length ?? null,
476
577
  bloomLevel: result.bloom?.level ?? null,
477
578
  },
478
- cael: { version: 'cael.v1', event: 'education_lms.learning_analysis', solverType: 'education-lms.irt-3pl' },
579
+ cael: {
580
+ version: 'cael.v1',
581
+ event: 'education_lms.learning_analysis',
582
+ solverType: 'education-lms.irt-3pl',
583
+ },
479
584
  acceptance: { accepted: violations.length === 0, violations },
480
585
  });
481
586
  }
package/src/index.ts CHANGED
@@ -1,9 +1,23 @@
1
1
  export * from './educationsolver';
2
- export { createCourseHandler, type CourseConfig, type CourseState, type Difficulty } from './traits/CourseTrait';
2
+ export {
3
+ createCourseHandler,
4
+ type CourseConfig,
5
+ type CourseState,
6
+ type Difficulty,
7
+ } from './traits/CourseTrait';
3
8
  export { createLessonHandler, type LessonConfig, type ContentType } from './traits/LessonTrait';
4
9
  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';
10
+ export {
11
+ createEnrollmentHandler,
12
+ type EnrollmentConfig,
13
+ type EnrollmentStatus,
14
+ } from './traits/EnrollmentTrait';
15
+ export {
16
+ createQuizHandler,
17
+ type QuizConfig,
18
+ type QuizQuestion,
19
+ type QuestionType,
20
+ } from './traits/QuizTrait';
7
21
  export * from './traits/types';
8
22
 
9
23
  import { createCourseHandler } from './traits/CourseTrait';
@@ -12,5 +26,15 @@ import { createGradeHandler } from './traits/GradeTrait';
12
26
  import { createEnrollmentHandler } from './traits/EnrollmentTrait';
13
27
  import { createQuizHandler } from './traits/QuizTrait';
14
28
 
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()];
29
+ export const pluginMeta = {
30
+ name: '@holoscript/plugin-education-lms',
31
+ version: '1.0.0',
32
+ traits: ['course', 'lesson', 'grade', 'enrollment', 'quiz'],
33
+ };
34
+ export const traitHandlers = [
35
+ createCourseHandler(),
36
+ createLessonHandler(),
37
+ createGradeHandler(),
38
+ createEnrollmentHandler(),
39
+ createQuizHandler(),
40
+ ];
@@ -2,21 +2,63 @@
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
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; }
5
+ export interface CourseConfig {
6
+ title: string;
7
+ description: string;
8
+ instructor: string;
9
+ durationHours: number;
10
+ difficulty: Difficulty;
11
+ prerequisites: string[];
12
+ modules: string[];
13
+ maxEnrollment: number;
14
+ }
15
+ export interface CourseState {
16
+ enrolledCount: number;
17
+ completionRate: number;
18
+ averageScore: number;
19
+ isPublished: boolean;
20
+ }
7
21
 
8
- const defaultConfig: CourseConfig = { title: '', description: '', instructor: '', durationHours: 1, difficulty: 'beginner', prerequisites: [], modules: [], maxEnrollment: 100 };
22
+ const defaultConfig: CourseConfig = {
23
+ title: '',
24
+ description: '',
25
+ instructor: '',
26
+ durationHours: 1,
27
+ difficulty: 'beginner',
28
+ prerequisites: [],
29
+ modules: [],
30
+ maxEnrollment: 100,
31
+ };
9
32
 
10
33
  export function createCourseHandler(): TraitHandler<CourseConfig> {
11
34
  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'); },
35
+ name: 'course',
36
+ defaultConfig,
37
+ onAttach(node: HSPlusNode, config: CourseConfig, ctx: TraitContext) {
38
+ node.__courseState = {
39
+ enrolledCount: 0,
40
+ completionRate: 0,
41
+ averageScore: 0,
42
+ isPublished: false,
43
+ };
44
+ ctx.emit?.('course:created', { title: config.title });
45
+ },
46
+ onDetach(node: HSPlusNode, _c: CourseConfig, ctx: TraitContext) {
47
+ delete node.__courseState;
48
+ ctx.emit?.('course:removed');
49
+ },
15
50
  onUpdate() {},
16
51
  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 }); }
52
+ const s = node.__courseState as CourseState | undefined;
53
+ if (!s) return;
54
+ if (event.type === 'course:publish') {
55
+ s.isPublished = true;
56
+ ctx.emit?.('course:published');
57
+ }
58
+ if (event.type === 'course:enroll') {
59
+ s.enrolledCount++;
60
+ ctx.emit?.('course:enrollment_updated', { count: s.enrolledCount });
61
+ }
20
62
  },
21
63
  };
22
64
  }
@@ -2,21 +2,51 @@
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
4
  export type EnrollmentStatus = 'enrolled' | 'completed' | 'dropped' | 'waitlisted' | 'suspended';
5
- export interface EnrollmentConfig { studentId: string; courseId: string; status: EnrollmentStatus; enrolledDate: string; progressPercent: number; }
5
+ export interface EnrollmentConfig {
6
+ studentId: string;
7
+ courseId: string;
8
+ status: EnrollmentStatus;
9
+ enrolledDate: string;
10
+ progressPercent: number;
11
+ }
6
12
 
7
- const defaultConfig: EnrollmentConfig = { studentId: '', courseId: '', status: 'enrolled', enrolledDate: '', progressPercent: 0 };
13
+ const defaultConfig: EnrollmentConfig = {
14
+ studentId: '',
15
+ courseId: '',
16
+ status: 'enrolled',
17
+ enrolledDate: '',
18
+ progressPercent: 0,
19
+ };
8
20
 
9
21
  export function createEnrollmentHandler(): TraitHandler<EnrollmentConfig> {
10
22
  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'); },
23
+ name: 'enrollment',
24
+ defaultConfig,
25
+ onAttach(node: HSPlusNode, config: EnrollmentConfig, ctx: TraitContext) {
26
+ node.__enrollState = { ...config };
27
+ ctx.emit?.('enrollment:created', { studentId: config.studentId, courseId: config.courseId });
28
+ },
29
+ onDetach(node: HSPlusNode, _c: EnrollmentConfig, ctx: TraitContext) {
30
+ delete node.__enrollState;
31
+ ctx.emit?.('enrollment:removed');
32
+ },
14
33
  onUpdate() {},
15
34
  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'); }
35
+ const s = node.__enrollState as EnrollmentConfig | undefined;
36
+ if (!s) return;
37
+ if (event.type === 'enrollment:update_progress') {
38
+ s.progressPercent = event.payload?.progress as number;
39
+ ctx.emit?.('enrollment:progress', { progress: s.progressPercent });
40
+ }
41
+ if (event.type === 'enrollment:complete') {
42
+ s.status = 'completed';
43
+ s.progressPercent = 100;
44
+ ctx.emit?.('enrollment:completed');
45
+ }
46
+ if (event.type === 'enrollment:drop') {
47
+ s.status = 'dropped';
48
+ ctx.emit?.('enrollment:dropped');
49
+ }
20
50
  },
21
51
  };
22
52
  }
@@ -2,26 +2,56 @@
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
4
  export type GradingScale = 'letter' | 'percentage' | 'pass_fail' | 'points' | 'gpa';
5
- export interface GradeConfig { score: number; maxScore: number; weight: number; gradingScale: GradingScale; rubric?: string; }
5
+ export interface GradeConfig {
6
+ score: number;
7
+ maxScore: number;
8
+ weight: number;
9
+ gradingScale: GradingScale;
10
+ rubric?: string;
11
+ }
6
12
 
7
- const defaultConfig: GradeConfig = { score: 0, maxScore: 100, weight: 1, gradingScale: 'percentage' };
13
+ const defaultConfig: GradeConfig = {
14
+ score: 0,
15
+ maxScore: 100,
16
+ weight: 1,
17
+ gradingScale: 'percentage',
18
+ };
8
19
 
9
20
  export function createGradeHandler(): TraitHandler<GradeConfig> {
10
21
  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'); },
22
+ name: 'grade',
23
+ defaultConfig,
24
+ onAttach(node: HSPlusNode, config: GradeConfig, ctx: TraitContext) {
25
+ node.__gradeState = { ...config, letterGrade: computeLetter(config.score, config.maxScore) };
26
+ ctx.emit?.('grade:assigned');
27
+ },
28
+ onDetach(node: HSPlusNode, _c: GradeConfig, ctx: TraitContext) {
29
+ delete node.__gradeState;
30
+ ctx.emit?.('grade:removed');
31
+ },
14
32
  onUpdate() {},
15
33
  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 }); }
34
+ const s = node.__gradeState as Record<string, unknown> | undefined;
35
+ if (!s) return;
36
+ if (event.type === 'grade:update') {
37
+ s.score = event.payload?.score as number;
38
+ s.letterGrade = computeLetter(s.score as number, s.maxScore as number);
39
+ ctx.emit?.('grade:updated', { score: s.score });
40
+ }
18
41
  },
19
42
  };
20
43
  }
21
44
 
22
45
  function computeLetter(score: number, max: number): string {
23
46
  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';
47
+ if (pct >= 93) return 'A';
48
+ if (pct >= 90) return 'A-';
49
+ if (pct >= 87) return 'B+';
50
+ if (pct >= 83) return 'B';
51
+ if (pct >= 80) return 'B-';
52
+ if (pct >= 77) return 'C+';
53
+ if (pct >= 73) return 'C';
54
+ if (pct >= 70) return 'C-';
55
+ if (pct >= 60) return 'D';
56
+ return 'F';
27
57
  }
@@ -2,19 +2,46 @@
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
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; }
5
+ export interface LessonConfig {
6
+ title: string;
7
+ contentType: ContentType;
8
+ durationMinutes: number;
9
+ order: number;
10
+ completionCriteria: 'view' | 'score' | 'submit';
11
+ passingScore?: number;
12
+ }
6
13
 
7
- const defaultConfig: LessonConfig = { title: '', contentType: 'text', durationMinutes: 15, order: 0, completionCriteria: 'view' };
14
+ const defaultConfig: LessonConfig = {
15
+ title: '',
16
+ contentType: 'text',
17
+ durationMinutes: 15,
18
+ order: 0,
19
+ completionCriteria: 'view',
20
+ };
8
21
 
9
22
  export function createLessonHandler(): TraitHandler<LessonConfig> {
10
23
  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; },
24
+ name: 'lesson',
25
+ defaultConfig,
26
+ onAttach(node: HSPlusNode, config: LessonConfig, ctx: TraitContext) {
27
+ node.__lessonState = { isComplete: false, timeSpentMs: 0, score: null };
28
+ ctx.emit?.('lesson:attached', { title: config.title });
29
+ },
30
+ onDetach(node: HSPlusNode, _c: LessonConfig, ctx: TraitContext) {
31
+ delete node.__lessonState;
32
+ ctx.emit?.('lesson:detached');
33
+ },
34
+ onUpdate(node: HSPlusNode, _c: LessonConfig, _ctx: TraitContext, delta: number) {
35
+ const s = node.__lessonState as Record<string, unknown> | undefined;
36
+ if (s) s.timeSpentMs = ((s.timeSpentMs as number) || 0) + delta;
37
+ },
15
38
  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 }); }
39
+ const s = node.__lessonState as Record<string, unknown> | undefined;
40
+ if (!s) return;
41
+ if (event.type === 'lesson:complete') {
42
+ s.isComplete = true;
43
+ ctx.emit?.('lesson:completed', { title: config.title, timeSpent: s.timeSpentMs });
44
+ }
18
45
  },
19
46
  };
20
47
  }
@@ -1,28 +1,87 @@
1
1
  /** @quiz Trait — Assessment with multiple question types. @trait quiz */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
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; }
4
+ export type QuestionType =
5
+ | 'multiple_choice'
6
+ | 'true_false'
7
+ | 'short_answer'
8
+ | 'matching'
9
+ | 'fill_blank';
10
+ export interface QuizQuestion {
11
+ id: string;
12
+ type: QuestionType;
13
+ text: string;
14
+ options?: string[];
15
+ correctAnswer: string | string[];
16
+ points: number;
17
+ }
18
+ export interface QuizConfig {
19
+ questions: QuizQuestion[];
20
+ timeLimitMinutes: number | null;
21
+ attemptsAllowed: number;
22
+ passingScore: number;
23
+ shuffle: boolean;
24
+ showCorrectAnswers: boolean;
25
+ }
26
+ export interface QuizState {
27
+ currentQuestion: number;
28
+ answers: Record<string, string>;
29
+ score: number;
30
+ attemptsUsed: number;
31
+ isComplete: boolean;
32
+ startedAt: number | null;
33
+ }
8
34
 
9
- const defaultConfig: QuizConfig = { questions: [], timeLimitMinutes: null, attemptsAllowed: 3, passingScore: 70, shuffle: false, showCorrectAnswers: true };
35
+ const defaultConfig: QuizConfig = {
36
+ questions: [],
37
+ timeLimitMinutes: null,
38
+ attemptsAllowed: 3,
39
+ passingScore: 70,
40
+ shuffle: false,
41
+ showCorrectAnswers: true,
42
+ };
10
43
 
11
44
  export function createQuizHandler(): TraitHandler<QuizConfig> {
12
45
  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'); },
46
+ name: 'quiz',
47
+ defaultConfig,
48
+ onAttach(node: HSPlusNode, config: QuizConfig, ctx: TraitContext) {
49
+ node.__quizState = {
50
+ currentQuestion: 0,
51
+ answers: {},
52
+ score: 0,
53
+ attemptsUsed: 0,
54
+ isComplete: false,
55
+ startedAt: null,
56
+ };
57
+ ctx.emit?.('quiz:ready', { questionCount: config.questions.length });
58
+ },
59
+ onDetach(node: HSPlusNode, _c: QuizConfig, ctx: TraitContext) {
60
+ delete node.__quizState;
61
+ ctx.emit?.('quiz:detached');
62
+ },
16
63
  onUpdate() {},
17
64
  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; }
65
+ const s = node.__quizState as QuizState | undefined;
66
+ if (!s) return;
67
+ if (event.type === 'quiz:start') {
68
+ s.startedAt = Date.now();
69
+ s.attemptsUsed++;
70
+ ctx.emit?.('quiz:started', { attempt: s.attemptsUsed });
71
+ }
72
+ if (event.type === 'quiz:answer') {
73
+ const qId = event.payload?.questionId as string;
74
+ const ans = event.payload?.answer as string;
75
+ s.answers[qId] = ans;
76
+ }
21
77
  if (event.type === 'quiz:submit') {
22
78
  let correct = 0;
23
- for (const q of config.questions) { if (s.answers[q.id] === q.correctAnswer) correct += q.points; }
79
+ for (const q of config.questions) {
80
+ if (s.answers[q.id] === q.correctAnswer) correct += q.points;
81
+ }
24
82
  const total = config.questions.reduce((sum, q) => sum + q.points, 0);
25
- s.score = total > 0 ? (correct / total) * 100 : 0; s.isComplete = true;
83
+ s.score = total > 0 ? (correct / total) * 100 : 0;
84
+ s.isComplete = true;
26
85
  ctx.emit?.('quiz:submitted', { score: s.score, passed: s.score >= config.passingScore });
27
86
  }
28
87
  },
@@ -1,4 +1,25 @@
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; }
1
+ export interface HSPlusNode {
2
+ id?: string;
3
+ properties?: Record<string, unknown>;
4
+ [key: string]: unknown;
5
+ }
6
+ export interface TraitContext {
7
+ emit?: (event: string, payload?: unknown) => void;
8
+ getState?: () => Record<string, unknown>;
9
+ setState?: (updates: Record<string, unknown>) => void;
10
+ [key: string]: unknown;
11
+ }
12
+ export interface TraitEvent {
13
+ type: string;
14
+ source?: string;
15
+ payload?: Record<string, unknown>;
16
+ [key: string]: unknown;
17
+ }
18
+ export interface TraitHandler<TConfig = unknown> {
19
+ name: string;
20
+ defaultConfig: TConfig;
21
+ onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void;
22
+ onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void;
23
+ onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void;
24
+ onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void;
25
+ }
package/tsconfig.json CHANGED
@@ -1 +1,10 @@
1
- { "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true }, "include": ["src"] }
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "declaration": true,
7
+ "declarationMap": true
8
+ },
9
+ "include": ["src"]
10
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
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.