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,234 @@
|
|
|
1
|
+
import { offVocabularyTokens } from "./units-vocab";
|
|
2
|
+
const CONVENTION_RE = /\bsign convention\b|\bcash-flow perspective\b|\bholder(?:'s)?(?:\s+\w+){0,2}\s+perspective\b|\bfrom (?:a|one|the|its)(?:\s+\w+){0,3}\s+perspective\b|\breverses? every sign\b|\bpositive (?:means|amounts?)\b/i;
|
|
3
|
+
function stripCode(text) {
|
|
4
|
+
return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " ")).replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, " "));
|
|
5
|
+
}
|
|
6
|
+
function mathSpans(body) {
|
|
7
|
+
const searchable = stripCode(body);
|
|
8
|
+
const spans = [];
|
|
9
|
+
for (const match of searchable.matchAll(/\$\$([\s\S]+?)\$\$/g)) {
|
|
10
|
+
spans.push({ latex: match[1] ?? "" });
|
|
11
|
+
}
|
|
12
|
+
for (const match of searchable.replace(/\$\$([\s\S]+?)\$\$/g, (block) => block.replace(/[^\n]/g, " ")).matchAll(/(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g)) {
|
|
13
|
+
spans.push({ latex: match[1] ?? "" });
|
|
14
|
+
}
|
|
15
|
+
return spans;
|
|
16
|
+
}
|
|
17
|
+
function tableRows(body) {
|
|
18
|
+
const rows = [];
|
|
19
|
+
for (const line of stripCode(body).split("\n")) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed.startsWith("|") || !trimmed.slice(1).includes("|")) continue;
|
|
22
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
23
|
+
rows.push(line);
|
|
24
|
+
}
|
|
25
|
+
return rows;
|
|
26
|
+
}
|
|
27
|
+
const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
|
|
28
|
+
function numeralCount(text) {
|
|
29
|
+
return (text.replace(/\\[a-zA-Z]+/g, " ").match(NUMERAL_RE) ?? []).length;
|
|
30
|
+
}
|
|
31
|
+
function isTagged(text) {
|
|
32
|
+
return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
|
|
33
|
+
}
|
|
34
|
+
function glyphMatcher(latex) {
|
|
35
|
+
const trimmed = latex.trim();
|
|
36
|
+
if (trimmed === "") return void 0;
|
|
37
|
+
if (/^[A-Za-z]$/.test(trimmed)) {
|
|
38
|
+
return new RegExp(`(?<![A-Za-z\\\\])${trimmed}(?![A-Za-z])`);
|
|
39
|
+
}
|
|
40
|
+
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
return new RegExp(
|
|
42
|
+
/[A-Za-z]$/.test(trimmed) ? `${escaped}(?![A-Za-z])` : escaped
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function checkGlyphUniqueness(input, out) {
|
|
46
|
+
const byId = new Map(
|
|
47
|
+
input.registry.definitions.map((definition) => [definition.id, definition])
|
|
48
|
+
);
|
|
49
|
+
const glyphs = /* @__PURE__ */ new Map();
|
|
50
|
+
const record = (glyph, key, lessonId) => {
|
|
51
|
+
const trimmed = glyph.trim();
|
|
52
|
+
if (trimmed === "") return;
|
|
53
|
+
const keys = glyphs.get(trimmed) ?? /* @__PURE__ */ new Map();
|
|
54
|
+
const lessons = keys.get(key) ?? /* @__PURE__ */ new Set();
|
|
55
|
+
lessons.add(lessonId);
|
|
56
|
+
keys.set(key, lessons);
|
|
57
|
+
glyphs.set(trimmed, keys);
|
|
58
|
+
};
|
|
59
|
+
for (const definition of input.registry.definitions) {
|
|
60
|
+
if (definition.kind === "local") {
|
|
61
|
+
record(definition.notation, definition.key, definition.lessonId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const bundle of input.registry.bundles) {
|
|
65
|
+
for (const binding of bundle.bindings) {
|
|
66
|
+
const definition = byId.get(binding.definitionId);
|
|
67
|
+
if (definition) record(definition.notation, binding.key, bundle.lessonId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const [glyph, keys] of [...glyphs].sort(
|
|
71
|
+
([a], [b]) => a.localeCompare(b)
|
|
72
|
+
)) {
|
|
73
|
+
if (keys.size < 2) continue;
|
|
74
|
+
if (input.lintIgnore.matches("glyph-unique-in-corpus", glyph)) continue;
|
|
75
|
+
const detail = [...keys].sort(([a], [b]) => a.localeCompare(b)).map(([key, lessons]) => `${key} (${[...lessons].sort().join(", ")})`).join("; ");
|
|
76
|
+
out.push({
|
|
77
|
+
code: "glyph-unique-in-corpus",
|
|
78
|
+
severity: "warning",
|
|
79
|
+
glyph,
|
|
80
|
+
message: `glyph ${JSON.stringify(glyph)} resolves to ${keys.size} meanings across the corpus: ${detail}`
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function checkNotationUnits(input, out) {
|
|
85
|
+
for (const spec of input.specs) {
|
|
86
|
+
if (spec.units === void 0 && !spec.dimensionless) {
|
|
87
|
+
if (input.lintIgnore.matches("notation-units", spec.key)) continue;
|
|
88
|
+
out.push({
|
|
89
|
+
code: "notation-units",
|
|
90
|
+
severity: "warning",
|
|
91
|
+
key: spec.key,
|
|
92
|
+
file: spec.file,
|
|
93
|
+
...spec.lessonId ? { lessonId: spec.lessonId } : {},
|
|
94
|
+
message: `notation ${spec.key} (${spec.scope}) declares neither units nor dimensionless`
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (spec.units === void 0) continue;
|
|
99
|
+
const offVocab = offVocabularyTokens(spec.units);
|
|
100
|
+
if (offVocab.length === 0) continue;
|
|
101
|
+
if (input.lintIgnore.matches("units-vocab", spec.key)) continue;
|
|
102
|
+
out.push({
|
|
103
|
+
code: "units-vocab",
|
|
104
|
+
severity: "warning",
|
|
105
|
+
key: spec.key,
|
|
106
|
+
file: spec.file,
|
|
107
|
+
...spec.lessonId ? { lessonId: spec.lessonId } : {},
|
|
108
|
+
message: `notation ${spec.key} units ${JSON.stringify(spec.units)} use non-vocabulary token(s): ${offVocab.join(", ")}`
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function checkConventionSingleDefinition(input, out) {
|
|
113
|
+
const lessonById = new Map(
|
|
114
|
+
input.catalog.lessons.map((lesson) => [lesson.id, lesson])
|
|
115
|
+
);
|
|
116
|
+
for (const lesson of input.notationInput.lessons) {
|
|
117
|
+
const body = lesson.body;
|
|
118
|
+
if (!CONVENTION_RE.test(body)) continue;
|
|
119
|
+
const meta = lessonById.get(lesson.lessonId);
|
|
120
|
+
const { signConventionKey, cashFlowPerspectiveCompetency } = input.conventions;
|
|
121
|
+
const anchoredByCompetency = [
|
|
122
|
+
...meta?.teaches ?? [],
|
|
123
|
+
...meta?.requires ?? []
|
|
124
|
+
].includes(cashFlowPerspectiveCompetency);
|
|
125
|
+
const anchoredByTerm = new RegExp(
|
|
126
|
+
`\\[\\[\\s*${signConventionKey}\\s*\\]\\]`
|
|
127
|
+
).test(body);
|
|
128
|
+
if (anchoredByCompetency || anchoredByTerm) continue;
|
|
129
|
+
if (input.lintIgnore.matches("convention-single-definition", lesson.lessonId)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
out.push({
|
|
133
|
+
code: "convention-single-definition",
|
|
134
|
+
severity: "warning",
|
|
135
|
+
lessonId: lesson.lessonId,
|
|
136
|
+
file: lesson.source.file,
|
|
137
|
+
message: `${lesson.lessonId} states a sign / cash-flow-perspective convention in prose but does not resolve it to a single declaration ([[${signConventionKey}]] or ${cashFlowPerspectiveCompetency})`
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function checkNumeralsTagged(input, out) {
|
|
142
|
+
for (const lesson of input.notationInput.lessons) {
|
|
143
|
+
if (input.lintIgnore.matches("numerals-tagged", lesson.lessonId)) continue;
|
|
144
|
+
let math = 0;
|
|
145
|
+
for (const span of mathSpans(lesson.body)) {
|
|
146
|
+
if (isTagged(span.latex)) continue;
|
|
147
|
+
math += numeralCount(span.latex);
|
|
148
|
+
}
|
|
149
|
+
let table = 0;
|
|
150
|
+
for (const row of tableRows(lesson.body)) {
|
|
151
|
+
if (isTagged(row)) continue;
|
|
152
|
+
table += numeralCount(row.replace(/\$[^$\n]*\$/g, " "));
|
|
153
|
+
}
|
|
154
|
+
const count = math + table;
|
|
155
|
+
if (count === 0) continue;
|
|
156
|
+
out.push({
|
|
157
|
+
code: "numerals-tagged",
|
|
158
|
+
severity: "warning",
|
|
159
|
+
lessonId: lesson.lessonId,
|
|
160
|
+
file: lesson.source.file,
|
|
161
|
+
count,
|
|
162
|
+
message: `${lesson.lessonId}: ${count} numeral(s) in $\u2026$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function checkWeakLocal(input, out) {
|
|
167
|
+
for (const lesson of input.notationInput.lessons) {
|
|
168
|
+
const spans = mathSpans(lesson.body).map((span) => span.latex);
|
|
169
|
+
for (const definition of lesson.localDefinitions) {
|
|
170
|
+
if (input.lintIgnore.matches("weak-local", definition.key)) continue;
|
|
171
|
+
const matcher = glyphMatcher(definition.notation);
|
|
172
|
+
if (!matcher) continue;
|
|
173
|
+
const uses = spans.filter((latex) => matcher.test(latex)).length;
|
|
174
|
+
if (uses > 1) continue;
|
|
175
|
+
out.push({
|
|
176
|
+
code: "weak-local",
|
|
177
|
+
severity: "warning",
|
|
178
|
+
lessonId: lesson.lessonId,
|
|
179
|
+
file: lesson.source.file,
|
|
180
|
+
key: definition.key,
|
|
181
|
+
count: uses,
|
|
182
|
+
message: `${lesson.lessonId}: local symbol ${definition.key} (${definition.notation}) appears in ${uses} body equation(s); referenced only in its defining equation and prose \u2014 inline candidate`
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function checkNotationSourceLocator(input, out) {
|
|
188
|
+
for (const spec of input.specs) {
|
|
189
|
+
for (const [index, source] of spec.sources.entries()) {
|
|
190
|
+
if (source.hasLocator) continue;
|
|
191
|
+
if (input.lintIgnore.matches("notation-source-locator", spec.key)) break;
|
|
192
|
+
out.push({
|
|
193
|
+
code: "notation-source-locator",
|
|
194
|
+
severity: "warning",
|
|
195
|
+
key: spec.key,
|
|
196
|
+
file: spec.file,
|
|
197
|
+
...spec.lessonId ? { lessonId: spec.lessonId } : {},
|
|
198
|
+
message: `notation ${spec.key} sources[${index}] (${source.id || "bare id"}) has no non-empty locator`
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const CHECKS = [
|
|
204
|
+
checkGlyphUniqueness,
|
|
205
|
+
checkNotationUnits,
|
|
206
|
+
checkConventionSingleDefinition,
|
|
207
|
+
checkNumeralsTagged,
|
|
208
|
+
checkWeakLocal,
|
|
209
|
+
checkNotationSourceLocator
|
|
210
|
+
];
|
|
211
|
+
function runConsistencyChecks(input) {
|
|
212
|
+
const diagnostics = [];
|
|
213
|
+
for (const check of CHECKS) check(input, diagnostics);
|
|
214
|
+
return diagnostics.sort(
|
|
215
|
+
(left, right) => left.code.localeCompare(right.code) || (left.lessonId ?? "").localeCompare(right.lessonId ?? "") || (left.key ?? "").localeCompare(right.key ?? "") || (left.glyph ?? "").localeCompare(right.glyph ?? "") || left.message.localeCompare(right.message)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
function consistencyCounts(diagnostics) {
|
|
219
|
+
const counts = {
|
|
220
|
+
"glyph-unique-in-corpus": 0,
|
|
221
|
+
"notation-units": 0,
|
|
222
|
+
"units-vocab": 0,
|
|
223
|
+
"convention-single-definition": 0,
|
|
224
|
+
"numerals-tagged": 0,
|
|
225
|
+
"weak-local": 0,
|
|
226
|
+
"notation-source-locator": 0
|
|
227
|
+
};
|
|
228
|
+
for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
|
|
229
|
+
return counts;
|
|
230
|
+
}
|
|
231
|
+
export {
|
|
232
|
+
consistencyCounts,
|
|
233
|
+
runConsistencyChecks
|
|
234
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CurriculumCatalog } from '../curriculum/validation.js';
|
|
2
|
+
import { NotationRegistry, NotationDiagnostic } from './types.js';
|
|
3
|
+
|
|
4
|
+
declare function validateNotationAlignment(registry: NotationRegistry, catalog: CurriculumCatalog): readonly NotationDiagnostic[];
|
|
5
|
+
declare function assertValidNotationAlignment(registry: NotationRegistry, catalog: CurriculumCatalog): void;
|
|
6
|
+
|
|
7
|
+
export { assertValidNotationAlignment, validateNotationAlignment };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { compareDiagnostics } from "./references";
|
|
2
|
+
function validateNotationAlignment(registry, catalog) {
|
|
3
|
+
const diagnostics = [];
|
|
4
|
+
const competencies = new Map(
|
|
5
|
+
catalog.competencies.map((entry) => [
|
|
6
|
+
entry.id,
|
|
7
|
+
entry
|
|
8
|
+
])
|
|
9
|
+
);
|
|
10
|
+
const lessons = new Map(catalog.lessons.map((entry) => [entry.id, entry]));
|
|
11
|
+
const sources = new Map(catalog.sources.map((entry) => [entry.id, entry]));
|
|
12
|
+
const trackPositions = buildTrackPositions(catalog, diagnostics);
|
|
13
|
+
for (const definition of registry.definitions) {
|
|
14
|
+
validateSources(definition, sources, diagnostics);
|
|
15
|
+
const alignment = definition.alignment;
|
|
16
|
+
if (alignment.kind === "general") continue;
|
|
17
|
+
const competency = competencies.get(alignment.introducedByCompetency);
|
|
18
|
+
const lesson = lessons.get(alignment.introducedInLesson);
|
|
19
|
+
if (!competency) {
|
|
20
|
+
diagnostics.push(
|
|
21
|
+
error(
|
|
22
|
+
"alignment-unknown-competency",
|
|
23
|
+
`${definition.id} is aligned to unknown competency ${alignment.introducedByCompetency}`,
|
|
24
|
+
definition
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!lesson) {
|
|
29
|
+
diagnostics.push(
|
|
30
|
+
error(
|
|
31
|
+
"alignment-unknown-lesson",
|
|
32
|
+
`${definition.id} is introduced in unknown lesson ${alignment.introducedInLesson}`,
|
|
33
|
+
definition
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!lesson.teaches.includes(alignment.introducedByCompetency)) {
|
|
39
|
+
diagnostics.push(
|
|
40
|
+
error(
|
|
41
|
+
"alignment-introduction",
|
|
42
|
+
`${definition.id} names ${alignment.introducedInLesson} as its introduction, but that lesson does not teach ${alignment.introducedByCompetency}`,
|
|
43
|
+
definition
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (definition.kind === "local" && alignment.introducedInLesson !== definition.lessonId) {
|
|
48
|
+
diagnostics.push(
|
|
49
|
+
error(
|
|
50
|
+
"alignment-introduction",
|
|
51
|
+
`${definition.id} is page-local to ${definition.lessonId} but declares its introduction in ${alignment.introducedInLesson}`,
|
|
52
|
+
definition
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
validateIntroductionOrder(
|
|
57
|
+
definition,
|
|
58
|
+
alignment.introducedInLesson,
|
|
59
|
+
registry,
|
|
60
|
+
trackPositions,
|
|
61
|
+
diagnostics
|
|
62
|
+
);
|
|
63
|
+
if (definition.status === "reviewed" && (lesson.status !== "reviewed" || competency?.editorialStatus !== "reviewed")) {
|
|
64
|
+
diagnostics.push(
|
|
65
|
+
error(
|
|
66
|
+
"alignment-review-state",
|
|
67
|
+
`${definition.id} is reviewed while its owning lesson or competency is not reviewed`,
|
|
68
|
+
definition
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return diagnostics.sort(compareDiagnostics);
|
|
74
|
+
}
|
|
75
|
+
function assertValidNotationAlignment(registry, catalog) {
|
|
76
|
+
const errors = validateNotationAlignment(registry, catalog).filter(
|
|
77
|
+
(diagnostic) => diagnostic.severity === "error"
|
|
78
|
+
);
|
|
79
|
+
if (errors.length === 0) return;
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Invalid notation alignment:
|
|
82
|
+
${errors.map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.message}`).join("\n")}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function buildTrackPositions(catalog, diagnostics) {
|
|
86
|
+
const positions = /* @__PURE__ */ new Map();
|
|
87
|
+
for (const track of catalog.tracks) {
|
|
88
|
+
const trackMap = /* @__PURE__ */ new Map();
|
|
89
|
+
track.lessons.forEach((lessonId, index) => trackMap.set(lessonId, index));
|
|
90
|
+
positions.set(track.id, trackMap);
|
|
91
|
+
}
|
|
92
|
+
if (positions.size === 0 && catalog.lessons.length > 0) {
|
|
93
|
+
diagnostics.push({
|
|
94
|
+
code: "alignment-unavailable",
|
|
95
|
+
severity: "error",
|
|
96
|
+
message: "notation introduction order cannot be checked without a track"
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return positions;
|
|
100
|
+
}
|
|
101
|
+
function validateIntroductionOrder(definition, introductionLessonId, registry, trackPositions, diagnostics) {
|
|
102
|
+
const usedIn = registry.backlinks.filter((backlink) => backlink.definitionId === definition.id).map((backlink) => backlink.lessonId);
|
|
103
|
+
for (const [trackId, positions] of trackPositions) {
|
|
104
|
+
const introductionPosition = positions.get(introductionLessonId);
|
|
105
|
+
for (const lessonId of usedIn) {
|
|
106
|
+
const usePosition = positions.get(lessonId);
|
|
107
|
+
if (usePosition === void 0) continue;
|
|
108
|
+
if (introductionPosition === void 0) {
|
|
109
|
+
diagnostics.push(
|
|
110
|
+
error(
|
|
111
|
+
"alignment-unavailable",
|
|
112
|
+
`${definition.id} is used by ${lessonId} in track ${trackId}, but its declared introduction ${introductionLessonId} is absent from that track`,
|
|
113
|
+
definition,
|
|
114
|
+
lessonId
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
} else if (usePosition < introductionPosition) {
|
|
118
|
+
diagnostics.push(
|
|
119
|
+
error(
|
|
120
|
+
"alignment-introduction",
|
|
121
|
+
`${definition.id} is used by ${lessonId} before its declared introduction ${introductionLessonId} in track ${trackId}`,
|
|
122
|
+
definition,
|
|
123
|
+
lessonId
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function validateSources(definition, sources, diagnostics) {
|
|
131
|
+
for (const sourceId of definition.sources) {
|
|
132
|
+
const source = sources.get(sourceId);
|
|
133
|
+
if (!source) {
|
|
134
|
+
diagnostics.push(
|
|
135
|
+
error(
|
|
136
|
+
"alignment-unavailable",
|
|
137
|
+
`${definition.id} cites unknown source ${sourceId}`,
|
|
138
|
+
definition
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
} else if (definition.status === "reviewed" && source.editorialStatus !== "reviewed") {
|
|
142
|
+
diagnostics.push(
|
|
143
|
+
error(
|
|
144
|
+
"alignment-review-state",
|
|
145
|
+
`${definition.id} is reviewed while source ${sourceId} is ${source.editorialStatus}`,
|
|
146
|
+
definition
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function error(code, message, definition, lessonId) {
|
|
153
|
+
return {
|
|
154
|
+
code,
|
|
155
|
+
severity: "error",
|
|
156
|
+
message,
|
|
157
|
+
key: definition.key,
|
|
158
|
+
definitionId: definition.id,
|
|
159
|
+
lessonId: lessonId ?? (definition.kind === "local" ? definition.lessonId : void 0),
|
|
160
|
+
source: definition.source
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
assertValidNotationAlignment,
|
|
165
|
+
validateNotationAlignment
|
|
166
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Equation-identity validation (Step D7), run inside `compileManifest()`.
|
|
3
|
+
*
|
|
4
|
+
* - `eq-duplicate-key` (Tier 1, error): an `eq:` key is unique per lesson.
|
|
5
|
+
* - `eq-ref-resolves` (Tier 1, error): every `[[eq-key]]` / `[[slug#eq-key]]`
|
|
6
|
+
* points at a display equation that carries `\label{eq:key}`.
|
|
7
|
+
* - `eq-key-unused` (Tier 3, warning): a labelled equation never referenced.
|
|
8
|
+
*
|
|
9
|
+
* The remark pass enforces the same rules on the render path; this gives the
|
|
10
|
+
* fast `pnpm validate:content` the same coverage and feeds the manifest.
|
|
11
|
+
*/
|
|
12
|
+
interface EquationLabelRecord {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly number: string;
|
|
15
|
+
readonly lessonId: string;
|
|
16
|
+
readonly slug: string;
|
|
17
|
+
readonly section: number;
|
|
18
|
+
readonly indexInSection: number;
|
|
19
|
+
}
|
|
20
|
+
interface EquationRefRecord {
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly lessonId: string;
|
|
23
|
+
/** Target lesson slug for a cross-page reference; `undefined` = same page. */
|
|
24
|
+
readonly targetSlug?: string;
|
|
25
|
+
}
|
|
26
|
+
type EquationDiagnosticCode = 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
|
|
27
|
+
interface EquationDiagnostic {
|
|
28
|
+
readonly code: EquationDiagnosticCode;
|
|
29
|
+
readonly severity: 'error' | 'warning';
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly file?: string;
|
|
32
|
+
readonly lessonId?: string;
|
|
33
|
+
readonly key?: string;
|
|
34
|
+
}
|
|
35
|
+
interface EquationValidation {
|
|
36
|
+
/** Every labelled equation, sorted by lesson then appearance. */
|
|
37
|
+
readonly labels: readonly EquationLabelRecord[];
|
|
38
|
+
/** `slug -> { eqKey: number }` — the render path's cross-page lookup table. */
|
|
39
|
+
readonly numbersBySlug: Record<string, Record<string, string>>;
|
|
40
|
+
readonly diagnostics: readonly EquationDiagnostic[];
|
|
41
|
+
}
|
|
42
|
+
interface EquationLessonInput {
|
|
43
|
+
readonly lessonId: string;
|
|
44
|
+
readonly slug: string;
|
|
45
|
+
readonly file: string;
|
|
46
|
+
readonly body: string;
|
|
47
|
+
}
|
|
48
|
+
declare function validateEquations(lessons: readonly EquationLessonInput[]): EquationValidation;
|
|
49
|
+
|
|
50
|
+
export { type EquationDiagnostic, type EquationDiagnosticCode, type EquationLabelRecord, type EquationLessonInput, type EquationRefRecord, type EquationValidation, validateEquations };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { parseEquationRef, scanEquationLabels } from "./equations.mjs";
|
|
2
|
+
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
3
|
+
function stripCode(text) {
|
|
4
|
+
return text.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
|
|
5
|
+
}
|
|
6
|
+
function bodyEquationRefs(lesson) {
|
|
7
|
+
const refs = [];
|
|
8
|
+
for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
|
|
9
|
+
const parsed = parseEquationRef((match[1] ?? "").trim());
|
|
10
|
+
if (!parsed) continue;
|
|
11
|
+
refs.push({
|
|
12
|
+
key: parsed.key,
|
|
13
|
+
lessonId: lesson.lessonId,
|
|
14
|
+
...parsed.lesson ? { targetSlug: parsed.lesson } : {}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return refs;
|
|
18
|
+
}
|
|
19
|
+
function validateEquations(lessons) {
|
|
20
|
+
const labels = [];
|
|
21
|
+
const diagnostics = [];
|
|
22
|
+
const numbersBySlug = {};
|
|
23
|
+
for (const lesson of [...lessons].sort(
|
|
24
|
+
(a, b) => a.lessonId.localeCompare(b.lessonId)
|
|
25
|
+
)) {
|
|
26
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27
|
+
const table = {};
|
|
28
|
+
for (const raw of scanEquationLabels(lesson.body)) {
|
|
29
|
+
if (seen.has(raw.key)) {
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
code: "eq-duplicate-key",
|
|
32
|
+
severity: "error",
|
|
33
|
+
message: `${lesson.lessonId} labels two display equations eq:${raw.key}; an eq: key is unique per lesson`,
|
|
34
|
+
file: lesson.file,
|
|
35
|
+
lessonId: lesson.lessonId,
|
|
36
|
+
key: raw.key
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
seen.add(raw.key);
|
|
41
|
+
table[raw.key] = raw.number;
|
|
42
|
+
labels.push({
|
|
43
|
+
key: raw.key,
|
|
44
|
+
number: raw.number,
|
|
45
|
+
lessonId: lesson.lessonId,
|
|
46
|
+
slug: lesson.slug,
|
|
47
|
+
section: raw.section,
|
|
48
|
+
indexInSection: raw.indexInSection
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
numbersBySlug[lesson.slug] = table;
|
|
52
|
+
}
|
|
53
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
54
|
+
for (const lesson of lessons) {
|
|
55
|
+
const slug = lesson.slug;
|
|
56
|
+
for (const ref of bodyEquationRefs(lesson)) {
|
|
57
|
+
const targetSlug = ref.targetSlug ?? slug;
|
|
58
|
+
const number = numbersBySlug[targetSlug]?.[ref.key];
|
|
59
|
+
referenced.add(`${targetSlug}#${ref.key}`);
|
|
60
|
+
if (number === void 0) {
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
code: "eq-ref-resolves",
|
|
63
|
+
severity: "error",
|
|
64
|
+
message: ref.targetSlug ? `${lesson.lessonId} references [[${ref.targetSlug}#eq-${ref.key}]] but that lesson has no display equation labelled eq:${ref.key}` : `${lesson.lessonId} references [[eq-${ref.key}]] but no display equation on the page carries \\label{eq:${ref.key}}`,
|
|
65
|
+
file: lesson.file,
|
|
66
|
+
lessonId: lesson.lessonId,
|
|
67
|
+
key: ref.key
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const label of labels) {
|
|
73
|
+
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
74
|
+
diagnostics.push({
|
|
75
|
+
code: "eq-key-unused",
|
|
76
|
+
severity: "warning",
|
|
77
|
+
message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
|
|
78
|
+
file: label.slug,
|
|
79
|
+
lessonId: label.lessonId,
|
|
80
|
+
key: label.key
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
diagnostics.sort(
|
|
84
|
+
(a, b) => a.code.localeCompare(b.code) || (a.lessonId ?? "").localeCompare(b.lessonId ?? "") || (a.key ?? "").localeCompare(b.key ?? "") || a.message.localeCompare(b.message)
|
|
85
|
+
);
|
|
86
|
+
return { labels, numbersBySlug, diagnostics };
|
|
87
|
+
}
|
|
88
|
+
export {
|
|
89
|
+
validateEquations
|
|
90
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Equation identity (Step D7) — shared, framework-free helpers.
|
|
3
|
+
*
|
|
4
|
+
* An author marks a display equation by putting a trailing `\label{eq:<key>}`
|
|
5
|
+
* inside its `$$…$$` block (inside math, so the `:` is safe and the form
|
|
6
|
+
* matches amsmath). The compiler assigns the *visible* number from appearance
|
|
7
|
+
* order, scoped to the enclosing `##` section (`(2.4)` = fourth keyed equation
|
|
8
|
+
* under the second section). The number is never authored; unkeyed display
|
|
9
|
+
* equations render clean and stay un-referenceable. Prose refers to an
|
|
10
|
+
* equation with the reserved `eq-` prefix (a bare `:` in `[[…]]` prose would
|
|
11
|
+
* be eaten by `remark-directive`):
|
|
12
|
+
*
|
|
13
|
+
* [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
|
|
14
|
+
* [[foundations/present-value#eq-pv]] cross page → number from the manifest
|
|
15
|
+
*
|
|
16
|
+
* `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
|
|
17
|
+
* MUST agree — `tests/notation/equations.test.ts` checks that on the real
|
|
18
|
+
* corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
|
|
19
|
+
* numbering algorithm.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
|
|
23
|
+
export const EQ_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
24
|
+
|
|
25
|
+
/** A `\label{eq:<key>}` anywhere in a math source. Global; `key` in group 1. */
|
|
26
|
+
export const EQ_LABEL_PATTERN =
|
|
27
|
+
/\\label\{\s*eq:([a-z0-9]+(?:-[a-z0-9]+)*)\s*\}/g;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A `[[ … ]]` payload that targets an equation: `eq-<key>` on the current page,
|
|
31
|
+
* or `<lesson-slug>#eq-<key>` across pages. `lesson` (group 1) is a `/`-slug.
|
|
32
|
+
* The `eq-` prefix is reserved — a notation key may not start with it.
|
|
33
|
+
*/
|
|
34
|
+
export const EQ_REF_PATTERN =
|
|
35
|
+
/^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?eq-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
36
|
+
|
|
37
|
+
/** `{ lesson?, key }` when `raw` is an equation reference, else `undefined`. */
|
|
38
|
+
export function parseEquationRef(raw) {
|
|
39
|
+
const match = EQ_REF_PATTERN.exec(String(raw).trim());
|
|
40
|
+
if (!match) return undefined;
|
|
41
|
+
return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Remove every `\label{eq:…}` (and the whitespace it leaves) from a math source. */
|
|
45
|
+
export function stripEquationLabels(latex) {
|
|
46
|
+
return String(latex)
|
|
47
|
+
.replace(EQ_LABEL_PATTERN, '')
|
|
48
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
49
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
50
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The visible number for the `index`-th keyed equation under `section`. */
|
|
55
|
+
function equationNumber(section, index) {
|
|
56
|
+
return section > 0 ? `${section}.${index}` : String(index);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stripFencedCode(markdown) {
|
|
60
|
+
return String(markdown)
|
|
61
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
62
|
+
.replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
63
|
+
.replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every keyed display equation in a Markdown body, in appearance order, with
|
|
68
|
+
* its assigned number. `$$…$$` blocks only; `##` headings between them advance
|
|
69
|
+
* the section counter.
|
|
70
|
+
*/
|
|
71
|
+
export function scanEquationLabels(markdown) {
|
|
72
|
+
const text = stripFencedCode(markdown);
|
|
73
|
+
const tokens = [];
|
|
74
|
+
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
75
|
+
const display = /\$\$([\s\S]+?)\$\$/g;
|
|
76
|
+
for (const match of text.matchAll(heading)) {
|
|
77
|
+
tokens.push({ kind: 'section', index: match.index });
|
|
78
|
+
}
|
|
79
|
+
for (const match of text.matchAll(display)) {
|
|
80
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
81
|
+
const label = EQ_LABEL_PATTERN.exec(match[1]);
|
|
82
|
+
if (label) {
|
|
83
|
+
tokens.push({ kind: 'equation', index: match.index, key: label[1] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
tokens.sort((a, b) => a.index - b.index);
|
|
87
|
+
|
|
88
|
+
const labels = [];
|
|
89
|
+
let section = 0;
|
|
90
|
+
let indexInSection = 0;
|
|
91
|
+
for (const token of tokens) {
|
|
92
|
+
if (token.kind === 'section') {
|
|
93
|
+
section += 1;
|
|
94
|
+
indexInSection = 0;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
indexInSection += 1;
|
|
98
|
+
labels.push({
|
|
99
|
+
key: token.key,
|
|
100
|
+
number: equationNumber(section, indexInSection),
|
|
101
|
+
section,
|
|
102
|
+
indexInSection,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return labels;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The mdast equivalent of `scanEquationLabels`: walk the tree in document
|
|
110
|
+
* order, advancing the section counter on every depth-2 heading and emitting a
|
|
111
|
+
* label for every `math` node whose source carries `\label{eq:…}`.
|
|
112
|
+
*/
|
|
113
|
+
export function collectEquationLabels(tree) {
|
|
114
|
+
const labels = [];
|
|
115
|
+
let section = 0;
|
|
116
|
+
let indexInSection = 0;
|
|
117
|
+
|
|
118
|
+
const walk = (node) => {
|
|
119
|
+
if (!node || typeof node !== 'object') return;
|
|
120
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
121
|
+
section += 1;
|
|
122
|
+
indexInSection = 0;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (node.type === 'math') {
|
|
126
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
127
|
+
const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
|
|
128
|
+
if (label) {
|
|
129
|
+
indexInSection += 1;
|
|
130
|
+
labels.push({
|
|
131
|
+
key: label[1],
|
|
132
|
+
number: equationNumber(section, indexInSection),
|
|
133
|
+
section,
|
|
134
|
+
indexInSection,
|
|
135
|
+
node,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(node.children)) node.children.forEach(walk);
|
|
141
|
+
};
|
|
142
|
+
walk(tree);
|
|
143
|
+
return labels;
|
|
144
|
+
}
|