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.
- package/README.md +108 -0
- package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
- package/dist/analytics/AnalyticsEmitter.js +5 -0
- package/dist/compiler/cli.d.ts +189 -0
- package/dist/compiler/cli.js +552 -0
- package/dist/compiler/collections.d.ts +70 -0
- package/dist/compiler/collections.js +383 -0
- package/dist/compiler/course-config.d.ts +31 -0
- package/dist/compiler/course-config.js +0 -0
- package/dist/compiler/lesson-derivation.d.ts +89 -0
- package/dist/compiler/lesson-derivation.js +91 -0
- package/dist/compiler/manifest.d.ts +178 -0
- package/dist/compiler/manifest.js +345 -0
- package/dist/compiler/resolution-report.d.ts +45 -0
- package/dist/compiler/resolution-report.js +97 -0
- package/dist/curriculum/validation.d.ts +76 -0
- package/dist/curriculum/validation.js +320 -0
- package/dist/progress/ProgressRepository.d.ts +53 -0
- package/dist/progress/ProgressRepository.js +12 -0
- package/dist/reference/base-library.mjs +27 -0
- package/dist/reference/citation-format.mjs +133 -0
- package/dist/reference/consistency.d.ts +64 -0
- package/dist/reference/consistency.js +234 -0
- package/dist/reference/curriculum-alignment.d.ts +7 -0
- package/dist/reference/curriculum-alignment.js +166 -0
- package/dist/reference/equations-validate.d.ts +50 -0
- package/dist/reference/equations-validate.js +90 -0
- package/dist/reference/equations.mjs +144 -0
- package/dist/reference/gate-math.d.ts +41 -0
- package/dist/reference/gate-math.js +151 -0
- package/dist/reference/katex-options.mjs +91 -0
- package/dist/reference/lab-math-scope.d.ts +21 -0
- package/dist/reference/lab-math-scope.js +28 -0
- package/dist/reference/label.d.ts +11 -0
- package/dist/reference/label.js +38 -0
- package/dist/reference/lint-ignore.d.ts +32 -0
- package/dist/reference/lint-ignore.js +39 -0
- package/dist/reference/math-glyphs.mjs +830 -0
- package/dist/reference/prose.d.ts +15 -0
- package/dist/reference/prose.js +12 -0
- package/dist/reference/references.d.ts +17 -0
- package/dist/reference/references.js +42 -0
- package/dist/reference/registry.d.ts +6 -0
- package/dist/reference/registry.js +548 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +753 -0
- package/dist/reference/render-lab-math.d.ts +21 -0
- package/dist/reference/render-lab-math.js +58 -0
- package/dist/reference/types.d.ts +151 -0
- package/dist/reference/types.js +0 -0
- package/dist/reference/units-vocab.d.ts +26 -0
- package/dist/reference/units-vocab.js +119 -0
- package/dist/session/PreferenceStore.d.ts +28 -0
- package/dist/session/PreferenceStore.js +30 -0
- package/dist/session/user-context.d.ts +7 -0
- package/dist/session/user-context.js +10 -0
- package/dist/session/user.d.ts +21 -0
- package/dist/session/user.js +8 -0
- package/package.json +53 -0
- package/src/analytics/AnalyticsEmitter.ts +23 -0
- package/src/compiler/cli.ts +866 -0
- package/src/compiler/collections.ts +554 -0
- package/src/compiler/course-config.ts +32 -0
- package/src/compiler/lesson-derivation.ts +186 -0
- package/src/compiler/manifest.ts +601 -0
- package/src/compiler/resolution-report.ts +150 -0
- package/src/components/CurriculumMap.astro +208 -0
- package/src/components/HoverPanel.astro +67 -0
- package/src/components/assessments/AssessmentRunner.tsx +320 -0
- package/src/components/assessments/AssessmentSet.astro +54 -0
- package/src/components/citation/CitationLayer.astro +173 -0
- package/src/components/examples/CompactExample.astro +34 -0
- package/src/components/examples/CompactExamples.astro +253 -0
- package/src/components/hover-panel.ts +273 -0
- package/src/components/notation/NotationGlossary.astro +412 -0
- package/src/components/notation/NotationLayer.astro +594 -0
- package/src/components/notation/NotationMath.astro +24 -0
- package/src/components/notation/SafeMathNode.astro +23 -0
- package/src/components/notation/render-notation-math.ts +223 -0
- package/src/components/starlight/EquationEnhancer.astro +72 -0
- package/src/components/starlight/LayoutHeader.astro +102 -0
- package/src/components/starlight/LayoutPageSidebar.astro +33 -0
- package/src/components/starlight/LayoutSidebar.astro +29 -0
- package/src/components/starlight/LessonFooter.astro +58 -0
- package/src/content-config.ts +259 -0
- package/src/curriculum/validation.ts +481 -0
- package/src/progress/ProgressRepository.ts +64 -0
- package/src/reference/base-library.mjs +27 -0
- package/src/reference/citation-format.mjs +133 -0
- package/src/reference/consistency.ts +375 -0
- package/src/reference/curriculum-alignment.ts +228 -0
- package/src/reference/equations-validate.ts +167 -0
- package/src/reference/equations.mjs +144 -0
- package/src/reference/gate-math.ts +250 -0
- package/src/reference/katex-options.mjs +91 -0
- package/src/reference/lab-math-scope.ts +54 -0
- package/src/reference/label.ts +47 -0
- package/src/reference/lint-ignore.ts +75 -0
- package/src/reference/math-glyphs.mjs +830 -0
- package/src/reference/prose.ts +21 -0
- package/src/reference/references.ts +82 -0
- package/src/reference/registry.ts +723 -0
- package/src/reference/rehype-fail-katex-errors.mjs +55 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +753 -0
- package/src/reference/render-lab-math.ts +99 -0
- package/src/reference/types.ts +182 -0
- package/src/reference/units-vocab.ts +143 -0
- package/src/session/PreferenceStore.ts +54 -0
- package/src/session/user-context.ts +18 -0
- package/src/session/user.ts +24 -0
- package/src/styles/global.css +763 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
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
|
+
interface LessonDefinition {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly status: 'draft' | 'in-review' | 'reviewed';
|
|
14
|
+
readonly requires: readonly string[];
|
|
15
|
+
readonly teaches: readonly string[];
|
|
16
|
+
readonly assessments: readonly string[];
|
|
17
|
+
readonly sources: readonly string[];
|
|
18
|
+
readonly assumptions: readonly string[];
|
|
19
|
+
/** Raw Markdown body, used to keep inline `[@…]` citations in sync with `sources`. */
|
|
20
|
+
readonly body?: string;
|
|
21
|
+
}
|
|
22
|
+
interface AssessmentItemBase {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly competencyId: string;
|
|
25
|
+
readonly evidenceKind: 'direct' | 'transfer';
|
|
26
|
+
}
|
|
27
|
+
interface NumericAssessmentItem extends AssessmentItemBase {
|
|
28
|
+
readonly type: 'numeric';
|
|
29
|
+
readonly answer: {
|
|
30
|
+
readonly value: number;
|
|
31
|
+
readonly tolerance: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
interface ChoiceAssessmentItem extends AssessmentItemBase {
|
|
35
|
+
readonly type: 'single-choice';
|
|
36
|
+
readonly options: readonly {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly label: string;
|
|
39
|
+
}[];
|
|
40
|
+
readonly correctOptionId: string;
|
|
41
|
+
}
|
|
42
|
+
interface AssessmentDefinition {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly items: readonly (NumericAssessmentItem | ChoiceAssessmentItem)[];
|
|
45
|
+
}
|
|
46
|
+
interface SourceDefinition {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly editorialStatus: 'draft' | 'in-review' | 'reviewed';
|
|
49
|
+
}
|
|
50
|
+
interface TrackDefinition {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly lessons: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
interface CurriculumCatalog {
|
|
55
|
+
readonly competencies: readonly CompetencyDefinition[];
|
|
56
|
+
readonly lessons: readonly LessonDefinition[];
|
|
57
|
+
readonly assessments: readonly AssessmentDefinition[];
|
|
58
|
+
readonly sources: readonly SourceDefinition[];
|
|
59
|
+
readonly tracks: readonly TrackDefinition[];
|
|
60
|
+
}
|
|
61
|
+
interface CurriculumIssue {
|
|
62
|
+
readonly kind: 'invalid-id' | 'duplicate-id' | 'unknown-reference' | 'duplicate-reference' | 'self-prerequisite' | 'cycle' | 'lesson-order' | 'track-order' | 'assessment-coverage' | 'invalid-assessment' | 'review-state' | 'teaches-support';
|
|
63
|
+
readonly message: string;
|
|
64
|
+
/**
|
|
65
|
+
* `error` (default) blocks the build; `warning` is reported but non-blocking.
|
|
66
|
+
* New checks enter as warnings and are promoted once they run clean on real
|
|
67
|
+
* content.
|
|
68
|
+
*/
|
|
69
|
+
readonly severity?: 'error' | 'warning';
|
|
70
|
+
}
|
|
71
|
+
declare function validateCurriculum(catalog: CurriculumCatalog): readonly CurriculumIssue[];
|
|
72
|
+
/** Issues that block the build (everything except `severity: 'warning'`). */
|
|
73
|
+
declare function curriculumErrors(issues: readonly CurriculumIssue[]): readonly CurriculumIssue[];
|
|
74
|
+
declare function assertValidCurriculum(catalog: CurriculumCatalog): void;
|
|
75
|
+
|
|
76
|
+
export { type AssessmentDefinition, type ChoiceAssessmentItem, type CompetencyDefinition, type CurriculumCatalog, type CurriculumIssue, type LessonDefinition, type NumericAssessmentItem, type SourceDefinition, type TrackDefinition, assertValidCurriculum, curriculumErrors, validateCurriculum };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
const VALID_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
2
|
+
function buildIndex(kind, entries, issues) {
|
|
3
|
+
const index = /* @__PURE__ */ new Map();
|
|
4
|
+
for (const entry of entries) {
|
|
5
|
+
if (!VALID_ID.test(entry.id)) {
|
|
6
|
+
issues.push({
|
|
7
|
+
kind: "invalid-id",
|
|
8
|
+
message: `${kind} has invalid id ${JSON.stringify(entry.id)}`
|
|
9
|
+
});
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (index.has(entry.id)) {
|
|
13
|
+
issues.push({
|
|
14
|
+
kind: "duplicate-id",
|
|
15
|
+
message: `duplicate ${kind} id ${entry.id}`
|
|
16
|
+
});
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
index.set(entry.id, entry);
|
|
20
|
+
}
|
|
21
|
+
return index;
|
|
22
|
+
}
|
|
23
|
+
function citedSourceIds(body) {
|
|
24
|
+
if (!body) return /* @__PURE__ */ new Set();
|
|
25
|
+
const withoutCode = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
26
|
+
const ids = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const match of withoutCode.matchAll(
|
|
28
|
+
/\[@([a-z0-9][a-z0-9.-]*)(?:;[^\]]*)?\]/g
|
|
29
|
+
)) {
|
|
30
|
+
ids.add((match[1] ?? "").trim());
|
|
31
|
+
}
|
|
32
|
+
return ids;
|
|
33
|
+
}
|
|
34
|
+
function findDuplicates(values) {
|
|
35
|
+
const seen = /* @__PURE__ */ new Set();
|
|
36
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
37
|
+
for (const value of values) {
|
|
38
|
+
if (seen.has(value)) duplicates.add(value);
|
|
39
|
+
seen.add(value);
|
|
40
|
+
}
|
|
41
|
+
return [...duplicates];
|
|
42
|
+
}
|
|
43
|
+
function validateCompetencyGraph(competencies, issues) {
|
|
44
|
+
for (const competency of competencies.values()) {
|
|
45
|
+
for (const prerequisiteId of findDuplicates(competency.prerequisites)) {
|
|
46
|
+
issues.push({
|
|
47
|
+
kind: "duplicate-reference",
|
|
48
|
+
message: `${competency.id} repeats prerequisite ${prerequisiteId}`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const prerequisiteId of competency.prerequisites) {
|
|
52
|
+
if (prerequisiteId === competency.id) {
|
|
53
|
+
issues.push({
|
|
54
|
+
kind: "self-prerequisite",
|
|
55
|
+
message: `${competency.id} requires itself`
|
|
56
|
+
});
|
|
57
|
+
} else if (!competencies.has(prerequisiteId)) {
|
|
58
|
+
issues.push({
|
|
59
|
+
kind: "unknown-reference",
|
|
60
|
+
message: `${competency.id} requires unknown competency ${prerequisiteId}`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const state = /* @__PURE__ */ new Map();
|
|
66
|
+
const stack = [];
|
|
67
|
+
const reportedCycles = /* @__PURE__ */ new Set();
|
|
68
|
+
const visit = (id) => {
|
|
69
|
+
state.set(id, "visiting");
|
|
70
|
+
stack.push(id);
|
|
71
|
+
for (const prerequisiteId of competencies.get(id)?.prerequisites ?? []) {
|
|
72
|
+
if (prerequisiteId === id || !competencies.has(prerequisiteId)) continue;
|
|
73
|
+
const prerequisiteState = state.get(prerequisiteId);
|
|
74
|
+
if (prerequisiteState === void 0) {
|
|
75
|
+
visit(prerequisiteId);
|
|
76
|
+
} else if (prerequisiteState === "visiting") {
|
|
77
|
+
const start = stack.lastIndexOf(prerequisiteId);
|
|
78
|
+
const path = [...stack.slice(start), prerequisiteId];
|
|
79
|
+
const key = [...new Set(path)].sort().join("|");
|
|
80
|
+
if (!reportedCycles.has(key)) {
|
|
81
|
+
reportedCycles.add(key);
|
|
82
|
+
issues.push({
|
|
83
|
+
kind: "cycle",
|
|
84
|
+
message: `prerequisite cycle: ${path.join(" -> ")}`
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
stack.pop();
|
|
90
|
+
state.set(id, "visited");
|
|
91
|
+
};
|
|
92
|
+
for (const id of competencies.keys()) {
|
|
93
|
+
if (state.get(id) === void 0) visit(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const WORKED_INSTANCE = /<CompactExample\b|^#{1,6}\s+.*\b(?:worked|example)\b|=\s*-?\d|\d\.\d{3,}/im;
|
|
97
|
+
function validateTeachesSupport(lessons, evidenceByCompetency, issues) {
|
|
98
|
+
for (const lesson of lessons.values()) {
|
|
99
|
+
if (lesson.teaches.length === 0) continue;
|
|
100
|
+
if (!WORKED_INSTANCE.test(lesson.body ?? "")) {
|
|
101
|
+
issues.push({
|
|
102
|
+
kind: "teaches-support",
|
|
103
|
+
severity: "warning",
|
|
104
|
+
message: `${lesson.id} teaches ${lesson.teaches.join(", ")} but the body has no worked instance (CompactExample or a worked-example section)`
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const unmeasured = lesson.teaches.filter(
|
|
108
|
+
(competencyId) => (evidenceByCompetency.get(competencyId)?.count ?? 0) === 0
|
|
109
|
+
);
|
|
110
|
+
if (unmeasured.length > 0) {
|
|
111
|
+
issues.push({
|
|
112
|
+
kind: "teaches-support",
|
|
113
|
+
severity: "warning",
|
|
114
|
+
message: `${lesson.id} teaches ${unmeasured.join(", ")} with no assessment item measuring it`
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function validateCurriculum(catalog) {
|
|
120
|
+
const issues = [];
|
|
121
|
+
const competencies = buildIndex("competency", catalog.competencies, issues);
|
|
122
|
+
const lessons = buildIndex("lesson", catalog.lessons, issues);
|
|
123
|
+
const assessments = buildIndex("assessment", catalog.assessments, issues);
|
|
124
|
+
const sources = buildIndex("source", catalog.sources, issues);
|
|
125
|
+
const tracks = buildIndex("track", catalog.tracks, issues);
|
|
126
|
+
validateCompetencyGraph(competencies, issues);
|
|
127
|
+
for (const lesson of lessons.values()) {
|
|
128
|
+
for (const field of ["requires", "teaches"]) {
|
|
129
|
+
for (const duplicate of findDuplicates(lesson[field])) {
|
|
130
|
+
issues.push({
|
|
131
|
+
kind: "duplicate-reference",
|
|
132
|
+
message: `${lesson.id} repeats ${field} competency ${duplicate}`
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
for (const competencyId of lesson[field]) {
|
|
136
|
+
if (!competencies.has(competencyId)) {
|
|
137
|
+
issues.push({
|
|
138
|
+
kind: "unknown-reference",
|
|
139
|
+
message: `${lesson.id} ${field} unknown competency ${competencyId}`
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const competencyId of lesson.requires) {
|
|
145
|
+
if (lesson.teaches.includes(competencyId)) {
|
|
146
|
+
issues.push({
|
|
147
|
+
kind: "lesson-order",
|
|
148
|
+
message: `${lesson.id} both requires and teaches ${competencyId}`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const available = new Set(lesson.requires);
|
|
153
|
+
for (const competencyId of lesson.teaches) {
|
|
154
|
+
const competency = competencies.get(competencyId);
|
|
155
|
+
if (competency) {
|
|
156
|
+
for (const prerequisiteId of competency.prerequisites) {
|
|
157
|
+
if (!available.has(prerequisiteId)) {
|
|
158
|
+
issues.push({
|
|
159
|
+
kind: "lesson-order",
|
|
160
|
+
message: `${lesson.id} teaches ${competencyId} before prerequisite ${prerequisiteId} is available`
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
available.add(competencyId);
|
|
166
|
+
}
|
|
167
|
+
for (const assessmentId of lesson.assessments) {
|
|
168
|
+
if (!assessments.has(assessmentId)) {
|
|
169
|
+
issues.push({
|
|
170
|
+
kind: "unknown-reference",
|
|
171
|
+
message: `${lesson.id} references unknown assessment ${assessmentId}`
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const cited = citedSourceIds(lesson.body);
|
|
176
|
+
for (const sourceId of lesson.sources) {
|
|
177
|
+
const source = sources.get(sourceId);
|
|
178
|
+
if (!source) {
|
|
179
|
+
issues.push({
|
|
180
|
+
kind: "unknown-reference",
|
|
181
|
+
message: `${lesson.id} references unknown source ${sourceId}`
|
|
182
|
+
});
|
|
183
|
+
} else if (lesson.status === "reviewed" && source.editorialStatus !== "reviewed") {
|
|
184
|
+
issues.push({
|
|
185
|
+
kind: "review-state",
|
|
186
|
+
message: `${lesson.id} is reviewed but source ${sourceId} is ${source.editorialStatus}`
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (lesson.body !== void 0 && !cited.has(sourceId)) {
|
|
190
|
+
issues.push({
|
|
191
|
+
kind: "unknown-reference",
|
|
192
|
+
message: `${lesson.id} declares source ${sourceId} but never cites it with [@${sourceId}]`
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const sourceId of cited) {
|
|
197
|
+
if (!lesson.sources.includes(sourceId)) {
|
|
198
|
+
issues.push({
|
|
199
|
+
kind: "unknown-reference",
|
|
200
|
+
message: `${lesson.id} cites ${sourceId} in prose but does not list it in frontmatter sources`
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (lesson.teaches.length > 0 && lesson.assumptions.length === 0) {
|
|
205
|
+
issues.push({
|
|
206
|
+
kind: "review-state",
|
|
207
|
+
message: `${lesson.id} teaches quantitative content but declares no assumptions`
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const itemIds = /* @__PURE__ */ new Set();
|
|
212
|
+
const evidenceByCompetency = /* @__PURE__ */ new Map();
|
|
213
|
+
for (const assessment of assessments.values()) {
|
|
214
|
+
for (const item of assessment.items) {
|
|
215
|
+
if (itemIds.has(item.id)) {
|
|
216
|
+
issues.push({
|
|
217
|
+
kind: "duplicate-id",
|
|
218
|
+
message: `duplicate assessment item id ${item.id}`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
itemIds.add(item.id);
|
|
222
|
+
if (!competencies.has(item.competencyId)) {
|
|
223
|
+
issues.push({
|
|
224
|
+
kind: "unknown-reference",
|
|
225
|
+
message: `${assessment.id}/${item.id} measures unknown competency ${item.competencyId}`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (item.type === "single-choice") {
|
|
229
|
+
if (!item.options.some((option) => option.id === item.correctOptionId)) {
|
|
230
|
+
issues.push({
|
|
231
|
+
kind: "invalid-assessment",
|
|
232
|
+
message: `${assessment.id}/${item.id} has unknown correct option ${item.correctOptionId}`
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} else if (!Number.isFinite(item.answer.value) || !Number.isFinite(item.answer.tolerance) || item.answer.tolerance <= 0) {
|
|
236
|
+
issues.push({
|
|
237
|
+
kind: "invalid-assessment",
|
|
238
|
+
message: `${assessment.id}/${item.id} has an invalid numeric answer or tolerance`
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const evidence = evidenceByCompetency.get(item.competencyId) ?? {
|
|
242
|
+
count: 0,
|
|
243
|
+
hasTransfer: false
|
|
244
|
+
};
|
|
245
|
+
evidence.count += 1;
|
|
246
|
+
evidence.hasTransfer ||= item.evidenceKind === "transfer";
|
|
247
|
+
evidenceByCompetency.set(item.competencyId, evidence);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const taughtCompetencies = new Set(
|
|
251
|
+
[...lessons.values()].flatMap((lesson) => [...lesson.teaches])
|
|
252
|
+
);
|
|
253
|
+
for (const competencyId of taughtCompetencies) {
|
|
254
|
+
const competency = competencies.get(competencyId);
|
|
255
|
+
if (!competency) continue;
|
|
256
|
+
const evidence = evidenceByCompetency.get(competencyId) ?? {
|
|
257
|
+
count: 0,
|
|
258
|
+
hasTransfer: false
|
|
259
|
+
};
|
|
260
|
+
if (evidence.count < competency.evidence.minimumIndependentItems) {
|
|
261
|
+
issues.push({
|
|
262
|
+
kind: "assessment-coverage",
|
|
263
|
+
message: `${competencyId} has ${evidence.count} assessment item(s), fewer than required ${competency.evidence.minimumIndependentItems}`
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (competency.evidence.requiresTransfer && !evidence.hasTransfer) {
|
|
267
|
+
issues.push({
|
|
268
|
+
kind: "assessment-coverage",
|
|
269
|
+
message: `${competencyId} requires a transfer assessment item`
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
validateTeachesSupport(lessons, evidenceByCompetency, issues);
|
|
274
|
+
for (const track of tracks.values()) {
|
|
275
|
+
const available = /* @__PURE__ */ new Set();
|
|
276
|
+
for (const duplicate of findDuplicates(track.lessons)) {
|
|
277
|
+
issues.push({
|
|
278
|
+
kind: "duplicate-reference",
|
|
279
|
+
message: `${track.id} repeats lesson ${duplicate}`
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
for (const lessonId of track.lessons) {
|
|
283
|
+
const lesson = lessons.get(lessonId);
|
|
284
|
+
if (!lesson) {
|
|
285
|
+
issues.push({
|
|
286
|
+
kind: "unknown-reference",
|
|
287
|
+
message: `${track.id} references unknown lesson ${lessonId}`
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
for (const competencyId of lesson.requires) {
|
|
292
|
+
if (!available.has(competencyId)) {
|
|
293
|
+
issues.push({
|
|
294
|
+
kind: "track-order",
|
|
295
|
+
message: `${track.id} reaches ${lessonId} before required competency ${competencyId} is taught`
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const competencyId of lesson.teaches) available.add(competencyId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return issues;
|
|
303
|
+
}
|
|
304
|
+
function curriculumErrors(issues) {
|
|
305
|
+
return issues.filter((issue) => issue.severity !== "warning");
|
|
306
|
+
}
|
|
307
|
+
function assertValidCurriculum(catalog) {
|
|
308
|
+
const errors = curriculumErrors(validateCurriculum(catalog));
|
|
309
|
+
if (errors.length > 0) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Invalid curriculum:
|
|
312
|
+
${errors.map(({ message }) => `- ${message}`).join("\n")}`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
export {
|
|
317
|
+
assertValidCurriculum,
|
|
318
|
+
curriculumErrors,
|
|
319
|
+
validateCurriculum
|
|
320
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { SessionUser } from '../session/user.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The learning-progress seam (Phase H1).
|
|
5
|
+
*
|
|
6
|
+
* Nothing is implemented behind this interface. `noopProgressRepository` is the
|
|
7
|
+
* only implementation the static site ships: `load` returns an empty snapshot,
|
|
8
|
+
* `recordAttempt` / `reset` do nothing. Assessment components call it on every
|
|
9
|
+
* result so a future host — an SSR app, an LMS importer, or the MCP server that
|
|
10
|
+
* already consumes `.generated/manifest.json` (see `docs/architecture.md`
|
|
11
|
+
* §15) — can supply a real repository (a versioned browser-storage adapter
|
|
12
|
+
* first, a server store later) without changing a single component.
|
|
13
|
+
*
|
|
14
|
+
* This module is framework-free: it imports no React, Astro, content, or
|
|
15
|
+
* browser API, and it reads no browser storage. The first real adapter is the
|
|
16
|
+
* only place a storage call will live.
|
|
17
|
+
*/
|
|
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
|
+
type CompetencyProgressState = 'unseen' | 'exposed' | 'practicing' | 'demonstrated' | 'refresh_due';
|
|
24
|
+
/** One recorded answer to one assessment item. */
|
|
25
|
+
interface AssessmentAttempt {
|
|
26
|
+
readonly assessmentId: string;
|
|
27
|
+
readonly itemId: string;
|
|
28
|
+
readonly competencyId?: string;
|
|
29
|
+
readonly evidenceKind: 'direct' | 'transfer';
|
|
30
|
+
readonly outcome: 'correct' | 'incorrect';
|
|
31
|
+
/** Epoch milliseconds; the caller supplies it so this stays side-effect-free. */
|
|
32
|
+
readonly at: number;
|
|
33
|
+
}
|
|
34
|
+
interface ProgressSnapshot {
|
|
35
|
+
readonly attempts: readonly AssessmentAttempt[];
|
|
36
|
+
readonly states: Readonly<Record<string, CompetencyProgressState>>;
|
|
37
|
+
}
|
|
38
|
+
interface ProgressRepository {
|
|
39
|
+
/** Everything known for this viewer. The no-op impl returns {@link EMPTY_PROGRESS}. */
|
|
40
|
+
load(user: SessionUser): ProgressSnapshot | Promise<ProgressSnapshot>;
|
|
41
|
+
/** Persist one attempt. The no-op impl discards it. */
|
|
42
|
+
recordAttempt(user: SessionUser, attempt: AssessmentAttempt): void | Promise<void>;
|
|
43
|
+
/** Clear stored attempts, optionally scoped to one assessment or item. */
|
|
44
|
+
reset(user: SessionUser, scope?: {
|
|
45
|
+
readonly assessmentId?: string;
|
|
46
|
+
readonly itemId?: string;
|
|
47
|
+
}): void | Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
declare const EMPTY_PROGRESS: ProgressSnapshot;
|
|
50
|
+
/** The only implementation in the static build. */
|
|
51
|
+
declare const noopProgressRepository: ProgressRepository;
|
|
52
|
+
|
|
53
|
+
export { type AssessmentAttempt, type CompetencyProgressState, EMPTY_PROGRESS, type ProgressRepository, type ProgressSnapshot, noopProgressRepository };
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CourseConventions } from '../compiler/course-config.js';
|
|
2
|
+
import { CurriculumCatalog } from '../curriculum/validation.js';
|
|
3
|
+
import { NotationSpec } from '../compiler/collections.js';
|
|
4
|
+
import { LintIgnore } from './lint-ignore.js';
|
|
5
|
+
import { NotationRegistry, NotationRegistryInput } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
|
|
9
|
+
* plan's "Enforced checks" section that no earlier step covers.
|
|
10
|
+
*
|
|
11
|
+
* Every check here enters as a **Tier-3 warning**: `pnpm validate:content`
|
|
12
|
+
* surfaces the counts, `manifest.diagnostics.consistency` records the detail,
|
|
13
|
+
* and `resolution/consistency-report.json` is the committed, diff-checkable
|
|
14
|
+
* snapshot. Nothing here changes content — each finding is an open item for a
|
|
15
|
+
* separate human-quantitative-reviewed pass. A finding is silenced only by a
|
|
16
|
+
* reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
|
|
17
|
+
*
|
|
18
|
+
* (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
|
|
19
|
+
* (b) notation-units / units-vocab every entry has units or `dimensionless`,
|
|
20
|
+
* unit strings drawn from a controlled vocab
|
|
21
|
+
* (c) convention-single-definition a sign / cash-flow-perspective convention a
|
|
22
|
+
* lesson uses resolves to one declaration
|
|
23
|
+
* (e) numerals-tagged numerals in `$…$` / result tables are tagged
|
|
24
|
+
* to a `src/domain/` call or a source locator
|
|
25
|
+
* (f) weak-local a `notation.local` symbol used only in its
|
|
26
|
+
* own defining equation and prose
|
|
27
|
+
* (g) notation-source-locator every notation `sources` entry has a locator
|
|
28
|
+
*
|
|
29
|
+
* Check (d) — the committed per-lesson resolution report — lives in
|
|
30
|
+
* `src/content/resolution-report.ts`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator';
|
|
34
|
+
interface ConsistencyDiagnostic {
|
|
35
|
+
readonly code: ConsistencyCode;
|
|
36
|
+
/** Tier 3 — every finding here is a warning until content is brought clean. */
|
|
37
|
+
readonly severity: 'warning';
|
|
38
|
+
readonly message: string;
|
|
39
|
+
readonly file?: string;
|
|
40
|
+
readonly lessonId?: string;
|
|
41
|
+
readonly key?: string;
|
|
42
|
+
readonly glyph?: string;
|
|
43
|
+
/** For the aggregate numeral check. */
|
|
44
|
+
readonly count?: number;
|
|
45
|
+
}
|
|
46
|
+
interface ConsistencyInput {
|
|
47
|
+
readonly registry: NotationRegistry;
|
|
48
|
+
readonly notationInput: NotationRegistryInput;
|
|
49
|
+
readonly specs: readonly NotationSpec[];
|
|
50
|
+
readonly catalog: CurriculumCatalog;
|
|
51
|
+
readonly lintIgnore: LintIgnore;
|
|
52
|
+
/** Convention keys this course anchors on (from `content/course.config.ts`). */
|
|
53
|
+
readonly conventions: CourseConventions;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Run every consistency check. The result is sorted deterministically (code,
|
|
57
|
+
* then lesson / key / glyph, then message) so it is byte-stable in the manifest
|
|
58
|
+
* and the committed report.
|
|
59
|
+
*/
|
|
60
|
+
declare function runConsistencyChecks(input: ConsistencyInput): ConsistencyDiagnostic[];
|
|
61
|
+
/** `{ code: count }` for the CLI summary and the report header. */
|
|
62
|
+
declare function consistencyCounts(diagnostics: readonly ConsistencyDiagnostic[]): Record<ConsistencyCode, number>;
|
|
63
|
+
|
|
64
|
+
export { type ConsistencyCode, type ConsistencyDiagnostic, type ConsistencyInput, consistencyCounts, runConsistencyChecks };
|