explico 0.1.0 → 0.2.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 (54) hide show
  1. package/dist/compiler/cli.d.ts +1 -0
  2. package/dist/compiler/cli.js +1 -1
  3. package/dist/compiler/collections.js +30 -3
  4. package/dist/compiler/manifest.d.ts +10 -1
  5. package/dist/compiler/manifest.js +28 -12
  6. package/dist/compiler/resolution-report.d.ts +1 -0
  7. package/dist/compiler/resolution-report.js +2 -2
  8. package/dist/reference/base-library.d.mts +17 -0
  9. package/dist/reference/citation-format.d.mts +55 -0
  10. package/dist/reference/consistency.d.ts +1 -1
  11. package/dist/reference/consistency.js +94 -3
  12. package/dist/reference/curriculum-alignment.js +1 -1
  13. package/dist/reference/equations.d.mts +84 -0
  14. package/dist/reference/equations.mjs +75 -39
  15. package/dist/reference/gate-math.d.ts +5 -4
  16. package/dist/reference/gate-math.js +73 -2
  17. package/dist/reference/gloss.d.ts +87 -0
  18. package/dist/reference/gloss.js +59 -0
  19. package/dist/reference/katex-options.d.mts +33 -0
  20. package/dist/reference/lab-math-scope.js +1 -1
  21. package/dist/reference/math-glyphs.d.mts +46 -0
  22. package/dist/reference/registry.js +26 -1
  23. package/dist/reference/rehype-fail-katex-errors.d.mts +6 -0
  24. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  25. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  26. package/dist/reference/remark-citation.d.mts +6 -0
  27. package/dist/reference/remark-notation.d.mts +49 -0
  28. package/dist/reference/remark-notation.mjs +30 -24
  29. package/dist/reference/types.d.ts +25 -2
  30. package/dist/session/user-context.js +1 -1
  31. package/package.json +2 -2
  32. package/src/compiler/collections.ts +40 -0
  33. package/src/compiler/manifest.ts +31 -0
  34. package/src/components/HoverPanel.astro +42 -0
  35. package/src/components/citation/CitationLayer.astro +1 -0
  36. package/src/components/hover-panel.ts +71 -5
  37. package/src/components/notation/NotationFormula.astro +154 -0
  38. package/src/components/notation/NotationGlossary.astro +60 -0
  39. package/src/components/notation/NotationLayer.astro +248 -103
  40. package/src/components/notation/render-notation-math.ts +55 -0
  41. package/src/components/popup-stack.ts +345 -0
  42. package/src/components/starlight/EquationEnhancer.astro +20 -5
  43. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  44. package/src/components/starlight/LessonFooter.astro +4 -0
  45. package/src/content-config.ts +38 -0
  46. package/src/reference/consistency.ts +154 -1
  47. package/src/reference/equations.mjs +75 -39
  48. package/src/reference/gate-math.ts +104 -7
  49. package/src/reference/gloss.ts +153 -0
  50. package/src/reference/registry.ts +43 -0
  51. package/src/reference/rehype-heading-anchors.mjs +144 -0
  52. package/src/reference/remark-notation.mjs +30 -24
  53. package/src/reference/types.ts +26 -1
  54. package/src/styles/global.css +84 -2
@@ -42,7 +42,10 @@ export type ConsistencyCode =
42
42
  | 'convention-single-definition'
43
43
  | 'numerals-tagged'
44
44
  | 'weak-local'
45
- | 'notation-source-locator';
45
+ | 'notation-source-locator'
46
+ | 'gloss-wants-promoting'
47
+ | 'card-wants-demoting'
48
+ | 'gloss-name-shape';
46
49
 
47
50
  export interface ConsistencyDiagnostic {
48
51
  readonly code: ConsistencyCode;
@@ -325,6 +328,150 @@ function checkNotationSourceLocator(
325
328
  }
326
329
  }
327
330
 
331
+ /**
332
+ * The two-tier vocabulary needs both directions kept honest (D15).
333
+ *
334
+ * A gloss is cheap on purpose, and cheapness invites copying: the same symbol
335
+ * and name glossed on three cards is a shared meaning that should be a card,
336
+ * reviewed once, with sources and a curriculum home. `GLOSS_PROMOTION_LIMIT`
337
+ * is where "a letter this formula needs" stops being a plausible reading.
338
+ */
339
+ const GLOSS_PROMOTION_LIMIT = 3;
340
+
341
+ function checkGlossWantsPromoting(
342
+ input: ConsistencyInput,
343
+ out: ConsistencyDiagnostic[],
344
+ ): void {
345
+ const owners = new Map<string, { label: string; keys: Set<string> }>();
346
+ const record = (
347
+ ownerKey: string,
348
+ gloss: { notation: string; label: string },
349
+ ) => {
350
+ const id = `${gloss.notation}\u0000${gloss.label.toLowerCase()}`;
351
+ const current = owners.get(id) ?? { label: gloss.label, keys: new Set() };
352
+ current.keys.add(ownerKey);
353
+ owners.set(id, current);
354
+ };
355
+
356
+ for (const definition of input.notationInput.sharedDefinitions) {
357
+ for (const gloss of definition.glosses) record(definition.key, gloss);
358
+ }
359
+ for (const lesson of input.notationInput.lessons) {
360
+ for (const definition of lesson.localDefinitions) {
361
+ for (const gloss of definition.glosses) record(definition.key, gloss);
362
+ }
363
+ }
364
+
365
+ for (const [id, { label, keys }] of [...owners].sort(([a], [b]) =>
366
+ a.localeCompare(b),
367
+ )) {
368
+ if (keys.size < GLOSS_PROMOTION_LIMIT) continue;
369
+ const glyph = id.split('\u0000')[0] ?? '';
370
+ if (input.lintIgnore.matches('gloss-wants-promoting', glyph)) continue;
371
+ out.push({
372
+ code: 'gloss-wants-promoting',
373
+ severity: 'warning',
374
+ glyph,
375
+ message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(', ')}); a meaning reused this widely wants promoting to a card`,
376
+ });
377
+ }
378
+ }
379
+
380
+ /**
381
+ * The other direction: a card that never grew past its name is carrying the
382
+ * full weight of the card tier — a glossary entry, a curriculum node, a review
383
+ * obligation — for something a gloss would say as well.
384
+ */
385
+ const CARD_BODY_MINIMUM_WORDS = 25;
386
+
387
+ function checkCardWantsDemoting(
388
+ input: ConsistencyInput,
389
+ out: ConsistencyDiagnostic[],
390
+ ): void {
391
+ const used = new Set(
392
+ input.registry.backlinks.map((backlink) => backlink.key),
393
+ );
394
+
395
+ for (const definition of input.notationInput.sharedDefinitions) {
396
+ if (definition.formula !== undefined) continue;
397
+ if (definition.sources.length > 0) continue;
398
+ if (used.has(definition.key)) continue;
399
+ const words = stripCode(definition.body)
400
+ .split(/\s+/)
401
+ .filter((word) => word.length > 0).length;
402
+ if (words >= CARD_BODY_MINIMUM_WORDS) continue;
403
+ if (input.lintIgnore.matches('card-wants-demoting', definition.key)) {
404
+ continue;
405
+ }
406
+ out.push({
407
+ code: 'card-wants-demoting',
408
+ severity: 'warning',
409
+ key: definition.key,
410
+ file: definition.source.file,
411
+ count: words,
412
+ message: `notation card ${definition.key} has no formula, no sources, a ${words}-word body, and no lesson uses it; it carries no more than a name — a gloss on the entry that needs it may be the right home`,
413
+ });
414
+ }
415
+ }
416
+
417
+ /**
418
+ * A gloss `name` is the name of a symbol, not a description of it — the
419
+ * description tier is the card. The schema already blocks the unambiguous
420
+ * failure (LaTeX in the name); this catches the softer drift toward prose.
421
+ *
422
+ * The word limit is measured, not guessed: across the 62 shared cards the
423
+ * longest name is six words (`cds protection buyer net present value`), and
424
+ * that is the most complicated quantity in the course. A gloss names a *letter*
425
+ * inside one formula, so it should be shorter still. Seven or more words means
426
+ * the author is describing rather than naming — a warning, never a block,
427
+ * because a genuinely complicated quantity may earn the exception through
428
+ * `lint-ignore.yml`.
429
+ */
430
+ const GLOSS_NAME_WORD_LIMIT = 6;
431
+ const GLOSS_NAME_PROSE = /[.;]|\b(?:which|whose|that is|such that)\b/i;
432
+
433
+ function checkGlossNameShape(
434
+ input: ConsistencyInput,
435
+ out: ConsistencyDiagnostic[],
436
+ ): void {
437
+ const check = (
438
+ gloss: { key: string; label: string },
439
+ file: string,
440
+ lessonId?: string,
441
+ ) => {
442
+ const words = gloss.label.trim().split(/\s+/).filter(Boolean);
443
+ const reason =
444
+ words.length > GLOSS_NAME_WORD_LIMIT
445
+ ? `${words.length} words`
446
+ : GLOSS_NAME_PROSE.test(gloss.label)
447
+ ? 'sentence punctuation or a relative clause'
448
+ : undefined;
449
+ if (reason === undefined) return;
450
+ if (input.lintIgnore.matches('gloss-name-shape', gloss.key)) return;
451
+ out.push({
452
+ code: 'gloss-name-shape',
453
+ severity: 'warning',
454
+ key: gloss.key,
455
+ file,
456
+ ...(lessonId ? { lessonId } : {}),
457
+ message: `gloss ${gloss.key} is named ${JSON.stringify(gloss.label)} (${reason}); a gloss name is a short noun phrase naming the symbol, not a description of it`,
458
+ });
459
+ };
460
+
461
+ for (const definition of input.notationInput.sharedDefinitions) {
462
+ for (const gloss of definition.glosses) {
463
+ check(gloss, definition.source.file);
464
+ }
465
+ }
466
+ for (const lesson of input.notationInput.lessons) {
467
+ for (const definition of lesson.localDefinitions) {
468
+ for (const gloss of definition.glosses) {
469
+ check(gloss, definition.source.file, lesson.lessonId);
470
+ }
471
+ }
472
+ }
473
+ }
474
+
328
475
  const CHECKS: ((
329
476
  input: ConsistencyInput,
330
477
  out: ConsistencyDiagnostic[],
@@ -335,6 +482,9 @@ const CHECKS: ((
335
482
  checkNumeralsTagged,
336
483
  checkWeakLocal,
337
484
  checkNotationSourceLocator,
485
+ checkGlossWantsPromoting,
486
+ checkCardWantsDemoting,
487
+ checkGlossNameShape,
338
488
  ];
339
489
 
340
490
  /**
@@ -369,6 +519,9 @@ export function consistencyCounts(
369
519
  'numerals-tagged': 0,
370
520
  'weak-local': 0,
371
521
  'notation-source-locator': 0,
522
+ 'gloss-wants-promoting': 0,
523
+ 'card-wants-demoting': 0,
524
+ 'gloss-name-shape': 0,
372
525
  } satisfies Record<ConsistencyCode, number>;
373
526
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
374
527
  return counts;
@@ -1,22 +1,24 @@
1
1
  /**
2
- * Equation identity (Step D7) — shared, framework-free helpers.
2
+ * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
3
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`):
4
+ * Every display equation (`$$…$$`) in a lesson is numbered and given an
5
+ * `id`, from appearance order scoped to the enclosing `##` section (`(2.4)` =
6
+ * fourth display equation under the second section). An author additionally
7
+ * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
8
+ * block (inside math, so the `:` is safe and the form matches amsmath) when
9
+ * they want a *stable* anchor that survives edits and cross-page references:
12
10
  *
13
11
  * [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
14
12
  * [[foundations/present-value#eq-pv]] cross page → number from the manifest
15
13
  *
14
+ * An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
15
+ * deep-linked, but only a keyed equation is referenceable by name.
16
+ *
16
17
  * `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
17
18
  * MUST agree — `tests/notation/equations.test.ts` checks that on the real
18
19
  * corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
19
- * numbering algorithm.
20
+ * numbering algorithm. `collectDisplayEquations` is the superset the render
21
+ * path uses to number and anchor every display equation, keyed or not.
20
22
  */
21
23
 
22
24
  /** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
@@ -51,11 +53,16 @@ export function stripEquationLabels(latex) {
51
53
  .trim();
52
54
  }
53
55
 
54
- /** The visible number for the `index`-th keyed equation under `section`. */
56
+ /** The visible number for the `index`-th display equation under `section`. */
55
57
  function equationNumber(section, index) {
56
58
  return section > 0 ? `${section}.${index}` : String(index);
57
59
  }
58
60
 
61
+ /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
62
+ export function equationAnchorId({ key, number }) {
63
+ return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
64
+ }
65
+
59
66
  function stripFencedCode(markdown) {
60
67
  return String(markdown)
61
68
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
@@ -63,13 +70,29 @@ function stripFencedCode(markdown) {
63
70
  .replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
64
71
  }
65
72
 
73
+ /**
74
+ * Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
75
+ * so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
76
+ * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
77
+ * outermost match wins, so nested components are blanked in one pass;
78
+ * newlines are kept so line/section structure is unchanged.
79
+ */
80
+ function stripJsxComponents(markdown) {
81
+ return String(markdown).replace(
82
+ /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
83
+ (block) => block.replace(/[^\n]/g, ' '),
84
+ );
85
+ }
86
+
66
87
  /**
67
88
  * Every keyed display equation in a Markdown body, in appearance order, with
68
89
  * its assigned number. `$$…$$` blocks only; `##` headings between them advance
69
- * the section counter.
90
+ * the section counter. Every display block advances the in-section index, so a
91
+ * keyed equation's number reflects its position among *all* display equations,
92
+ * not only the keyed ones.
70
93
  */
71
94
  export function scanEquationLabels(markdown) {
72
- const text = stripFencedCode(markdown);
95
+ const text = stripJsxComponents(stripFencedCode(markdown));
73
96
  const tokens = [];
74
97
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
75
98
  const display = /\$\$([\s\S]+?)\$\$/g;
@@ -79,9 +102,11 @@ export function scanEquationLabels(markdown) {
79
102
  for (const match of text.matchAll(display)) {
80
103
  EQ_LABEL_PATTERN.lastIndex = 0;
81
104
  const label = EQ_LABEL_PATTERN.exec(match[1]);
82
- if (label) {
83
- tokens.push({ kind: 'equation', index: match.index, key: label[1] });
84
- }
105
+ tokens.push({
106
+ kind: 'equation',
107
+ index: match.index,
108
+ key: label ? label[1] : null,
109
+ });
85
110
  }
86
111
  tokens.sort((a, b) => a.index - b.index);
87
112
 
@@ -95,6 +120,7 @@ export function scanEquationLabels(markdown) {
95
120
  continue;
96
121
  }
97
122
  indexInSection += 1;
123
+ if (token.key == null) continue;
98
124
  labels.push({
99
125
  key: token.key,
100
126
  number: equationNumber(section, indexInSection),
@@ -106,39 +132,49 @@ export function scanEquationLabels(markdown) {
106
132
  }
107
133
 
108
134
  /**
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:…}`.
135
+ * The mdast equivalent of `scanEquationLabels` for *every* display equation:
136
+ * walk the tree in document order, advancing the section counter on every
137
+ * depth-2 heading and emitting a record for every `math` node. `key` is the
138
+ * `\label{eq:…}` payload when present, else `null`.
112
139
  */
113
- export function collectEquationLabels(tree) {
114
- const labels = [];
140
+ export function collectDisplayEquations(tree) {
141
+ const equations = [];
115
142
  let section = 0;
116
143
  let indexInSection = 0;
117
144
 
118
- const walk = (node) => {
119
- if (!node || typeof node !== 'object') return;
145
+ // Only top-level blocks: a `$$…$$` nested in an MDX component (a
146
+ // `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
147
+ // of the lesson's equation sequence, and its container may be collapsed.
148
+ const children = Array.isArray(tree?.children) ? tree.children : [];
149
+ for (const node of children) {
150
+ if (!node || typeof node !== 'object') continue;
120
151
  if (node.type === 'heading' && node.depth === 2) {
121
152
  section += 1;
122
153
  indexInSection = 0;
123
- return;
154
+ continue;
124
155
  }
125
156
  if (node.type === 'math') {
157
+ indexInSection += 1;
126
158
  EQ_LABEL_PATTERN.lastIndex = 0;
127
159
  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;
160
+ equations.push({
161
+ key: label ? label[1] : null,
162
+ number: equationNumber(section, indexInSection),
163
+ section,
164
+ indexInSection,
165
+ node,
166
+ });
139
167
  }
140
- if (Array.isArray(node.children)) node.children.forEach(walk);
141
- };
142
- walk(tree);
143
- return labels;
168
+ }
169
+ return equations;
170
+ }
171
+
172
+ /**
173
+ * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
174
+ * `scanEquationLabels`. Numbers reflect position among all display equations.
175
+ */
176
+ export function collectEquationLabels(tree) {
177
+ return collectDisplayEquations(tree).filter(
178
+ (equation) => equation.key != null,
179
+ );
144
180
  }
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { resolveMathGlyphs, GlyphResolutionError } from './math-glyphs.mjs';
13
13
  import { stripEquationLabels } from './equations.mjs';
14
+ import { formulaGlyphScope, unknownFormulaScopeKeys } from './gloss';
15
+ import type { GlyphScopeEntry } from './gloss';
14
16
  import type {
15
17
  NotationLessonInput,
16
18
  NotationRegistryInput,
@@ -21,10 +23,11 @@ export interface MathGateDiagnostic {
21
23
  readonly context: 'body' | 'formula' | 'assessment';
22
24
  /**
23
25
  * Body / component-slot math blocks the build (the render path needs every
24
- * glyph resolved for the KaTeX trust callback). `formula` and `assessment`
25
- * math enter as warnings (Tier 3) and are promoted once existing content is
26
- * brought into line — they surface pre-existing ambiguities that are a
27
- * separate reviewed content pass (D6/D13/D14), not a D3 codemod.
26
+ * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
27
+ * a formula's glyphs are now rendered live and explorable, so an unresolved
28
+ * one is a hole the reader can see, and the corpus was brought to zero when
29
+ * the tier was promoted. `assessment` math stays a Tier-3 warning until its
30
+ * own reviewed content pass (D6/D13/D14).
28
31
  */
29
32
  readonly severity: 'error' | 'warning';
30
33
  readonly file: string;
@@ -35,7 +38,7 @@ export interface MathGateDiagnostic {
35
38
 
36
39
  const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
37
40
  body: 'error',
38
- formula: 'warning',
41
+ formula: 'error',
39
42
  assessment: 'warning',
40
43
  };
41
44
 
@@ -157,6 +160,51 @@ function resolveInto(
157
160
 
158
161
  const BODY_FIX =
159
162
  'Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.';
163
+ function dedupeScope(scope: readonly GlyphScopeEntry[]): GlyphScopeEntry[] {
164
+ const seen = new Set<string>();
165
+ const out: GlyphScopeEntry[] = [];
166
+ for (const entry of scope) {
167
+ if (seen.has(entry.key)) continue;
168
+ seen.add(entry.key);
169
+ out.push(entry);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * `\explain{key}` in a formula widens the glyph scope without becoming a
176
+ * reference edge (D15), so nothing else checks that the key exists. Report it
177
+ * here: a marker pointing at no card is an authoring mistake, not a silent
178
+ * fall-through to an unresolved glyph.
179
+ */
180
+ function reportUnknownScopeKeys(
181
+ definition: {
182
+ readonly key: string;
183
+ readonly formula?: string;
184
+ readonly glosses: readonly { readonly key: string }[];
185
+ },
186
+ cardKeys: ReadonlySet<string>,
187
+ file: string,
188
+ out: MathGateDiagnostic[],
189
+ ): void {
190
+ const glossKeys = new Set(definition.glosses.map((gloss) => gloss.key));
191
+ for (const key of unknownFormulaScopeKeys(
192
+ definition.formula,
193
+ glossKeys,
194
+ cardKeys,
195
+ )) {
196
+ out.push({
197
+ context: 'formula',
198
+ severity: SEVERITY.formula,
199
+ file,
200
+ token: key,
201
+ message: `${definition.key} formula names unknown notation key ${JSON.stringify(key)}. Name an existing card, or declare the symbol under glosses: on this entry.`,
202
+ });
203
+ }
204
+ }
205
+
206
+ const SHARED_FORMULA_FIX =
207
+ 'Every symbol in a notation entry formula must resolve to the entry itself, one of its glosses:, a card the formula names with \\explain{key}{latex}, or the base library.';
160
208
  const FORMULA_FIX =
161
209
  'Every right-hand-side variable of a notation.local formula must resolve to the entry key, another page symbol, or the base library.';
162
210
  const ASSESSMENT_FIX =
@@ -178,6 +226,18 @@ export function gateContentMath(input: {
178
226
  definition,
179
227
  ]),
180
228
  );
229
+ const sharedCards = new Map<string, GlyphScopeEntry>(
230
+ notationInput.sharedDefinitions.map((definition) => [
231
+ definition.key,
232
+ { key: definition.key, notation: definition.notation },
233
+ ]),
234
+ );
235
+ const cardKeys = new Set<string>([
236
+ ...sharedCards.keys(),
237
+ ...notationInput.lessons.flatMap((lesson) =>
238
+ lesson.localDefinitions.map((definition) => definition.key),
239
+ ),
240
+ ]);
181
241
  const lessonById = new Map(
182
242
  notationInput.lessons.map((lesson) => [lesson.lessonId, lesson]),
183
243
  );
@@ -206,19 +266,56 @@ export function gateContentMath(input: {
206
266
  for (const definition of lesson.localDefinitions) {
207
267
  if (!definition.formula) continue;
208
268
  // `scope` already carries every page-local entry, including this one, so
209
- // the formula's LHS key resolves against itself.
269
+ // the formula's LHS key resolves against itself; its glosses (D15) name
270
+ // the letters the formula introduces and nothing else on the page.
210
271
  resolveInto(
211
272
  definition.formula,
212
- scope,
273
+ dedupeScope([
274
+ ...scope,
275
+ ...definition.glosses.map((gloss) => ({
276
+ key: gloss.key,
277
+ notation: gloss.notation,
278
+ })),
279
+ ]),
213
280
  'formula',
214
281
  file,
215
282
  undefined,
216
283
  FORMULA_FIX,
217
284
  diagnostics,
218
285
  );
286
+ reportUnknownScopeKeys(definition, cardKeys, file, diagnostics);
219
287
  }
220
288
  }
221
289
 
290
+ // Shared cards carry their own rigorous `formula`. It resolves against the
291
+ // entry-scoped table (D15) — the card, its glosses, and the cards the
292
+ // formula names with `\explain` — not the registry-wide table the card's
293
+ // body math uses.
294
+ for (const definition of notationInput.sharedDefinitions) {
295
+ if (!definition.formula) continue;
296
+ resolveInto(
297
+ definition.formula,
298
+ formulaGlyphScope({
299
+ key: definition.key,
300
+ notation: definition.notation,
301
+ formula: definition.formula,
302
+ glosses: definition.glosses,
303
+ cardsByKey: sharedCards,
304
+ }),
305
+ 'formula',
306
+ definition.source.file,
307
+ undefined,
308
+ SHARED_FORMULA_FIX,
309
+ diagnostics,
310
+ );
311
+ reportUnknownScopeKeys(
312
+ definition,
313
+ cardKeys,
314
+ definition.source.file,
315
+ diagnostics,
316
+ );
317
+ }
318
+
222
319
  for (const link of lessonAssessments) {
223
320
  const lesson = lessonById.get(link.lessonId);
224
321
  if (!lesson) continue;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Glosses and formula scope (D15) — the second tier of the notation
3
+ * vocabulary.
4
+ *
5
+ * A **card** is a `content/notation/*.md` entry (or a `notation.local` entry):
6
+ * symbol, name, description, units, sources, curriculum alignment, review
7
+ * state. It gets a glossary card and is a node in the curriculum graph.
8
+ *
9
+ * A **gloss** is declared on a card, in that card's `glosses:` frontmatter,
10
+ * and carries a symbol, a name, and optionally a bare units string. It exists
11
+ * for one reason: so a card's rigorous `formula` can name its own letters
12
+ * without each letter having to become a card. A gloss has no description, no
13
+ * sources, no alignment, no review state, and no glossary card — and, the
14
+ * point of the whole tier, **it creates no curriculum edge**. A card that
15
+ * dragged its formula's letters into the registry would drag them into the
16
+ * lesson bundle of every lesson that uses it, and a foundations lesson would
17
+ * fail `alignment-introduction` on a symbol introduced six lessons later.
18
+ *
19
+ * The editorial convention (not enforced here): a card's formula may introduce
20
+ * glosses; glosses terminate — they have no formula of their own. A symbol
21
+ * that later needs a real definition is **promoted**: delete the gloss line,
22
+ * add `content/notation/<key>.md`. Because a formula spells LaTeX and never a
23
+ * key, nothing else in the corpus changes.
24
+ */
25
+ export interface NotationGlossInput {
26
+ readonly latex: string;
27
+ readonly name: string;
28
+ readonly units?: string;
29
+ }
30
+
31
+ /** A gloss with its derived key and display label. */
32
+ export interface ResolvedGloss {
33
+ /** `<card key>.<slug(name)>` — derived, never authored. */
34
+ readonly key: string;
35
+ readonly notation: string;
36
+ readonly label: string;
37
+ readonly units?: string;
38
+ /** The card that declares it. */
39
+ readonly ownerKey: string;
40
+ }
41
+
42
+ /** One `{key, notation}` row of the flat table `resolveMathGlyphs` consumes. */
43
+ export interface GlyphScopeEntry {
44
+ readonly key: string;
45
+ readonly notation: string;
46
+ }
47
+
48
+ /**
49
+ * The key segment derived from a gloss name: lower-cased, runs of anything
50
+ * that is not a letter or digit collapsed to a single `-`. Returns `undefined`
51
+ * when the name has no usable characters, so the schema can reject it rather
52
+ * than the loader minting an invalid key.
53
+ */
54
+ export function glossKeySegment(name: string): string | undefined {
55
+ const segment = name
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9]+/g, '-')
58
+ .replace(/^-+|-+$/g, '');
59
+ return segment.length > 0 ? segment : undefined;
60
+ }
61
+
62
+ /**
63
+ * Derive a gloss's key. Dotted onto the owning card's key, so a gloss is
64
+ * entry-scoped by construction: two cards may each gloss `T`, and a gloss can
65
+ * never be confused with a card in the flat registry.
66
+ */
67
+ export function glossKey(ownerKey: string, name: string): string {
68
+ const segment = glossKeySegment(name);
69
+ if (segment === undefined) {
70
+ throw new Error(
71
+ `Gloss name ${JSON.stringify(name)} on ${ownerKey} yields no key segment.`,
72
+ );
73
+ }
74
+ return `${ownerKey}.${segment}`;
75
+ }
76
+
77
+ /** Resolve a card's authored `glosses:` list into keyed, labelled entries. */
78
+ export function resolveGlosses(
79
+ ownerKey: string,
80
+ glosses: readonly NotationGlossInput[],
81
+ ): ResolvedGloss[] {
82
+ return glosses.map((gloss) => ({
83
+ key: glossKey(ownerKey, gloss.name),
84
+ notation: gloss.latex,
85
+ label: gloss.name,
86
+ ownerKey,
87
+ ...(gloss.units === undefined ? {} : { units: gloss.units }),
88
+ }));
89
+ }
90
+
91
+ /** `\explain{key}` occurrences in a formula — scope only, never a reference. */
92
+ const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
93
+
94
+ export function formulaExplainKeys(formula: string): string[] {
95
+ EXPLAIN_KEY.lastIndex = 0;
96
+ return [...formula.matchAll(EXPLAIN_KEY)].map((match) => match[1] ?? '');
97
+ }
98
+
99
+ /**
100
+ * The glyph table a card's `formula` resolves against:
101
+ *
102
+ * {the card itself} ∪ {its glosses} ∪ {cards its formula names} ∪ base library
103
+ *
104
+ * Narrower than the registry-wide table a card's *body* math still uses, and
105
+ * narrower than a lesson page's scope. A card named with `\explain{key}` in
106
+ * the formula joins the table for glyph resolution **only**: its existence is
107
+ * checked, but it deliberately becomes no reference edge, because a
108
+ * definitional letter is not a curriculum prerequisite. See the module note.
109
+ */
110
+ export function formulaGlyphScope(input: {
111
+ readonly key: string;
112
+ readonly notation: string;
113
+ readonly formula?: string;
114
+ readonly glosses: readonly ResolvedGloss[];
115
+ /** Every card by key — shared entries, plus page-local ones for a lesson. */
116
+ readonly cardsByKey: ReadonlyMap<string, GlyphScopeEntry>;
117
+ }): GlyphScopeEntry[] {
118
+ const scope: GlyphScopeEntry[] = [
119
+ { key: input.key, notation: input.notation },
120
+ ];
121
+ const seen = new Set([input.key]);
122
+
123
+ for (const gloss of input.glosses) {
124
+ if (seen.has(gloss.key)) continue;
125
+ seen.add(gloss.key);
126
+ scope.push({ key: gloss.key, notation: gloss.notation });
127
+ }
128
+
129
+ for (const key of formulaExplainKeys(input.formula ?? '')) {
130
+ if (seen.has(key)) continue;
131
+ const card = input.cardsByKey.get(key);
132
+ if (!card) continue;
133
+ seen.add(key);
134
+ scope.push({ key: card.key, notation: card.notation });
135
+ }
136
+
137
+ return scope;
138
+ }
139
+
140
+ /**
141
+ * Keys named by a formula that no card defines. The build reports these — an
142
+ * `\explain{…}` pointing at nothing is an authoring mistake, not a silent
143
+ * fallback to an unresolved glyph.
144
+ */
145
+ export function unknownFormulaScopeKeys(
146
+ formula: string | undefined,
147
+ glossKeys: ReadonlySet<string>,
148
+ cardKeys: ReadonlySet<string>,
149
+ ): string[] {
150
+ return formulaExplainKeys(formula ?? '').filter(
151
+ (key) => !cardKeys.has(key) && !glossKeys.has(key),
152
+ );
153
+ }