explico 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +108 -0
  2. package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
  3. package/dist/analytics/AnalyticsEmitter.js +5 -0
  4. package/dist/compiler/cli.d.ts +189 -0
  5. package/dist/compiler/cli.js +552 -0
  6. package/dist/compiler/collections.d.ts +70 -0
  7. package/dist/compiler/collections.js +383 -0
  8. package/dist/compiler/course-config.d.ts +31 -0
  9. package/dist/compiler/course-config.js +0 -0
  10. package/dist/compiler/lesson-derivation.d.ts +89 -0
  11. package/dist/compiler/lesson-derivation.js +91 -0
  12. package/dist/compiler/manifest.d.ts +178 -0
  13. package/dist/compiler/manifest.js +345 -0
  14. package/dist/compiler/resolution-report.d.ts +45 -0
  15. package/dist/compiler/resolution-report.js +97 -0
  16. package/dist/curriculum/validation.d.ts +76 -0
  17. package/dist/curriculum/validation.js +320 -0
  18. package/dist/progress/ProgressRepository.d.ts +53 -0
  19. package/dist/progress/ProgressRepository.js +12 -0
  20. package/dist/reference/base-library.mjs +27 -0
  21. package/dist/reference/citation-format.mjs +133 -0
  22. package/dist/reference/consistency.d.ts +64 -0
  23. package/dist/reference/consistency.js +234 -0
  24. package/dist/reference/curriculum-alignment.d.ts +7 -0
  25. package/dist/reference/curriculum-alignment.js +166 -0
  26. package/dist/reference/equations-validate.d.ts +50 -0
  27. package/dist/reference/equations-validate.js +90 -0
  28. package/dist/reference/equations.mjs +144 -0
  29. package/dist/reference/gate-math.d.ts +41 -0
  30. package/dist/reference/gate-math.js +151 -0
  31. package/dist/reference/katex-options.mjs +91 -0
  32. package/dist/reference/lab-math-scope.d.ts +21 -0
  33. package/dist/reference/lab-math-scope.js +28 -0
  34. package/dist/reference/label.d.ts +11 -0
  35. package/dist/reference/label.js +38 -0
  36. package/dist/reference/lint-ignore.d.ts +32 -0
  37. package/dist/reference/lint-ignore.js +39 -0
  38. package/dist/reference/math-glyphs.mjs +830 -0
  39. package/dist/reference/prose.d.ts +15 -0
  40. package/dist/reference/prose.js +12 -0
  41. package/dist/reference/references.d.ts +17 -0
  42. package/dist/reference/references.js +42 -0
  43. package/dist/reference/registry.d.ts +6 -0
  44. package/dist/reference/registry.js +548 -0
  45. package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
  46. package/dist/reference/remark-citation.mjs +0 -0
  47. package/dist/reference/remark-notation.mjs +753 -0
  48. package/dist/reference/render-lab-math.d.ts +21 -0
  49. package/dist/reference/render-lab-math.js +58 -0
  50. package/dist/reference/types.d.ts +151 -0
  51. package/dist/reference/types.js +0 -0
  52. package/dist/reference/units-vocab.d.ts +26 -0
  53. package/dist/reference/units-vocab.js +119 -0
  54. package/dist/session/PreferenceStore.d.ts +28 -0
  55. package/dist/session/PreferenceStore.js +30 -0
  56. package/dist/session/user-context.d.ts +7 -0
  57. package/dist/session/user-context.js +10 -0
  58. package/dist/session/user.d.ts +21 -0
  59. package/dist/session/user.js +8 -0
  60. package/package.json +53 -0
  61. package/src/analytics/AnalyticsEmitter.ts +23 -0
  62. package/src/compiler/cli.ts +866 -0
  63. package/src/compiler/collections.ts +554 -0
  64. package/src/compiler/course-config.ts +32 -0
  65. package/src/compiler/lesson-derivation.ts +186 -0
  66. package/src/compiler/manifest.ts +601 -0
  67. package/src/compiler/resolution-report.ts +150 -0
  68. package/src/components/CurriculumMap.astro +208 -0
  69. package/src/components/HoverPanel.astro +67 -0
  70. package/src/components/assessments/AssessmentRunner.tsx +320 -0
  71. package/src/components/assessments/AssessmentSet.astro +54 -0
  72. package/src/components/citation/CitationLayer.astro +173 -0
  73. package/src/components/examples/CompactExample.astro +34 -0
  74. package/src/components/examples/CompactExamples.astro +253 -0
  75. package/src/components/hover-panel.ts +273 -0
  76. package/src/components/notation/NotationGlossary.astro +412 -0
  77. package/src/components/notation/NotationLayer.astro +594 -0
  78. package/src/components/notation/NotationMath.astro +24 -0
  79. package/src/components/notation/SafeMathNode.astro +23 -0
  80. package/src/components/notation/render-notation-math.ts +223 -0
  81. package/src/components/starlight/EquationEnhancer.astro +72 -0
  82. package/src/components/starlight/LayoutHeader.astro +102 -0
  83. package/src/components/starlight/LayoutPageSidebar.astro +33 -0
  84. package/src/components/starlight/LayoutSidebar.astro +29 -0
  85. package/src/components/starlight/LessonFooter.astro +58 -0
  86. package/src/content-config.ts +259 -0
  87. package/src/curriculum/validation.ts +481 -0
  88. package/src/progress/ProgressRepository.ts +64 -0
  89. package/src/reference/base-library.mjs +27 -0
  90. package/src/reference/citation-format.mjs +133 -0
  91. package/src/reference/consistency.ts +375 -0
  92. package/src/reference/curriculum-alignment.ts +228 -0
  93. package/src/reference/equations-validate.ts +167 -0
  94. package/src/reference/equations.mjs +144 -0
  95. package/src/reference/gate-math.ts +250 -0
  96. package/src/reference/katex-options.mjs +91 -0
  97. package/src/reference/lab-math-scope.ts +54 -0
  98. package/src/reference/label.ts +47 -0
  99. package/src/reference/lint-ignore.ts +75 -0
  100. package/src/reference/math-glyphs.mjs +830 -0
  101. package/src/reference/prose.ts +21 -0
  102. package/src/reference/references.ts +82 -0
  103. package/src/reference/registry.ts +723 -0
  104. package/src/reference/rehype-fail-katex-errors.mjs +55 -0
  105. package/src/reference/remark-citation.mjs +0 -0
  106. package/src/reference/remark-notation.mjs +753 -0
  107. package/src/reference/render-lab-math.ts +99 -0
  108. package/src/reference/types.ts +182 -0
  109. package/src/reference/units-vocab.ts +143 -0
  110. package/src/session/PreferenceStore.ts +54 -0
  111. package/src/session/user-context.ts +18 -0
  112. package/src/session/user.ts +24 -0
  113. package/src/styles/global.css +763 -0
@@ -0,0 +1,481 @@
1
+ export interface CompetencyDefinition {
2
+ readonly id: string;
3
+ readonly title: string;
4
+ readonly prerequisites: readonly string[];
5
+ readonly evidence: {
6
+ readonly minimumIndependentItems: number;
7
+ readonly requiresTransfer: boolean;
8
+ readonly requiresUnassistedPass: boolean;
9
+ };
10
+ }
11
+
12
+ export interface LessonDefinition {
13
+ readonly id: string;
14
+ readonly status: 'draft' | 'in-review' | 'reviewed';
15
+ readonly requires: readonly string[];
16
+ readonly teaches: readonly string[];
17
+ readonly assessments: readonly string[];
18
+ readonly sources: readonly string[];
19
+ readonly assumptions: readonly string[];
20
+ /** Raw Markdown body, used to keep inline `[@…]` citations in sync with `sources`. */
21
+ readonly body?: string;
22
+ }
23
+
24
+ interface AssessmentItemBase {
25
+ readonly id: string;
26
+ readonly competencyId: string;
27
+ readonly evidenceKind: 'direct' | 'transfer';
28
+ }
29
+
30
+ export interface NumericAssessmentItem extends AssessmentItemBase {
31
+ readonly type: 'numeric';
32
+ readonly answer: { readonly value: number; readonly tolerance: number };
33
+ }
34
+
35
+ export interface ChoiceAssessmentItem extends AssessmentItemBase {
36
+ readonly type: 'single-choice';
37
+ readonly options: readonly { readonly id: string; readonly label: string }[];
38
+ readonly correctOptionId: string;
39
+ }
40
+
41
+ export interface AssessmentDefinition {
42
+ readonly id: string;
43
+ readonly items: readonly (NumericAssessmentItem | ChoiceAssessmentItem)[];
44
+ }
45
+
46
+ export interface SourceDefinition {
47
+ readonly id: string;
48
+ readonly editorialStatus: 'draft' | 'in-review' | 'reviewed';
49
+ }
50
+
51
+ export interface TrackDefinition {
52
+ readonly id: string;
53
+ readonly lessons: readonly string[];
54
+ }
55
+
56
+ export interface CurriculumCatalog {
57
+ readonly competencies: readonly CompetencyDefinition[];
58
+ readonly lessons: readonly LessonDefinition[];
59
+ readonly assessments: readonly AssessmentDefinition[];
60
+ readonly sources: readonly SourceDefinition[];
61
+ readonly tracks: readonly TrackDefinition[];
62
+ }
63
+
64
+ export interface CurriculumIssue {
65
+ readonly kind:
66
+ | 'invalid-id'
67
+ | 'duplicate-id'
68
+ | 'unknown-reference'
69
+ | 'duplicate-reference'
70
+ | 'self-prerequisite'
71
+ | 'cycle'
72
+ | 'lesson-order'
73
+ | 'track-order'
74
+ | 'assessment-coverage'
75
+ | 'invalid-assessment'
76
+ | 'review-state'
77
+ | 'teaches-support';
78
+ readonly message: string;
79
+ /**
80
+ * `error` (default) blocks the build; `warning` is reported but non-blocking.
81
+ * New checks enter as warnings and are promoted once they run clean on real
82
+ * content.
83
+ */
84
+ readonly severity?: 'error' | 'warning';
85
+ }
86
+
87
+ const VALID_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
88
+
89
+ function buildIndex<T extends { readonly id: string }>(
90
+ kind: string,
91
+ entries: readonly T[],
92
+ issues: CurriculumIssue[],
93
+ ): Map<string, T> {
94
+ const index = new Map<string, T>();
95
+ for (const entry of entries) {
96
+ if (!VALID_ID.test(entry.id)) {
97
+ issues.push({
98
+ kind: 'invalid-id',
99
+ message: `${kind} has invalid id ${JSON.stringify(entry.id)}`,
100
+ });
101
+ continue;
102
+ }
103
+ if (index.has(entry.id)) {
104
+ issues.push({
105
+ kind: 'duplicate-id',
106
+ message: `duplicate ${kind} id ${entry.id}`,
107
+ });
108
+ continue;
109
+ }
110
+ index.set(entry.id, entry);
111
+ }
112
+ return index;
113
+ }
114
+
115
+ /**
116
+ * Source ids referenced by `[@id]` (optionally `[@id; locator]`) in a lesson
117
+ * body, with fenced and inline code removed first so an example of the syntax
118
+ * does not count as a real citation.
119
+ */
120
+ function citedSourceIds(body: string | undefined): Set<string> {
121
+ if (!body) return new Set();
122
+ const withoutCode = body
123
+ .replace(/```[\s\S]*?```/g, '')
124
+ .replace(/`[^`\n]*`/g, '');
125
+ const ids = new Set<string>();
126
+ for (const match of withoutCode.matchAll(
127
+ /\[@([a-z0-9][a-z0-9.-]*)(?:;[^\]]*)?\]/g,
128
+ )) {
129
+ ids.add((match[1] ?? '').trim());
130
+ }
131
+ return ids;
132
+ }
133
+
134
+ function findDuplicates(values: readonly string[]): readonly string[] {
135
+ const seen = new Set<string>();
136
+ const duplicates = new Set<string>();
137
+ for (const value of values) {
138
+ if (seen.has(value)) duplicates.add(value);
139
+ seen.add(value);
140
+ }
141
+ return [...duplicates];
142
+ }
143
+
144
+ function validateCompetencyGraph(
145
+ competencies: ReadonlyMap<string, CompetencyDefinition>,
146
+ issues: CurriculumIssue[],
147
+ ): void {
148
+ for (const competency of competencies.values()) {
149
+ for (const prerequisiteId of findDuplicates(competency.prerequisites)) {
150
+ issues.push({
151
+ kind: 'duplicate-reference',
152
+ message: `${competency.id} repeats prerequisite ${prerequisiteId}`,
153
+ });
154
+ }
155
+ for (const prerequisiteId of competency.prerequisites) {
156
+ if (prerequisiteId === competency.id) {
157
+ issues.push({
158
+ kind: 'self-prerequisite',
159
+ message: `${competency.id} requires itself`,
160
+ });
161
+ } else if (!competencies.has(prerequisiteId)) {
162
+ issues.push({
163
+ kind: 'unknown-reference',
164
+ message: `${competency.id} requires unknown competency ${prerequisiteId}`,
165
+ });
166
+ }
167
+ }
168
+ }
169
+
170
+ const state = new Map<string, 'visiting' | 'visited'>();
171
+ const stack: string[] = [];
172
+ const reportedCycles = new Set<string>();
173
+
174
+ const visit = (id: string): void => {
175
+ state.set(id, 'visiting');
176
+ stack.push(id);
177
+
178
+ for (const prerequisiteId of competencies.get(id)?.prerequisites ?? []) {
179
+ if (prerequisiteId === id || !competencies.has(prerequisiteId)) continue;
180
+ const prerequisiteState = state.get(prerequisiteId);
181
+ if (prerequisiteState === undefined) {
182
+ visit(prerequisiteId);
183
+ } else if (prerequisiteState === 'visiting') {
184
+ const start = stack.lastIndexOf(prerequisiteId);
185
+ const path = [...stack.slice(start), prerequisiteId];
186
+ const key = [...new Set(path)].sort().join('|');
187
+ if (!reportedCycles.has(key)) {
188
+ reportedCycles.add(key);
189
+ issues.push({
190
+ kind: 'cycle',
191
+ message: `prerequisite cycle: ${path.join(' -> ')}`,
192
+ });
193
+ }
194
+ }
195
+ }
196
+
197
+ stack.pop();
198
+ state.set(id, 'visited');
199
+ };
200
+
201
+ for (const id of competencies.keys()) {
202
+ if (state.get(id) === undefined) visit(id);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Provisional signal that a lesson body instantiates its content with numbers:
208
+ * a worked-example component or section, or an equation that evaluates to a
209
+ * numeral (`… = 6.`). Deliberately broad — this gates a warning, and the
210
+ * heuristic is expected to tighten (or be replaced by `equation-instantiated`)
211
+ * as content is reviewed.
212
+ */
213
+ const WORKED_INSTANCE =
214
+ /<CompactExample\b|^#{1,6}\s+.*\b(?:worked|example)\b|=\s*-?\d|\d\.\d{3,}/im;
215
+
216
+ /**
217
+ * `teaches`-support lint (Phase C1, warning tier): every competency a lesson
218
+ * `teaches` needs both a worked instance in the body and at least one
219
+ * assessment item measuring it. Enters as a warning; promoted once real
220
+ * content runs clean. Owns nothing the stricter Tier-1 checks
221
+ * (`assessment-measures-taught`, `equation-instantiated`) will later cover.
222
+ */
223
+ function validateTeachesSupport(
224
+ lessons: ReadonlyMap<string, LessonDefinition>,
225
+ evidenceByCompetency: ReadonlyMap<string, { count: number }>,
226
+ issues: CurriculumIssue[],
227
+ ): void {
228
+ for (const lesson of lessons.values()) {
229
+ if (lesson.teaches.length === 0) continue;
230
+
231
+ if (!WORKED_INSTANCE.test(lesson.body ?? '')) {
232
+ issues.push({
233
+ kind: 'teaches-support',
234
+ severity: 'warning',
235
+ message: `${lesson.id} teaches ${lesson.teaches.join(', ')} but the body has no worked instance (CompactExample or a worked-example section)`,
236
+ });
237
+ }
238
+
239
+ const unmeasured = lesson.teaches.filter(
240
+ (competencyId) =>
241
+ (evidenceByCompetency.get(competencyId)?.count ?? 0) === 0,
242
+ );
243
+ if (unmeasured.length > 0) {
244
+ issues.push({
245
+ kind: 'teaches-support',
246
+ severity: 'warning',
247
+ message: `${lesson.id} teaches ${unmeasured.join(', ')} with no assessment item measuring it`,
248
+ });
249
+ }
250
+ }
251
+ }
252
+
253
+ export function validateCurriculum(
254
+ catalog: CurriculumCatalog,
255
+ ): readonly CurriculumIssue[] {
256
+ const issues: CurriculumIssue[] = [];
257
+ const competencies = buildIndex('competency', catalog.competencies, issues);
258
+ const lessons = buildIndex('lesson', catalog.lessons, issues);
259
+ const assessments = buildIndex('assessment', catalog.assessments, issues);
260
+ const sources = buildIndex('source', catalog.sources, issues);
261
+ const tracks = buildIndex('track', catalog.tracks, issues);
262
+
263
+ validateCompetencyGraph(competencies, issues);
264
+
265
+ for (const lesson of lessons.values()) {
266
+ for (const field of ['requires', 'teaches'] as const) {
267
+ for (const duplicate of findDuplicates(lesson[field])) {
268
+ issues.push({
269
+ kind: 'duplicate-reference',
270
+ message: `${lesson.id} repeats ${field} competency ${duplicate}`,
271
+ });
272
+ }
273
+ for (const competencyId of lesson[field]) {
274
+ if (!competencies.has(competencyId)) {
275
+ issues.push({
276
+ kind: 'unknown-reference',
277
+ message: `${lesson.id} ${field} unknown competency ${competencyId}`,
278
+ });
279
+ }
280
+ }
281
+ }
282
+
283
+ for (const competencyId of lesson.requires) {
284
+ if (lesson.teaches.includes(competencyId)) {
285
+ issues.push({
286
+ kind: 'lesson-order',
287
+ message: `${lesson.id} both requires and teaches ${competencyId}`,
288
+ });
289
+ }
290
+ }
291
+
292
+ const available = new Set(lesson.requires);
293
+ for (const competencyId of lesson.teaches) {
294
+ const competency = competencies.get(competencyId);
295
+ if (competency) {
296
+ for (const prerequisiteId of competency.prerequisites) {
297
+ if (!available.has(prerequisiteId)) {
298
+ issues.push({
299
+ kind: 'lesson-order',
300
+ message: `${lesson.id} teaches ${competencyId} before prerequisite ${prerequisiteId} is available`,
301
+ });
302
+ }
303
+ }
304
+ }
305
+ available.add(competencyId);
306
+ }
307
+
308
+ for (const assessmentId of lesson.assessments) {
309
+ if (!assessments.has(assessmentId)) {
310
+ issues.push({
311
+ kind: 'unknown-reference',
312
+ message: `${lesson.id} references unknown assessment ${assessmentId}`,
313
+ });
314
+ }
315
+ }
316
+ const cited = citedSourceIds(lesson.body);
317
+ for (const sourceId of lesson.sources) {
318
+ const source = sources.get(sourceId);
319
+ if (!source) {
320
+ issues.push({
321
+ kind: 'unknown-reference',
322
+ message: `${lesson.id} references unknown source ${sourceId}`,
323
+ });
324
+ } else if (
325
+ lesson.status === 'reviewed' &&
326
+ source.editorialStatus !== 'reviewed'
327
+ ) {
328
+ issues.push({
329
+ kind: 'review-state',
330
+ message: `${lesson.id} is reviewed but source ${sourceId} is ${source.editorialStatus}`,
331
+ });
332
+ }
333
+ if (lesson.body !== undefined && !cited.has(sourceId)) {
334
+ issues.push({
335
+ kind: 'unknown-reference',
336
+ message: `${lesson.id} declares source ${sourceId} but never cites it with [@${sourceId}]`,
337
+ });
338
+ }
339
+ }
340
+ for (const sourceId of cited) {
341
+ if (!lesson.sources.includes(sourceId)) {
342
+ issues.push({
343
+ kind: 'unknown-reference',
344
+ message: `${lesson.id} cites ${sourceId} in prose but does not list it in frontmatter sources`,
345
+ });
346
+ }
347
+ }
348
+ if (lesson.teaches.length > 0 && lesson.assumptions.length === 0) {
349
+ issues.push({
350
+ kind: 'review-state',
351
+ message: `${lesson.id} teaches quantitative content but declares no assumptions`,
352
+ });
353
+ }
354
+ }
355
+
356
+ const itemIds = new Set<string>();
357
+ const evidenceByCompetency = new Map<
358
+ string,
359
+ { count: number; hasTransfer: boolean }
360
+ >();
361
+
362
+ for (const assessment of assessments.values()) {
363
+ for (const item of assessment.items) {
364
+ if (itemIds.has(item.id)) {
365
+ issues.push({
366
+ kind: 'duplicate-id',
367
+ message: `duplicate assessment item id ${item.id}`,
368
+ });
369
+ }
370
+ itemIds.add(item.id);
371
+
372
+ if (!competencies.has(item.competencyId)) {
373
+ issues.push({
374
+ kind: 'unknown-reference',
375
+ message: `${assessment.id}/${item.id} measures unknown competency ${item.competencyId}`,
376
+ });
377
+ }
378
+
379
+ if (item.type === 'single-choice') {
380
+ if (
381
+ !item.options.some((option) => option.id === item.correctOptionId)
382
+ ) {
383
+ issues.push({
384
+ kind: 'invalid-assessment',
385
+ message: `${assessment.id}/${item.id} has unknown correct option ${item.correctOptionId}`,
386
+ });
387
+ }
388
+ } else if (
389
+ !Number.isFinite(item.answer.value) ||
390
+ !Number.isFinite(item.answer.tolerance) ||
391
+ item.answer.tolerance <= 0
392
+ ) {
393
+ issues.push({
394
+ kind: 'invalid-assessment',
395
+ message: `${assessment.id}/${item.id} has an invalid numeric answer or tolerance`,
396
+ });
397
+ }
398
+
399
+ const evidence = evidenceByCompetency.get(item.competencyId) ?? {
400
+ count: 0,
401
+ hasTransfer: false,
402
+ };
403
+ evidence.count += 1;
404
+ evidence.hasTransfer ||= item.evidenceKind === 'transfer';
405
+ evidenceByCompetency.set(item.competencyId, evidence);
406
+ }
407
+ }
408
+
409
+ const taughtCompetencies = new Set(
410
+ [...lessons.values()].flatMap((lesson) => [...lesson.teaches]),
411
+ );
412
+ for (const competencyId of taughtCompetencies) {
413
+ const competency = competencies.get(competencyId);
414
+ if (!competency) continue;
415
+ const evidence = evidenceByCompetency.get(competencyId) ?? {
416
+ count: 0,
417
+ hasTransfer: false,
418
+ };
419
+ if (evidence.count < competency.evidence.minimumIndependentItems) {
420
+ issues.push({
421
+ kind: 'assessment-coverage',
422
+ message: `${competencyId} has ${evidence.count} assessment item(s), fewer than required ${competency.evidence.minimumIndependentItems}`,
423
+ });
424
+ }
425
+ if (competency.evidence.requiresTransfer && !evidence.hasTransfer) {
426
+ issues.push({
427
+ kind: 'assessment-coverage',
428
+ message: `${competencyId} requires a transfer assessment item`,
429
+ });
430
+ }
431
+ }
432
+
433
+ validateTeachesSupport(lessons, evidenceByCompetency, issues);
434
+
435
+ for (const track of tracks.values()) {
436
+ const available = new Set<string>();
437
+ for (const duplicate of findDuplicates(track.lessons)) {
438
+ issues.push({
439
+ kind: 'duplicate-reference',
440
+ message: `${track.id} repeats lesson ${duplicate}`,
441
+ });
442
+ }
443
+ for (const lessonId of track.lessons) {
444
+ const lesson = lessons.get(lessonId);
445
+ if (!lesson) {
446
+ issues.push({
447
+ kind: 'unknown-reference',
448
+ message: `${track.id} references unknown lesson ${lessonId}`,
449
+ });
450
+ continue;
451
+ }
452
+ for (const competencyId of lesson.requires) {
453
+ if (!available.has(competencyId)) {
454
+ issues.push({
455
+ kind: 'track-order',
456
+ message: `${track.id} reaches ${lessonId} before required competency ${competencyId} is taught`,
457
+ });
458
+ }
459
+ }
460
+ for (const competencyId of lesson.teaches) available.add(competencyId);
461
+ }
462
+ }
463
+
464
+ return issues;
465
+ }
466
+
467
+ /** Issues that block the build (everything except `severity: 'warning'`). */
468
+ export function curriculumErrors(
469
+ issues: readonly CurriculumIssue[],
470
+ ): readonly CurriculumIssue[] {
471
+ return issues.filter((issue) => issue.severity !== 'warning');
472
+ }
473
+
474
+ export function assertValidCurriculum(catalog: CurriculumCatalog): void {
475
+ const errors = curriculumErrors(validateCurriculum(catalog));
476
+ if (errors.length > 0) {
477
+ throw new Error(
478
+ `Invalid curriculum:\n${errors.map(({ message }) => `- ${message}`).join('\n')}`,
479
+ );
480
+ }
481
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The learning-progress seam (Phase H1).
3
+ *
4
+ * Nothing is implemented behind this interface. `noopProgressRepository` is the
5
+ * only implementation the static site ships: `load` returns an empty snapshot,
6
+ * `recordAttempt` / `reset` do nothing. Assessment components call it on every
7
+ * result so a future host — an SSR app, an LMS importer, or the MCP server that
8
+ * already consumes `.generated/manifest.json` (see `docs/architecture.md`
9
+ * §15) — can supply a real repository (a versioned browser-storage adapter
10
+ * first, a server store later) without changing a single component.
11
+ *
12
+ * This module is framework-free: it imports no React, Astro, content, or
13
+ * browser API, and it reads no browser storage. The first real adapter is the
14
+ * only place a storage call will live.
15
+ */
16
+
17
+ import type { SessionUser } from '../session/user';
18
+
19
+ /**
20
+ * The per-competency progress ladder from `docs/architecture.md` §3. Unused by
21
+ * the no-op impl; part of the contract a real repository fills in.
22
+ */
23
+ export type CompetencyProgressState =
24
+ 'unseen' | 'exposed' | 'practicing' | 'demonstrated' | 'refresh_due';
25
+
26
+ /** One recorded answer to one assessment item. */
27
+ export interface AssessmentAttempt {
28
+ readonly assessmentId: string;
29
+ readonly itemId: string;
30
+ readonly competencyId?: string;
31
+ readonly evidenceKind: 'direct' | 'transfer';
32
+ readonly outcome: 'correct' | 'incorrect';
33
+ /** Epoch milliseconds; the caller supplies it so this stays side-effect-free. */
34
+ readonly at: number;
35
+ }
36
+
37
+ export interface ProgressSnapshot {
38
+ readonly attempts: readonly AssessmentAttempt[];
39
+ readonly states: Readonly<Record<string, CompetencyProgressState>>;
40
+ }
41
+
42
+ export interface ProgressRepository {
43
+ /** Everything known for this viewer. The no-op impl returns {@link EMPTY_PROGRESS}. */
44
+ load(user: SessionUser): ProgressSnapshot | Promise<ProgressSnapshot>;
45
+ /** Persist one attempt. The no-op impl discards it. */
46
+ recordAttempt(
47
+ user: SessionUser,
48
+ attempt: AssessmentAttempt,
49
+ ): void | Promise<void>;
50
+ /** Clear stored attempts, optionally scoped to one assessment or item. */
51
+ reset(
52
+ user: SessionUser,
53
+ scope?: { readonly assessmentId?: string; readonly itemId?: string },
54
+ ): void | Promise<void>;
55
+ }
56
+
57
+ export const EMPTY_PROGRESS: ProgressSnapshot = { attempts: [], states: {} };
58
+
59
+ /** The only implementation in the static build. */
60
+ export const noopProgressRepository: ProgressRepository = {
61
+ load: () => EMPTY_PROGRESS,
62
+ recordAttempt: () => {},
63
+ reset: () => {},
64
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The base notation library: a small, fixed set of universal identifier atoms
3
+ * that the page-level glyph map never asks an author to declare.
4
+ *
5
+ * Rationale (cleanup plan, "Decide once"): keep a base library so the
6
+ * completeness gate does not choke on mathematical constants and the
7
+ * differential operator. It is deliberately tiny and greppable — anything a
8
+ * lesson uses with a domain meaning (rates, indices, prices) still resolves to
9
+ * a semantic key or fails at `file:line:token`.
10
+ *
11
+ * Function names (`\ln`, `\exp`, `\log`, `\sin`, `\max`, `\sum`, `\prod`, …)
12
+ * are not listed here: KaTeX parses them as operator nodes, not identifier
13
+ * atoms, so the tokenizer never treats them as bindable symbols.
14
+ */
15
+ export const BASE_LIBRARY = Object.freeze(
16
+ new Set([
17
+ 'd', // differential operator: dt, dx
18
+ 'e', // Euler's number
19
+ 'i', // imaginary unit
20
+ '\\pi',
21
+ ]),
22
+ );
23
+
24
+ /** True when `token` is a base-library atom and needs no semantic key. */
25
+ export function isBaseLibraryAtom(token) {
26
+ return BASE_LIBRARY.has(token);
27
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pure helpers for turning a source record plus a per-citation locator into
3
+ * the strings shown in the inline panel and the end-of-page reference list.
4
+ *
5
+ * Imported by both the remark adapter (build-time list rendering) and the
6
+ * `CitationLayer` island (hover panel), so it must not touch the DOM, the
7
+ * file system, or Astro APIs.
8
+ */
9
+
10
+ export const SOURCE_ID_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
11
+
12
+ const ORDINAL_SUFFIX = (value) => {
13
+ const number = Number.parseInt(value, 10);
14
+ if (!Number.isFinite(number)) return String(value);
15
+ const tens = number % 100;
16
+ if (tens >= 11 && tens <= 13) return `${number}th`;
17
+ switch (number % 10) {
18
+ case 1:
19
+ return `${number}st`;
20
+ case 2:
21
+ return `${number}nd`;
22
+ case 3:
23
+ return `${number}rd`;
24
+ default:
25
+ return `${number}th`;
26
+ }
27
+ };
28
+
29
+ /**
30
+ * Short author label: one surname, "A & B" for two, "A et al." for more.
31
+ * Falls back to the publishing organization for official guidance.
32
+ *
33
+ * @param {Record<string, unknown>} record
34
+ * @returns {string}
35
+ */
36
+ export function formatAuthors(record) {
37
+ const authors = Array.isArray(record.authors) ? record.authors : [];
38
+ const surnames = authors
39
+ .map((name) => String(name).trim().split(/\s+/).at(-1) ?? '')
40
+ .filter(Boolean);
41
+
42
+ if (surnames.length === 1) return surnames[0];
43
+ if (surnames.length === 2) return `${surnames[0]} & ${surnames[1]}`;
44
+ if (surnames.length > 2) return `${surnames[0]} et al.`;
45
+
46
+ return typeof record.organization === 'string' ? record.organization : '';
47
+ }
48
+
49
+ /**
50
+ * The "(4th ed., 2022)" tail, omitting whichever parts are absent.
51
+ *
52
+ * @param {Record<string, unknown>} record
53
+ * @returns {string}
54
+ */
55
+ export function formatEditionYear(record) {
56
+ const parts = [];
57
+ if (record.edition !== undefined && record.edition !== '') {
58
+ parts.push(`${ORDINAL_SUFFIX(record.edition)} ed.`);
59
+ }
60
+ if (typeof record.year === 'number') parts.push(String(record.year));
61
+ return parts.length > 0 ? ` (${parts.join(', ')})` : '';
62
+ }
63
+
64
+ const stripTrailingPeriod = (value) => value.replace(/\s*\.\s*$/, '');
65
+
66
+ /**
67
+ * One reference-list line: "Authors, Title (edition, year). Locator."
68
+ * `locator` is the text an author passed after the `;` in `[@id; locator]` and is
69
+ * optional. The source record's own catalog `locator` is not used here.
70
+ *
71
+ * @param {Record<string, unknown>} record
72
+ * @param {string | undefined} locator
73
+ * @returns {string}
74
+ */
75
+ export function formatReferenceText(record, locator) {
76
+ const authors = formatAuthors(record);
77
+ const title =
78
+ typeof record.title === 'string' && record.title.length > 0
79
+ ? record.title
80
+ : String(record.id ?? '');
81
+
82
+ const head = [authors, `${title}${formatEditionYear(record)}`]
83
+ .filter(Boolean)
84
+ .join(', ');
85
+
86
+ const trimmedLocator = stripTrailingPeriod(String(locator ?? '').trim());
87
+ return trimmedLocator.length > 0
88
+ ? `${stripTrailingPeriod(head)}. ${trimmedLocator}.`
89
+ : `${stripTrailingPeriod(head)}.`;
90
+ }
91
+
92
+ /**
93
+ * Recover the source label (authors + title, no locator) from a rendered
94
+ * reference-list line — the inverse of the ". <locator>." tail
95
+ * `formatReferenceText` appends. Lets the citation hover panel read its
96
+ * heading off the page's own "References" list instead of a second serialized
97
+ * copy of the source database. Falls back to the whole line if the tail is
98
+ * not where it is expected.
99
+ *
100
+ * @param {string} referenceText text of the `#cite-n` list item
101
+ * @param {string | undefined} locator the marker's `data-citation-locator`
102
+ * @returns {string}
103
+ */
104
+ export function referenceLabelFromText(referenceText, locator) {
105
+ const text = String(referenceText ?? '')
106
+ .replace(/\s+/g, ' ')
107
+ .trim();
108
+ const trimmedLocator = stripTrailingPeriod(String(locator ?? '').trim());
109
+ if (trimmedLocator.length === 0) return stripTrailingPeriod(text);
110
+
111
+ const tail = `. ${trimmedLocator}.`;
112
+ return stripTrailingPeriod(
113
+ text.endsWith(tail) ? text.slice(0, -tail.length) : text,
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The compact "Tuckman & Serrat, Fixed Income Securities (4th ed., 2022)"
119
+ * shown as the panel heading, without the locator.
120
+ *
121
+ * @param {Record<string, unknown>} record
122
+ * @returns {string}
123
+ */
124
+ export function formatSourceLabel(record) {
125
+ const authors = formatAuthors(record);
126
+ const title =
127
+ typeof record.title === 'string' && record.title.length > 0
128
+ ? record.title
129
+ : String(record.id ?? '');
130
+ return [authors, `${title}${formatEditionYear(record)}`]
131
+ .filter(Boolean)
132
+ .join(', ');
133
+ }