Module 00: What Is a Language Model?
~15 minutes · No prerequisites
DIAGRAM_LIB_REVISION = "2026-09-25-flow-waypoint-routes"
// =============================================================================
// D3 IMPORT
// =============================================================================
d3 = import("../../assets/vendor/d3/d3.esm.js")
// =============================================================================
// THEME DETECTION
// =============================================================================
// Reactive value that tracks Quarto's native dark mode and re-renders diagrams
// when the theme toggles. Generators.observe makes this a live OJS dependency:
// any cell referencing isDarkMode re-runs whenever the body/html class changes.
isDarkMode = Generators.observe(notify => {
const check = () =>
document.body.classList.contains('quarto-dark') ||
document.documentElement.classList.contains('quarto-dark');
notify(check());
const observer = new MutationObserver(() => notify(check()));
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
})
// =============================================================================
// CSS VARIABLE UTILITIES
// =============================================================================
// Function to read CSS custom property values from the document.
// Reads from <body> because Quarto applies the .quarto-dark class there, so the
// dark-mode variable overrides resolve on the body element, not <html>.
getCSSVar = function(name, fallback = null) {
if (typeof document === 'undefined') return fallback;
const value = getComputedStyle(document.body).getPropertyValue(name).trim();
return value || fallback;
}
// Parse a CSS color string to {r, g, b} bytes. Understands #rgb/#rrggbb/
// #rrggbbaa, rgb()/rgba(), and var(--token) (resolved against <body>, like
// getCSSVar). Returns null for anything it can't parse — callers must fall
// back, never interpolate through NaN.
_parseColor = function(color) {
if (typeof color !== 'string') return null;
let c = color.trim();
const varMatch = c.match(/^var\(\s*(--[\w-]+)/);
if (varMatch) c = (getCSSVar(varMatch[1], '') || '').trim();
let m;
if ((m = c.match(/^#([0-9a-f]{3,8})$/i))) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16)
};
}
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16)
};
}
if ((m = c.match(/^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)/i))) {
return {r: +m[1], g: +m[2], b: +m[3]};
}
return null;
}
// =============================================================================
// THEME OBJECT
// =============================================================================
// Object containing all diagram colors read from CSS variables
// Falls back to hardcoded values if CSS vars not available
diagramTheme = {
// Light-mode fallback values (used if CSS vars are unavailable)
const lightFallbacks = {
nodeFill: '#f5f5f4',
nodeFillHover: '#e7e5e4',
nodeStroke: '#d6d3d1',
nodeText: '#1c1917',
edgeStroke: '#78716c',
highlight: '#f97316',
highlightGlow: 'rgba(249, 115, 22, 0.3)',
highlightBg: 'rgba(249, 115, 22, 0.1)',
accent: '#0ea5e9',
accentGlow: 'rgba(14, 165, 233, 0.3)',
textOnHighlight: '#1c1917',
textOnAccent: '#1c1917',
// Ink for highlight/accent-colored RUNNING text on the diagram surface
// (labels, titles, readouts — not on a saturated fill). Raw highlight
// (#f97316, 2.68:1) / accent (#0ea5e9, 2.65:1) fail AA as light-mode text;
// 70% hue (the §6 idiom's fraction) still misses at 4.2–4.4:1 for these two
// bright hues, so these mix 60% toward the theme ink
// (light: #a15525/#1473a3, 5.0–5.2:1; dark: #f9b984/#84d3f5, ~10.3:1).
// Shapes keep the raw fills — only text reads these keys.
highlightText: '#a15525',
accentText: '#1473a3',
// Scoped ink for in-cell labels on the paper→highlight heatmap on-ramp.
// The light value is a deep bronze that clears AA across the audited
// mid-band; figures use it only for those labels, never general text.
heatmapMidText: '#7c2d12',
// Primary-accent tint for chips/badges sitting behind --ph-emb ink
// (exposed as diagramTheme.primaryBg): ~10% indigo over transparent.
primaryBg: 'rgba(79, 70, 229, 0.10)',
// Text sitting on a fully-saturated *status* fill (break-red / run-green /
// any deep status hue). Unlike textOnHighlight/textOnAccent (dark in both
// themes, because the orange highlight & sky accent stay mid-tone), the
// status hues flip: DEEP in light (#dc2626/#059669 → white reads) but
// BRIGHTENED in dark (#f87171/#34d399 → dark text reads). So this token
// flips too — white in light, near-black in dark — clearing AA on the
// status fills in both themes. One token so figures stop hard-coding "#fff".
textOnFill: '#ffffff',
// Ink for text sitting ON success/error FILLS — same flip logic as
// textOnFill, scoped per status hue. Resolved from real tokens
// (--diagram-text-on-success/-error in _diagram-variables.scss) so chips,
// heatmap cells, and badges on run-green/break-red stop hand-picking ink.
textOnSuccess: '#ffffff',
textOnError: '#ffffff',
bg: '#fafaf9',
bgSecondary: '#f5f5f4',
// Semantic colors for status/feedback — aliased onto the canonical status/
// phase hues (DESIGN_LANGUAGE.md §2/pillar 2): error≡--pg-break (was
// already byte-identical), success≡--pg-run, info≡--ph-attn (2026-07-11,
// was ad-hoc #16a34a/#2563eb — a "seventh blue" and a second green).
error: '#dc2626',
errorBg: 'rgba(220, 38, 38, 0.1)',
success: '#059669',
successBg: 'rgba(5, 150, 105, 0.1)',
info: '#0284c7',
infoBg: 'rgba(2, 132, 199, 0.1)',
// Pipeline phase palette (DESIGN_LANGUAGE §2) — the site's canonical accent
// grammar, mirrored from the playground north star (playground/ui/kit.js).
// Any diagram that colors a pipeline stage reads its hue from here so lesson
// figures and the debugger share one palette. Status hues (run/break) too.
phaseTok: '#7c3aed',
phaseEmb: '#4f46e5',
phaseAttn: '#0284c7',
phaseMlp: '#0d9488',
phaseNorm: '#64748b',
phaseLogits: '#d97706',
phaseSample: '#e11d48',
run: '#059669',
break: '#dc2626'
};
// Dark-mode fallbacks + brighter semantic colors for readability on dark.
const darkFallbacks = {
nodeFill: '#292524',
nodeFillHover: '#3f3a36',
nodeStroke: '#57534e',
nodeText: '#fafaf9',
edgeStroke: '#a8a29e',
highlight: '#fb923c',
highlightGlow: 'rgba(251, 146, 60, 0.4)',
highlightBg: 'rgba(251, 146, 60, 0.18)',
accent: '#38bdf8',
accentGlow: 'rgba(56, 189, 248, 0.4)',
textOnHighlight: '#1c1917',
textOnAccent: '#1c1917',
// Dark twins: brightened hues mixed 60% toward near-white ink (≈10.3:1 on
// the dark surface).
highlightText: '#f9b984',
accentText: '#84d3f5',
// Dark twin of the scoped heatmap/on-ramp label ink. Near-black umber
// clears the high side of the audited paper→highlight ramp.
heatmapMidText: '#030201',
// Dark twin of primaryBg — the brightened indigo at 18%.
primaryBg: 'rgba(129, 140, 248, 0.18)',
// Dark twin: status fills brighten on dark (#f87171/#34d399), so dark text
// reads on them — the flip described on the light textOnFill above.
textOnFill: '#1c1917',
// Dark twin: brightened status fills (#34d399/#f87171) want dark ink.
textOnSuccess: '#1c1917',
textOnError: '#1c1917',
bg: 'transparent',
bgSecondary: '#1c1917',
error: '#f87171',
errorBg: 'rgba(248, 113, 113, 0.18)',
success: '#34d399',
successBg: 'rgba(52, 211, 153, 0.18)',
info: '#38bdf8',
infoBg: 'rgba(56, 189, 248, 0.18)',
// Phase palette — dark twin (brighter for legibility on dark surfaces),
// mirroring the playground's .quarto-dark --ph-*/--pg-* values exactly.
phaseTok: '#a78bfa',
phaseEmb: '#818cf8',
phaseAttn: '#38bdf8',
phaseMlp: '#2dd4bf',
phaseNorm: '#94a3b8',
phaseLogits: '#fbbf24',
phaseSample: '#fb7185',
run: '#34d399',
break: '#f87171'
};
// Referencing isDarkMode here makes this cell reactive: it recomputes (and all
// diagrams that read it re-render) whenever the theme is toggled.
const fallbacks = isDarkMode ? darkFallbacks : lightFallbacks;
// Resolved once so `bgOpaque` can be derived from them (an object literal
// can't reference its own siblings).
const bg = getCSSVar('--diagram-bg', fallbacks.bg);
const bgSecondary = getCSSVar('--diagram-bg-secondary', fallbacks.bgSecondary);
// bgOpaque hoisted to a const so bgRGB can derive from it (an object literal
// can't reference its own siblings — same constraint as bg/bgSecondary).
const bgOpaque = (!bg || bg === 'transparent' || bg === 'none') ? bgSecondary : bg;
return {
nodeFill: getCSSVar('--diagram-node-fill', fallbacks.nodeFill),
nodeFillHover: getCSSVar('--diagram-hover-fill', fallbacks.nodeFillHover),
nodeStroke: getCSSVar('--diagram-node-stroke', fallbacks.nodeStroke),
nodeText: getCSSVar('--diagram-node-text', fallbacks.nodeText),
edgeStroke: getCSSVar('--diagram-edge-stroke', fallbacks.edgeStroke),
highlight: getCSSVar('--diagram-highlight', fallbacks.highlight),
highlightGlow: getCSSVar('--diagram-highlight-glow', fallbacks.highlightGlow),
highlightBg: getCSSVar('--diagram-highlight-bg', fallbacks.highlightBg),
accent: getCSSVar('--diagram-accent', fallbacks.accent),
accentGlow: getCSSVar('--diagram-accent-glow', fallbacks.accentGlow),
textOnHighlight: fallbacks.textOnHighlight,
textOnAccent: fallbacks.textOnAccent,
highlightText: getCSSVar('--diagram-highlight-text', fallbacks.highlightText),
accentText: getCSSVar('--diagram-accent-text', fallbacks.accentText),
heatmapMidText: getCSSVar('--diagram-heatmap-mid-text', fallbacks.heatmapMidText),
textOnFill: fallbacks.textOnFill,
textOnSuccess: getCSSVar('--diagram-text-on-success', fallbacks.textOnSuccess),
textOnError: getCSSVar('--diagram-text-on-error', fallbacks.textOnError),
bg,
bgSecondary,
// `bg`, but guaranteed PAINTABLE. `--diagram-bg` is deliberately
// `transparent` in dark mode so a figure sits *on* the article surface
// instead of stamping a light slab onto it — which is right for a figure's
// own background rect, and wrong everywhere `bg` is used as *ink*:
//
// * a mark's **halo ring** (`<circle stroke=theme.bg stroke-width=2>`),
// drawn so a point stays legible where it crosses its own line — with a
// transparent stroke there is no ring at all;
// * **knockout text** (`<text fill=theme.bg>`), the page-colored label on
// an active/filled node — with a transparent fill the label is *invisible*.
//
// Both silently broke in dark and were fine in light, which is why they
// survived eight page sweeps. This token keeps `bg` when it is paintable and
// falls back to the opaque `bgSecondary` (#f5f5f4 light / #1c1917 dark) when
// it is not, so ink survives both themes. Rule: a figure's own background
// rect fills `bg`; anything that PAINTS (stroke or text fill) uses
// `bgOpaque` — never `theme.bg`, never a bare "#fff".
bgOpaque,
// Precomputed {r,g,b} of bgOpaque, for color ramps/interpolators that
// would otherwise re-parse a CSS string — and BREAK in dark, where bg is
// 'transparent' (parse → NaN → a near-white ramp). Always the OPAQUE,
// paintable bg: interpolate from this, never from theme.bg.
// Usage: d3.interpolateRgb(d3.rgb(theme.bgRGB), d3.rgb(theme.accent))
bgRGB: _parseColor(bgOpaque) || { r: 245, g: 245, b: 244 },
// Semantic colors — now real --diagram-* tokens (2026-07-11), aliased onto
// --pg-break/--pg-run/--ph-attn at the shared layer so they're overridable
// and theme-reactive the same way every other diagram color is.
error: getCSSVar('--diagram-error', fallbacks.error),
errorBg: getCSSVar('--diagram-error-bg', fallbacks.errorBg),
success: getCSSVar('--diagram-success', fallbacks.success),
successBg: getCSSVar('--diagram-success-bg', fallbacks.successBg),
info: getCSSVar('--diagram-info', fallbacks.info),
infoBg: getCSSVar('--diagram-info-bg', fallbacks.infoBg),
// Primary accent — the site's canonical UI accent (embeddings indigo,
// DESIGN_LANGUAGE §2: `--accent-primary ≈ --ph-emb`). Several lessons
// (m01/m04/m06) already read `theme.primary` for their featured series, but
// the token was never defined here, so it resolved to `undefined` and those
// marks fell back to browser-default black in both themes. Defining it fixes
// that everywhere, theme-reactively.
primary: getCSSVar('--ph-emb', fallbacks.phaseEmb),
// Chip/badge background behind the primary accent — the resolved
// --diagram-primary-bg token (see _diagram-variables.scss).
primaryBg: getCSSVar('--diagram-primary-bg', fallbacks.primaryBg),
// Pipeline phase palette + status hues. Read from the canonical --ph-*/--pg-*
// tokens where present (they get seeded into custom.scss as the shared token
// work lands), else the phase fallbacks above — so a figure is on-palette and
// theme-reactive whether or not the site tokens exist yet. Grouped under
// `phase` (matching playground/ui/kit.js) plus flat run/break status hues.
phase: {
tok: getCSSVar('--ph-tok', fallbacks.phaseTok),
emb: getCSSVar('--ph-emb', fallbacks.phaseEmb),
attn: getCSSVar('--ph-attn', fallbacks.phaseAttn),
mlp: getCSSVar('--ph-mlp', fallbacks.phaseMlp),
norm: getCSSVar('--ph-norm', fallbacks.phaseNorm),
logits: getCSSVar('--ph-logits', fallbacks.phaseLogits),
sample: getCSSVar('--ph-sample', fallbacks.phaseSample)
},
run: getCSSVar('--pg-run', fallbacks.run),
break: getCSSVar('--pg-break', fallbacks.break),
isDark: isDarkMode
};
}
// =============================================================================
// TEXT MEASUREMENT & FITTING
// =============================================================================
// Canvas-backed text measurement shared by every fitting helper. The 2d
// context is created lazily and measured widths are memoized per (text|font)
// key. Falls back to a conservative 0.62em-per-glyph estimate when canvas
// measurement is unavailable, so callers always get a usable number.
// Holder object for the measurement machinery: OJS binds top-level assigned
// names as single-definition (const) cells, so the lazy canvas-context init
// and the broken-canvas flag must mutate PROPERTIES of this holder rather
// than reassign top-level bindings (that throws "Assignment to constant
// variable" at compile time).
// IIFE wrapper: a bare `name = {…}` object literal would parse as an OJS
// BLOCK CELL; the parens force expression parsing.
_measure = (() => ({ctx: null, broken: false, cache: new Map()}))()
// Resolve a CSS font-family that may be written as `var(--pg-mono)`: canvas
// font strings can't use custom properties, so the token is read off <body>
// (getCSSVar), exactly like every other theme read in this file.
_resolveFontFamily = function(family) {
if (!family) return null;
let f = String(family).trim();
const varMatch = f.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/);
if (varMatch) {
const fallback = varMatch[2] != null ? varMatch[2].replace(/["']/g, '').trim() : null;
f = getCSSVar(varMatch[1], fallback) || '';
}
f = f.replace(/!important/g, '').trim().replace(/^["']|["']$/g, '');
return f || null;
}
// Measured (or estimated) rendered width of a single-line text in px.
// Options: {fontSize = 12, fontWeight = 400, fontFamily = null, italic = false}
// fontFamily may be `var(--token)`; null measures in the body's font.
measureText = function(text, {fontSize = 12, fontWeight = 400, fontFamily = null, italic = false} = {}) {
if (text == null || text === '') return 0;
const len = String(text).length;
const family = _resolveFontFamily(fontFamily);
// Monospace chrome is the site's diagram font (--pg-mono → JetBrains Mono,
// a 0.600em-advance face). Canvas measurement races webfont loading: OJS
// cells evaluate before @font-face fonts finish, and canvas then reports
// FALLBACK-face metrics (much narrower), silently under-sizing boxes,
// gutters and measured legends. For mono families use the face's exact
// design advance — deterministic and load-independent.
if (family && /mono/i.test(family)) return 0.6 * fontSize * len;
// Proportional faces: canvas is exact ONLY if the webfont is actually
// loaded. If it isn't yet, canvas would silently return fallback metrics —
// return a conservative per-glyph estimate instead.
const firstFace = family ? family.split(',')[0].replace(/["']/g, '').trim() : null;
const fontReady = typeof document !== 'undefined' && document.fonts && document.fonts.check &&
(!firstFace || document.fonts.check(`${italic ? 'italic ' : ''}${fontWeight} ${fontSize}px "${firstFace}"`));
if (typeof document !== 'undefined' && !fontReady) return 0.55 * fontSize * len;
const key = `${text}\u0000${fontSize}\u0000${fontWeight}\u0000${fontFamily || ''}\u0000${italic ? 1 : 0}`;
if (_measure.cache.has(key)) return _measure.cache.get(key);
let width = NaN;
if (typeof document !== 'undefined' && !_measure.broken) {
try {
if (!_measure.ctx) _measure.ctx = document.createElement('canvas').getContext('2d');
if (_measure.ctx) {
const family = _resolveFontFamily(fontFamily) ||
(typeof getComputedStyle === 'function'
? _resolveFontFamily(getComputedStyle(document.body).fontFamily)
: null);
_measure.ctx.font = `${italic ? 'italic ' : ''}${fontWeight} ${fontSize}px ${family || 'sans-serif'}`;
width = _measure.ctx.measureText(String(text)).width;
} else {
_measure.broken = true;
}
} catch (e) {
_measure.broken = true;
}
}
if (!isFinite(width) || width <= 0) {
width = 0.62 * fontSize * String(text).length;
}
_measure.cache.set(key, width);
return width;
}
// Convenience wrapper with positional args: estimateTextWidth(text, fontSize,
// mono). mono=true (the default) measures in the site's mono stack — what most
// diagram chrome uses; mono=false measures in the body text face.
estimateTextWidth = function(text, fontSize = 12, mono = true) {
const family = mono
? getCSSVar('--pg-mono', 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace')
: null;
return measureText(text, {fontSize, fontFamily: family});
}
// Largest font size ≤ fontSize whose rendered width fits maxWidth. Never goes
// below minFontSize; if even that would overflow, the caller must wrap
// (wrapText/drawWrappedText) or move the text (placeLabel) instead.
fitFontSize = function(text, {maxWidth, fontSize = 12, minFontSize = 9, fontWeight, fontFamily, italic} = {}) {
const w = measureText(text, {fontSize, fontWeight, fontFamily, italic});
if (w <= 0 || w <= maxWidth) return fontSize;
return Math.max(minFontSize, Math.floor((fontSize * maxWidth / w) * 2) / 2);
}
// Greedy word-wrap into lines that each fit maxWidth (px). A single word wider
// than maxWidth is hard-broken at character level, so every returned line
// fits. Returns an array of strings ([] for empty input).
wrapText = function(text, maxWidth, {fontSize = 12, fontWeight, fontFamily, italic} = {}) {
if (text == null) return [];
const words = String(text).split(/\s+/).filter(Boolean);
if (!words.length) return [];
const lines = [];
let line = '';
const fits = (s) => !s || measureText(s, {fontSize, fontWeight, fontFamily, italic}) <= maxWidth;
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (fits(candidate)) { line = candidate; continue; }
if (line) { lines.push(line); line = ''; }
if (fits(word)) { line = word; continue; }
let piece = '';
for (const ch of word) {
if (piece && !fits(piece + ch)) { lines.push(piece); piece = ch; }
else piece += ch;
}
line = piece;
}
if (line) lines.push(line);
return lines;
}
// Character-count variant of wrapText for mono captions sized in characters.
wrapByChars = function(text, maxChars = 60) {
if (text == null) return [];
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (candidate.length <= maxChars) { line = candidate; continue; }
if (line) { lines.push(line); line = ''; }
let rest = word;
while (rest.length > maxChars) {
lines.push(rest.slice(0, maxChars));
rest = rest.slice(maxChars);
}
line = rest;
}
if (line) lines.push(line);
return lines;
}
// Wrap a caption and draw it as tspans on ONE <text> element — the cure for
// long single-line captions that run past the canvas edge (they clip instead
// of wrapping). Wraps by measured width when maxWidth (px) is given, else by
// maxChars; caps at maxLines with a trailing ellipsis when the text doesn't
// fit. Returns {node, lines, width, height} so callers can reserve room.
// Usage: drawWrappedText(svg, 'The token embedding E is (V × d) …', {x: cx,
// y: 250, maxWidth: width - 40, lineHeight: 14, maxLines: 2})
drawWrappedText = function(svg, text, {
x, y,
maxWidth = null, maxChars = null,
lineHeight = 14,
fontSize = 11,
fontWeight = null,
fontFamily = null,
fill = null,
anchor = 'middle',
maxLines = null,
id = null,
className = 'diagram-caption'
} = {}) {
const sel = svg && svg.node ? svg : d3.select(svg);
const str = text == null ? '' : String(text);
let lines = [];
if (str) {
lines = maxWidth != null
? wrapText(str, maxWidth, {fontSize, fontWeight, fontFamily})
: wrapByChars(str, maxChars == null ? 60 : maxChars);
if (maxLines != null && lines.length > maxLines) {
lines = lines.slice(0, maxLines);
const last = lines[lines.length - 1];
lines[lines.length - 1] = last.slice(0, Math.max(0, last.length - 1)).replace(/\s+\S*$/, '') + '…';
}
}
const t = sel.append('text')
.attr('x', x)
.attr('y', y)
.attr('text-anchor', anchor)
.attr('fill', fill != null ? fill : diagramTheme.nodeText)
.style('font-size', `${fontSize}px`)
.attr('class', className);
if (fontWeight != null) t.attr('font-weight', fontWeight);
if (fontFamily != null) t.attr('font-family', fontFamily);
if (id) t.attr('id', id);
lines.forEach((ln, i) => {
t.append('tspan')
.attr('x', x)
.attr('y', y + i * lineHeight)
.text(ln);
});
const width = lines.reduce((m, ln) => Math.max(m, measureText(ln, {fontSize, fontWeight, fontFamily})), 0);
return {node: t.node(), lines, width, height: lines.length ? (lines.length - 1) * lineHeight + fontSize * 1.25 : 0};
}
// Keep an anchored text run inside the canvas gutter. An end-anchored label at
// x=30 extends LEFT of its anchor, a start-anchored one extends RIGHT — long
// runs then start before 0 (or past the viewBox edge) and get clipped.
// Measures the text and returns the anchor x shifted so the whole run stays
// inside [min, max]. Text wider than the entire gutter is pinned to the
// nearest gutter edge (the caller can then shrink the font via fitFontSize).
// anchor: 'start' | 'middle' | 'end'.
fitGutter = function(text, x, {
anchor = 'end',
fontSize = 10,
fontWeight = null,
fontFamily = null,
min = 4,
max = null
} = {}) {
const w = measureText(text, {fontSize, fontWeight, fontFamily});
const lo = min, hi = max == null ? Infinity : max;
if (w > hi - lo) return Math.min(Math.max(x, lo), hi);
const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x;
const clampedLeft = Math.min(Math.max(left, lo), hi - w);
return anchor === 'end' ? clampedLeft + w
: anchor === 'middle' ? clampedLeft + w / 2
: clampedLeft;
}
// Perceptual ink choice for text sitting ON a filled shape (heatmap cells,
// status chips, badge fills): returns a near-black ink on light/bright fills
// and a white ink on dark fills, so cell values stay legible in BOTH themes.
// The 0.30 luminance threshold reproduces the site's own tokens: orange
// #f97316 and sky #0ea5e9 read as "bright" → dark ink (matching
// textOnHighlight/textOnAccent), while break-red #dc2626 reads as "dark" →
// white ink (matching textOnFill in light). Accepts #rgb/#rrggbb, rgb()/rgba()
// and var(--token) forms.
contrastInk = function(bg, {darkInk = '#1c1917', lightInk = '#ffffff', threshold = 0.30} = {}) {
const rgb = _parseColor(bg);
if (!rgb) return darkInk;
const lin = (v) => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
return L > threshold ? darkInk : lightInk;
}
// =============================================================================
// COLLISION-AWARE LABEL PLACEMENT
// =============================================================================
// Knockout outline behind glyphs: paint-order renders the stroke first (under
// the fill), in the figure-background color, so text stays legible where an
// arrow or another label passes underneath. Color reads --diagram-halo
// (theme-aware, opaque in both modes; see _diagram-variables.scss) with
// theme.bgOpaque as fallback.
applyHalo = function(text, color = null) {
const sel = text && text.node ? text : d3.select(text);
return sel
.style('paint-order', 'stroke')
.style('stroke', color || getCSSVar('--diagram-halo', diagramTheme.bgOpaque))
.style('stroke-width', '3px')
.style('stroke-linejoin', 'round');
}
// Harvest the drawn shapes of a rendered svg as rectangles in USER units, for
// use as collision obstacles. Full-canvas background slabs are dropped
// automatically (they would push every label out of the figure); everything
// else — node boxes, other labels, arrow paths — becomes an obstacle.
// `svg` may be a d3 selection or an <svg> element. Elements can be excluded
// per-element via a `data-no-obstacle` attribute or via the `exclude`
// selector(s).
collectObstacles = function(svg, {exclude = null} = {}) {
const node = svg && svg.node ? svg.node() : svg;
if (!node || typeof node.getScreenCTM !== 'function' || typeof node.querySelectorAll !== 'function') return [];
const ctm = node.getScreenCTM();
if (!ctm) return [];
const inv = ctm.inverse();
const vb = node.viewBox && node.viewBox.baseVal;
const vbW = (vb && vb.width) || 0, vbH = (vb && vb.height) || 0;
const excludes = exclude == null ? [] : (Array.isArray(exclude) ? exclude : [exclude]);
const isExcluded = (el) => {
if (el.closest('[data-no-obstacle]')) return true;
return excludes.some(sel => {
try { return el.matches(sel) || el.closest(sel); } catch (e) { return false; }
});
};
const toUser = (clientX, clientY) => {
const pt = node.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const p = pt.matrixTransform(inv);
return {x: p.x, y: p.y};
};
const out = [];
for (const sel of ['rect', 'text', 'path']) {
for (const el of node.querySelectorAll(sel)) {
if (isExcluded(el)) continue;
const r = el.getBoundingClientRect();
if (!r.width && !r.height) continue;
const a = toUser(r.left, r.top);
const b = toUser(r.right, r.bottom);
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
if (vbW && vbH && w >= vbW * 0.9 && h >= vbH * 0.9) continue; // background slab
out.push({x, y, w, h});
}
}
return out;
}
// Box a text run into a top-left rect, given its anchor point.
_anchorBox = function(x, y, w, h, anchor) {
const left = anchor === 'start' ? x : anchor === 'end' ? x - w : x - w / 2;
return {x: left, y: y - h / 2, w, h};
}
_hitsAny = function(box, obstacles, margin = 3) {
for (const o of obstacles) {
if (box.x < o.x + o.w + margin && box.x + box.w + margin > o.x &&
box.y < o.y + o.h + margin && box.y + box.h + margin > o.y) return true;
}
return false;
}
// Approximate an SVG path with small collision boxes in user units. These are
// deliberately sampled more densely than the 10px captions: the resolver's
// own 3px collision margin then treats each arrow stroke as a narrow,
// continuous obstacle without needing a browser hit-test for every candidate.
_pathObstacleRects = function(path, spacing = 4, pad = 2) {
if (!path || typeof path.getTotalLength !== 'function' || typeof path.getPointAtLength !== 'function') return [];
let length;
try { length = path.getTotalLength(); } catch (e) { return []; }
if (!isFinite(length) || length <= 0) return [];
try {
const out = [];
let prev = path.getPointAtLength(0);
for (let d = Math.min(spacing, length); d <= length + spacing; d += spacing) {
const next = path.getPointAtLength(Math.min(d, length));
out.push({
x: Math.min(prev.x, next.x) - pad,
y: Math.min(prev.y, next.y) - pad,
w: Math.abs(next.x - prev.x) + pad * 2,
h: Math.abs(next.y - prev.y) + pad * 2
});
prev = next;
if (d >= length) break;
}
return out;
} catch (e) {
return [];
}
}
_svgBounds = function(node) {
if (!node || !node.viewBox) return null;
const vb = node.viewBox.baseVal;
if (vb && vb.width && vb.height) {
return {x1: vb.x, y1: vb.y, x2: vb.x + vb.width, y2: vb.y + vb.height};
}
const w = parseFloat(node.getAttribute('width')), h = parseFloat(node.getAttribute('height'));
if (isFinite(w) && isFinite(h)) return {x1: 0, y1: 0, x2: w, y2: h};
return null;
}
// Search a small spiral of candidate offsets for a spot where the label's box
// neither hits an obstacle nor leaves the viewBox. Deterministic: candidates
// are tried in a fixed order (base, then sideways, then vertical, outward),
// so identical input always produces identical placement. Falls back to the
// requested position when nothing is clear — the caller's halo, if any, keeps
// it legible there.
_resolveLabelPos = function({x, y, w, h, anchor = 'middle', obstacles, bounds = null, step = 12, reach = 4, margin = 3}) {
if (!obstacles || !obstacles.length) return {x, y};
const hits = (cx, cy) => _hitsAny(_anchorBox(cx, cy, w, h, anchor), obstacles, margin);
const inside = (cx, cy) => {
if (!bounds) return true;
const b = _anchorBox(cx, cy, w, h, anchor);
return b.x >= bounds.x1 - 1 && b.y >= bounds.y1 - 1 &&
b.x + b.w <= bounds.x2 + 1 && b.y + b.h <= bounds.y2 + 1;
};
if (!hits(x, y) && inside(x, y)) return {x, y};
for (let k = 1; k <= reach; k++) {
for (const d of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const cx = x + d[0] * k * step, cy = y + d[1] * k * step;
if (!hits(cx, cy) && inside(cx, cy)) return {x: cx, y: cy};
}
}
return {x, y};
}
// Create a free-standing text label with measured size, optional collision
// avoidance, and an optional knockout halo. Returns {node, x, y, width,
// height} — the FINAL placed position, so callers can draw connectors to it.
// avoid: array of {x,y,w,h} rects, or an svg (element/selection) whose drawn
// shapes are harvested as obstacles. Usage: placeLabel(svg, {x: 320, y: 210,
// text: 'softmax(dim=-1)', avoid: svg, halo: true})
placeLabel = function(svg, {
x, y, text,
dx = 0, dy = 0,
anchor = 'middle',
fontSize = 10,
fontWeight = null,
fill = null,
opacity = null,
fontFamily = null,
halo = true,
haloColor = null,
avoid = null,
exclude = null,
step = 12, reach = 4,
id = null,
className = 'diagram-label'
} = {}) {
const sel = svg && svg.node ? svg : d3.select(svg);
const node = sel.node();
const w = measureText(text, {fontSize, fontWeight, fontFamily});
const h = fontSize * 1.3;
let obstacles = null;
if (avoid) obstacles = Array.isArray(avoid) ? avoid : collectObstacles(avoid, {exclude});
const baseX = x + dx, baseY = y + dy;
const pos = obstacles
? _resolveLabelPos({x: baseX, y: baseY, w, h, anchor, obstacles, bounds: _svgBounds(node), step, reach})
: {x: baseX, y: baseY};
const t = sel.append('text')
.attr('x', pos.x)
.attr('y', pos.y)
.attr('text-anchor', anchor)
.attr('dominant-baseline', 'central')
.attr('font-size', `${fontSize}px`)
.style('font-size', `${fontSize}px`)
.attr('pointer-events', 'none')
.attr('class', className)
.text(text);
if (fontWeight != null) t.attr('font-weight', fontWeight);
t.attr('fill', fill != null ? fill : diagramTheme.nodeText);
if (opacity != null) t.attr('opacity', opacity);
if (fontFamily != null) t.attr('font-family', fontFamily);
if (id) t.attr('id', id);
if (halo) applyHalo(t, haloColor);
return {node: t.node(), x: pos.x, y: pos.y, width: w, height: h};
}
// =============================================================================
// FIGURE FIT & SCROLL (anti-clipping)
// =============================================================================
// Expand an svg's viewBox just enough to contain everything actually drawn
// (labels, markers, captions included) — the cure for content that overflows
// the authored bounds and gets clipped at the bottom or right edge. Only ever
// GROWS the viewBox (a figure that already fits is left untouched) and grows
// the width/height attributes with the new aspect so the glyph scale (px per
// user unit) is preserved. Returns true when the viewBox changed. Safe on
// detached svgs (no layout yet → returns false) and after the final render
// step of a stepped diagram.
fitContent = function(svg, {pad = 6} = {}) {
const node = svg && svg.node ? svg.node() : svg;
if (!node || typeof node.getBBox !== 'function') return false;
let b;
try { b = node.getBBox(); } catch (e) { return false; }
if (!b || (!b.width && !b.height)) return false;
const vb = node.viewBox && node.viewBox.baseVal;
const hasVB = !!(vb && vb.width && vb.height);
let vx = 0, vy = 0, vw = 0, vh = 0;
if (hasVB) { vx = vb.x; vy = vb.y; vw = vb.width; vh = vb.height; }
else {
const r = node.getBoundingClientRect();
vw = r.width || 0;
vh = r.height || 0;
}
if (!vw || !vh) return false;
const left = Math.min(vx, b.x - pad), top = Math.min(vy, b.y - pad);
const right = Math.max(vx + vw, b.x + b.width + pad);
const bottom = Math.max(vy + vh, b.y + b.height + pad);
const nw = right - left, nh = bottom - top;
if (nw <= vw + 0.5 && nh <= vh + 0.5) return false;
node.setAttribute('viewBox', `${+left.toFixed(1)} ${+top.toFixed(1)} ${+nw.toFixed(1)} ${+nh.toFixed(1)}`);
// Preserve px-per-unit so text does not shrink when the box grows: scale
// the painted size to the new aspect. CSS-sized svgs (e.g. inside
// .diagram-container) track the viewBox ratio automatically via height:auto.
const attrNum = (name) => {
const v = node.getAttribute(name);
if (!v || v.includes('%')) return NaN;
const n = parseFloat(v);
return isFinite(n) ? n : NaN;
};
const wAttr = attrNum('width'), hAttr = attrNum('height');
const scale = hasVB
? (isFinite(wAttr) ? wAttr / vw : (isFinite(hAttr) ? hAttr / vh : null))
: 1;
if (isFinite(scale) && scale > 0) {
node.setAttribute('width', +(nw * scale).toFixed(1));
node.setAttribute('height', +(nh * scale).toFixed(1));
}
return true;
}
// fitContent for every svg under root (a selector string, element, or d3
// selection). Figures can opt out with the class `diagram-no-fit`. Call once
// after a figure's last render step; returns the number of svgs adjusted.
// Usage: fitAll(cellRoot) // after the final step's draw calls
fitAll = function(root, opts = {}) {
let el = root && root.node ? root.node() : root;
if (typeof el === 'string') el = document.querySelector(el);
if (!el || !el.querySelectorAll) return 0;
let n = 0;
el.querySelectorAll('svg').forEach(s => {
if (s.classList.contains('diagram-no-fit')) return;
if (fitContent(s, opts)) n++;
});
return n;
}
// Wrap a figure for display: tags the svg `.diagram-fig` (so the site's
// blanket `svg text { fill: … !important }` resets in custom.scss stop
// flattening the figure's own theme-aware fills) and puts it inside a
// horizontally scrollable box (`.diagram-scroll`) that never exceeds the page
// column — a wide diagram scrolls INSIDE its container instead of widening
// the page or being squeezed illegibly (DESIGN_LANGUAGE.md §5). The svg keeps
// its authored pixel width (inline min-width): narrower column → the
// container scrolls; wider column → the figure sits at its natural size.
// Options: {minWidth = null, maxHeight = null, className = ''}. `maxHeight`
// caps the box height and scrolls vertically instead — for tall figures
// (heatmap stacks) that would otherwise run past the viewport.
// Usage: display(scrollWrap(svg.node())) // wide figure → x-scroll
// display(scrollWrap(svg.node(), {maxHeight: 420})) // tall → y-scroll
scrollWrap = function(svg, {minWidth = null, maxHeight = null, className = ''} = {}) {
const node = svg && svg.node ? svg.node() : svg;
if (node) {
node.classList.add('diagram-fig');
let w = minWidth;
if (w == null) {
const attr = node.getAttribute('width');
if (attr && !attr.includes('%')) w = parseFloat(attr);
else {
const vb = node.viewBox && node.viewBox.baseVal;
w = vb && vb.width ? vb.width : null;
}
}
if (w) node.style.minWidth = `${w}px`;
}
const cls = `diagram-scroll${className ? ' ' + className : ''}`;
let wrapper;
try {
wrapper = htl.html`<div class="${cls}">${node}</div>`;
} catch (e) {
wrapper = document.createElement('div');
wrapper.className = cls;
if (node) wrapper.appendChild(node);
}
// Apply the cap to the actual scroller: htl returns the root element, but
// some environments wrap it — find the .diagram-scroll either way.
const scroller = (wrapper.classList && wrapper.classList.contains('diagram-scroll'))
? wrapper
: ((wrapper.querySelector && wrapper.querySelector('.diagram-scroll')) || wrapper);
if (maxHeight != null) {
scroller.style.maxHeight = `${maxHeight}px`;
scroller.style.overflowY = 'auto';
}
return wrapper;
}
// =============================================================================
// SVG PRIMITIVES
// =============================================================================
// Creates a group with rounded rect and text.
// Options: {x, y, width, height, label, sublabel, id, theme, rx, ry, className,
// fontFamily}
// Text fitting (additive; existing options keep their meaning):
// * width/height omitted → the box auto-fits its text, never narrower/taller
// than the historical 100×50 default, so legacy call sites keep geometry.
// * width given but the label is wider than the box → the label font shrinks
// (floor 9px, sublabel proportionally) to stay INSIDE the box instead of
// spilling over its edges ("Multi-Head Attention" in a 100px node).
// The resolved box is attached as the group's datum: g.datum() → {width,
// height, fontSize, subFontSize} — read it when positioning connectors.
createNode = function(svg, options) {
const {
x = 0,
y = 0,
width = null,
height = null,
label = '',
sublabel = '',
id = null,
theme = diagramTheme,
rx = 6,
ry = 6,
className = 'diagram-node',
fontFamily = null
} = options;
// --- text fitting ---------------------------------------------------------
// Measure in the font the text will actually render in: an explicit
// fontFamily option wins; else the svg's inline font-family (often
// `var(--pg-mono)` on lesson figures — mono is wider, so measuring in the
// body face would under-size the box); else the body font.
const padX = 12;
const family = fontFamily || (svg && svg.style ? svg.style('font-family') : null) || null;
const labelW = label ? measureText(label, {fontSize: 12, fontWeight: 500, fontFamily: family}) : 0;
const subW = sublabel ? measureText(sublabel, {fontSize: 10, fontFamily: family}) : 0;
let w, h, fontSize = 12, subFontSize = 10;
if (width == null) {
// Auto-fit: grow to the text, never below the historical 100px default.
w = Math.max(100, labelW + padX * 2, subW + 16);
} else {
// Authored box: keep its geometry, shrink the type to fit inside.
w = width;
const avail = Math.max(w - padX * 2, 20);
if (labelW > avail) {
fontSize = fitFontSize(label, {maxWidth: avail, fontSize: 12, minFontSize: 9, fontWeight: 500, fontFamily: family});
subFontSize = Math.max(7.5, 10 * fontSize / 12);
}
}
h = height == null ? 50 : height;
// Create group
const g = svg.append('g')
.attr('class', className)
.attr('transform', `translate(${x}, ${y})`);
if (id) g.attr('id', id);
// Add rectangle
g.append('rect')
.attr('x', -w / 2)
.attr('y', -h / 2)
.attr('width', w)
.attr('height', h)
.attr('rx', rx)
.attr('ry', ry)
.attr('fill', theme.nodeFill)
.attr('stroke', theme.nodeStroke)
.attr('stroke-width', 1.5);
// Add main label
if (label) {
const labelY = sublabel ? -6 : 0;
g.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.style('font-size', `${fontSize}px`) // inline style wins over the `.diagram-node text` CSS rule when shrinking
.attr('font-weight', '500')
.attr('pointer-events', 'none')
.text(label);
}
// Add sublabel
if (sublabel) {
g.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.style('font-size', `${subFontSize}px`)
.attr('opacity', 0.7)
.attr('pointer-events', 'none')
.text(sublabel);
}
// Publish the resolved box (additive; harmless to ignore).
g.datum({width: w, height: h, fontSize, subFontSize});
return g;
}
// Creates a path with arrowhead marker
// Options: {x1, y1, x2, y2, label, theme, curved, curvature, id, className,
// dashed, labelOffset, labelDx, labelDy, labelAnchor, halo, avoid,
// exclude}
createArrow = function(svg, options) {
const {
x1 = 0,
y1 = 0,
x2 = 100,
y2 = 0,
label = '',
theme = diagramTheme,
curved = false,
curvature = 0.3,
id = null,
className = 'diagram-edge',
dashed = false,
// Additive label options — every default preserves the original geometry:
labelOffset = 12, // perpendicular distance of the label from the line
labelDx = 0, // extra offset AFTER the perpendicular shift
labelDy = 0,
labelAnchor = 'middle',
halo = false, // knockout outline (--diagram-halo) behind the label
avoid = null, // dodge obstacles: rects[] or an svg (element/selection)
exclude = null // when harvesting, skip elements matching these selectors
} = options;
// Create unique marker ID
const markerId = `arrow-${Math.random().toString(36).substr(2, 9)}`;
// Ensure defs exists
let defs = svg.select('defs');
if (defs.empty()) {
defs = svg.append('defs');
}
// Add arrowhead marker
defs.append('marker')
.attr('id', markerId)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.edgeStroke);
// Create group for arrow
const g = svg.append('g')
.attr('class', className);
if (id) g.attr('id', id);
// Calculate path
let pathD;
if (curved) {
// Quadratic Bezier curve
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const dx = x2 - x1;
const dy = y2 - y1;
// Perpendicular offset for curve
const cx = midX - dy * curvature;
const cy = midY + dx * curvature;
pathD = `M${x1},${y1} Q${cx},${cy} ${x2},${y2}`;
} else {
// Straight line
pathD = `M${x1},${y1} L${x2},${y2}`;
}
// Add path
const path = g.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', theme.edgeStroke)
.attr('stroke-width', 1.5)
.attr('marker-end', `url(#${markerId})`);
if (dashed) {
path.attr('stroke-dasharray', '5,3');
}
// Add label if provided. Geometry when no additive option is given:
// midpoint of the line shifted 12px perpendicular — exactly as before.
if (label) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
// Offset label perpendicular to the line
const angle = Math.atan2(y2 - y1, x2 - x1);
const nx = Math.sin(angle), ny = -Math.cos(angle);
let lx = mx + nx * labelOffset + labelDx;
let ly = my + ny * labelOffset + labelDy;
// Collision avoidance (opt-in): nudge the label until its box clears every
// obstacle, so edge captions stop sitting on nodes, other labels, or other
// arrows. avoid: rects[] or an svg (element/selection) to harvest.
if (avoid) {
const obstacles = Array.isArray(avoid) ? avoid : collectObstacles(avoid, {exclude});
const tw = measureText(label, {fontSize: 10, fontFamily: (svg && svg.style ? svg.style('font-family') : null) || null});
const placed = _resolveLabelPos({
x: lx, y: ly, w: tw, h: 13, anchor: labelAnchor,
obstacles, bounds: _svgBounds(svg.node()), step: 12, reach: 4
});
lx = placed.x;
ly = placed.y;
}
const labelText = g.append('text')
.attr('x', lx)
.attr('y', ly)
.attr('text-anchor', labelAnchor)
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.style('font-size', '10px') // inline: wins over the `.diagram-edge text` CSS rule
.text(label);
if (halo) applyHalo(labelText);
}
return g;
}
// =============================================================================
// STEP ANIMATION CONTROLLER
// =============================================================================
// Factory function returning controller for step-through animations
// Options: {total, initialStep, speed, loop, onStepChange}
createStepController = function(options = {}) {
const {
total = 1,
initialStep = 0,
speed = 1000,
loop = true,
onStepChange = null
} = options;
let current = initialStep;
let isPlaying = false;
let intervalId = null;
let currentSpeed = speed;
const notifyChange = () => {
if (onStepChange && typeof onStepChange === 'function') {
onStepChange(current);
}
};
const controller = {
get current() { return current; },
get isPlaying() { return isPlaying; },
get total() { return total; },
get speed() { return currentSpeed; },
setStep(step) {
current = Math.max(0, Math.min(total - 1, step));
notifyChange();
return current;
},
next() {
if (current < total - 1) {
current++;
} else if (loop) {
current = 0;
}
notifyChange();
return current;
},
prev() {
if (current > 0) {
current--;
} else if (loop) {
current = total - 1;
}
notifyChange();
return current;
},
play() {
if (isPlaying) return;
isPlaying = true;
intervalId = setInterval(() => {
controller.next();
}, currentSpeed);
},
stop() {
isPlaying = false;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
},
toggle() {
if (isPlaying) {
controller.stop();
} else {
controller.play();
}
},
reset() {
controller.stop();
current = initialStep;
notifyChange();
},
setSpeed(newSpeed) {
currentSpeed = newSpeed;
if (isPlaying) {
controller.stop();
controller.play();
}
}
};
return controller;
}
// =============================================================================
// FLOW DIAGRAM COMPONENT
// =============================================================================
// Higher-level component for node/edge diagrams
// Options: {nodes, edges, width, height, activeNodes, activeEdges, theme,
// nodeWidth, nodeHeight, padding}
// Edge options: {id, source, target, label, labelHalo, labelDx, labelDy,
// dashed, curved, curvature}. Labels avoid unrelated nodes, other
// edge paths and sibling captions after the full diagram is drawn.
// Edges are trimmed at the box borders
// along their own direction (see the edge-trimming block below), and
// a curved edge's label rides the curve's midpoint.
// Text fitting (additive): when nodeWidth is OMITTED, each node's box grows to
// fit its label/sublabel (never narrower than the historical 100px default)
// instead of letting long labels spill outside the rect. When nodeWidth IS
// given, the authored geometry is respected and the label font shrinks (floor
// 9px) to stay inside the box.
FlowDiagram = function(options) {
const {
nodes = [],
edges = [],
width = 600,
height = 400,
activeNodes = [],
activeEdges = [],
theme = diagramTheme,
nodeWidth = 100,
nodeHeight = 50,
padding = 20
} = options;
const autoNodeWidth = options.nodeWidth == null;
const nodePadX = 12;
// Instrument chrome (§3): a figure's labels are chrome, not prose — mono,
// like every other figure in the book and the playground's panel. FlowDiagram
// was the one shared component that never set it, so its figures (m02, m29)
// drew their node labels, sublabels and edge captions in the body sans while
// the hand-built figures beside them were mono. The token is passed to the
// measurements too: mono's 0.6em advance is wider than the sans it replaced,
// so the auto-fit/shrink math has to measure the face that will render.
const chromeFont = 'var(--pg-mono)';
// Resolved {w, h, fontSize, subFontSize} per node (index-aligned).
const effSizes = nodes.map(node => {
const base = node.width || nodeWidth;
const labelW = node.label ? measureText(node.label, {fontSize: 12, fontWeight: 500, fontFamily: chromeFont}) : 0;
const subW = node.sublabel ? measureText(node.sublabel, {fontSize: 10, fontFamily: chromeFont}) : 0;
let w = base, fontSize = 12, subFontSize = 10;
if (autoNodeWidth) {
w = Math.max(base, labelW + nodePadX * 2, subW + 16);
} else {
const avail = Math.max(w - nodePadX * 2, 20);
if (labelW > avail) {
fontSize = fitFontSize(node.label, {maxWidth: avail, fontSize: 12, minFontSize: 9, fontWeight: 500, fontFamily: chromeFont});
subFontSize = Math.max(7.5, 10 * fontSize / 12);
}
}
return {w, h: node.height || nodeHeight, fontSize, subFontSize};
});
// Create SVG element
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.style('font-family', chromeFont)
.attr('class', 'flow-diagram diagram-fig'); // .diagram-fig: managed figure — opts out of the blanket svg-text color resets in custom.scss
// Add background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', theme.bg)
.attr('rx', 8);
// Create defs for markers
const defs = svg.append('defs');
// Standard arrow marker
defs.append('marker')
.attr('id', 'flow-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.edgeStroke);
// Highlighted arrow marker
defs.append('marker')
.attr('id', 'flow-arrow-highlight')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.highlight);
// --- Edge trimming --------------------------------------------------------
// An edge is trimmed at the border of the box it leaves and of the box it
// enters, so the arrowhead lands ON the target box instead of inside it.
// The trim distance must be measured along the edge's OWN direction: a
// vertical edge leaves through the box's top/bottom edge (h/2 away), not its
// side (w/2). Trimming every direction by w/2 (the old geometry) detached the
// head from the target — 17-63px of floating arrowhead on m29's platform
// figure — and, whenever the two centres were closer than w/2 + w/2 + 15, it
// pushed the start past the end and INVERTED the path, so the arrow pointed
// backwards (m29's identity→runtime and observability→memory edges).
// Ray/box exit distance: |centre → border| along unit direction (ux, uy).
const boxExit = (size, ux, uy) => {
const ax = Math.abs(ux), ay = Math.abs(uy);
const tx = ax > 1e-6 ? (size.w / 2) / ax : Infinity;
const ty = ay > 1e-6 ? (size.h / 2) / ay : Infinity;
return Math.min(tx, ty);
};
// Unit vector from (x, y), falling back to (fx, fy) for a degenerate span.
const unitDir = (x, y, fx, fy) => {
const l = Math.sqrt(x * x + y * y);
return l > 1e-6 ? [x / l, y / l] : [fx, fy];
};
const EDGE_START_GAP = 4; // breathing room where the line leaves the source
const EDGE_END_GAP = 1; // the head's tip extends ~2px past the path end
// Edges layer (draw first so nodes appear on top)
const edgesLayer = svg.append('g').attr('class', 'edges-layer');
const flowEdges = [];
const edgeLabels = [];
// Nodes layer
const nodesLayer = svg.append('g').attr('class', 'nodes-layer');
// Draw edges
edges.forEach((edge, i) => {
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const isActive = activeEdges.includes(edge.id) || activeEdges.includes(i);
const edgeColor = isActive ? theme.highlight : theme.edgeStroke;
const markerId = isActive ? 'flow-arrow-highlight' : 'flow-arrow';
// Calculate edge path
const x1 = sourceNode.x;
const y1 = sourceNode.y;
const x2 = targetNode.x;
const y2 = targetNode.y;
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const sizeS = effSizes[nodes.indexOf(sourceNode)];
const sizeT = effSizes[nodes.indexOf(targetNode)];
// Control point from the node CENTRES (the authored bulge is a property of
// the node layout, not of where the line happens to be trimmed).
const curvature = edge.curvature || 0.2;
const ctrlX = (x1 + x2) / 2 - dy * curvature;
const ctrlY = (y1 + y2) / 2 + dx * curvature;
// Optional waypoints describe an authored polyline route in diagram
// coordinates. They are useful when a long relationship must route around
// intervening node boxes; existing straight and curved edges keep their
// historical geometry when `via` is omitted.
const via = Array.isArray(edge.via) ? edge.via.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)) : [];
// A curved edge leaves toward its control point and arrives from it, so the
// trim follows those tangents — the head then meets the box square-on
// instead of sliding along the chord. Straight edges trim along the chord.
const dirStart = via.length
? unitDir(via[0].x - x1, via[0].y - y1, dx / len, dy / len)
: edge.curved
? unitDir(ctrlX - x1, ctrlY - y1, dx / len, dy / len)
: [dx / len, dy / len];
const dirEnd = via.length
? unitDir(x2 - via[via.length - 1].x, y2 - via[via.length - 1].y, dx / len, dy / len)
: edge.curved
? unitDir(x2 - ctrlX, y2 - ctrlY, dx / len, dy / len)
: [dx / len, dy / len];
let dStart = boxExit(sizeS, dirStart[0], dirStart[1]) + EDGE_START_GAP;
let dEnd = boxExit(sizeT, dirEnd[0], dirEnd[1]) + EDGE_END_GAP;
// Boxes that overlap (or nearly touch) leave no room for a line: scale both
// trims so a short stub survives instead of crossing the two anchors over.
const room = Math.max(len - 4, 0);
if (dStart + dEnd > room) {
const k = room / (dStart + dEnd);
dStart *= k;
dEnd *= k;
}
const startX = x1 + dirStart[0] * dStart;
const startY = y1 + dirStart[1] * dStart;
const endX = x2 - dirEnd[0] * dEnd;
const endY = y2 - dirEnd[1] * dEnd;
const edgeGroup = edgesLayer.append('g')
.attr('class', `edge ${isActive ? 'highlighted' : ''}`);
if (edge.id) edgeGroup.attr('id', edge.id);
// Draw path
const pathD = via.length
? [`M${startX},${startY}`, ...via.map(p => `L${p.x},${p.y}`), `L${endX},${endY}`].join(' ')
: edge.curved
? `M${startX},${startY} Q${ctrlX},${ctrlY} ${endX},${endY}`
: `M${startX},${startY} L${endX},${endY}`;
const path = edgeGroup.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', edgeColor)
.attr('stroke-width', isActive ? 2.5 : 1.5)
.attr('marker-end', `url(#${markerId})`);
flowEdges.push({path: path.node()});
if (edge.dashed) {
path.attr('stroke-dasharray', '5,3');
}
if (isActive) {
path.attr('filter', `drop-shadow(0 0 4px ${theme.highlightGlow})`);
}
// Add label if present
if (edge.label) {
// A curved edge's caption rides the CURVE, not the chord: on a bowed
// edge the chord midpoint can sit inside an unrelated node box (m29's
// "recall" label was painted underneath the Agent runtime box, i.e.
// invisible) while the curve itself is clear. The quadratic's midpoint
// is (P0 + 2C + P2)/4, and its tangent there is parallel to the chord,
// so the perpendicular offset below stays correct for both cases.
const labelX = edge.curved && !via.length
? (startX + 2 * ctrlX + endX) / 4
: (startX + endX) / 2;
const labelY = edge.curved && !via.length
? (startY + 2 * ctrlY + endY) / 4
: (startY + endY) / 2;
const labelPath = path.node();
const labelAt = labelPath.getPointAtLength(labelPath.getTotalLength() / 2);
const labelBefore = labelPath.getPointAtLength(Math.max(0, labelPath.getTotalLength() / 2 - 1));
const labelAfter = labelPath.getPointAtLength(Math.min(labelPath.getTotalLength(), labelPath.getTotalLength() / 2 + 1));
const angle = Math.atan2(labelAfter.y - labelBefore.y, labelAfter.x - labelBefore.x);
// Offset the caption perpendicular to the line, by the caption's OWN
// support along that direction: the text is horizontal, so a fixed 14px
// offset only clears the line for a horizontal edge — on a vertical edge
// the line ran straight through the caption (m29's "authorized" label sat
// on its own arrow). |nx|·w/2 + |ny|·h/2 + gap reproduces the historical
// ~14px for horizontal edges and widens as the edge turns vertical.
const perpX = Math.sin(angle);
const perpY = -Math.cos(angle);
const labelWidth = measureText(edge.label, {
fontSize: 10,
fontFamily: (svg && svg.style ? svg.style('font-family') : null) || null
});
const offset = Math.abs(perpX) * (labelWidth / 2) + Math.abs(perpY) * 6.5 + 8;
const offsetX = perpX * offset;
const offsetY = perpY * offset;
const labelXPlaced = (via.length ? labelAt.x : labelX) + offsetX + (edge.labelDx || 0);
const labelYPlaced = (via.length ? labelAt.y : labelY) + offsetY + (edge.labelDy || 0);
const labelText = edgeGroup.append('text')
.attr('x', labelXPlaced)
.attr('y', labelYPlaced)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isActive ? theme.highlightText : theme.nodeText)
.style('font-size', '10px')
.text(edge.label);
edgeLabels.push({
node: labelText.node(),
x: labelXPlaced,
y: labelYPlaced,
width: labelWidth,
sourceIndex: nodes.indexOf(sourceNode),
targetIndex: nodes.indexOf(targetNode),
path: path.node()
});
// Opt-in knockout halo (edge.labelHalo) keeps the caption legible where
// another edge or label passes underneath.
if (edge.labelHalo) applyHalo(labelText);
}
});
// Draw nodes
nodes.forEach((node, i) => {
const isActive = activeNodes.includes(node.id) || activeNodes.includes(i);
const nodeFill = isActive ? theme.highlight : theme.nodeFill;
const nodeStroke = isActive ? theme.highlight : theme.nodeStroke;
const textFill = isActive ? theme.textOnHighlight : theme.nodeText;
const {w, h, fontSize, subFontSize} = effSizes[i];
const nodeGroup = nodesLayer.append('g')
.attr('class', `node ${isActive ? 'highlighted' : ''}`)
.attr('transform', `translate(${node.x}, ${node.y})`);
if (node.id) nodeGroup.attr('id', node.id);
// Node rectangle
const rect = nodeGroup.append('rect')
.attr('x', -w / 2)
.attr('y', -h / 2)
.attr('width', w)
.attr('height', h)
.attr('rx', 6)
.attr('ry', 6)
.attr('fill', nodeFill)
.attr('stroke', nodeStroke)
.attr('stroke-width', isActive ? 2 : 1.5);
if (isActive) {
rect.attr('filter', `drop-shadow(0 0 6px ${theme.highlightGlow})`);
}
// Main label
const labelY = node.sublabel ? -6 : 0;
nodeGroup.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', textFill)
.style('font-size', `${fontSize}px`)
.attr('font-weight', '500')
.attr('pointer-events', 'none')
.text(node.label || '');
// Sublabel
if (node.sublabel) {
nodeGroup.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', textFill)
.style('font-size', `${subFontSize}px`)
.attr('opacity', isActive ? 0.9 : 0.7)
.attr('pointer-events', 'none')
.text(node.sublabel);
}
});
// Resolve captions only after every node and edge exists, so every candidate
// can avoid the complete diagram. The authored perpendicular offset and
// labelDx/labelDy remain the base position; with no collision the resolver
// returns it unchanged. Endpoint boxes are excluded because captions belong
// to those edges, while unrelated nodes, other edge paths and sibling
// captions are obstacles.
if (edgeLabels.length) {
const nodeObstacles = nodes.flatMap((node, i) => {
const {w, h} = effSizes[i];
return [{x: node.x - w / 2, y: node.y - h / 2, w, h, index: i}];
});
const pathObstacles = new Map(flowEdges.map(({path}) => [path, _pathObstacleRects(path)]));
edgeLabels.forEach((label, i) => {
const obstacles = nodeObstacles
.filter(box => box.index !== label.sourceIndex && box.index !== label.targetIndex)
.map(({index, ...box}) => box);
flowEdges.forEach(({path}) => {
if (path !== label.path) obstacles.push(...(pathObstacles.get(path) || []));
});
edgeLabels.forEach((other, j) => {
if (i === j) return;
const pos = other.node;
const x = parseFloat(pos.getAttribute('x'));
const y = parseFloat(pos.getAttribute('y'));
obstacles.push(_anchorBox(x, y, other.width, 13, 'middle'));
});
const placed = _resolveLabelPos({
x: label.x,
y: label.y,
w: label.width,
h: 13,
anchor: 'middle',
obstacles,
bounds: _svgBounds(svg.node()),
step: 12,
reach: 4
});
label.node.setAttribute('x', placed.x);
label.node.setAttribute('y', placed.y);
});
}
return svg.node();
}
// =============================================================================
// EXPORTS
// =============================================================================
// Export everything as a single object for lessons to use
diagramLib = {
// Core dependencies
d3,
// Theme utilities
isDarkMode,
getCSSVar,
diagramTheme,
contrastInk,
// Text measurement & fitting
measureText,
estimateTextWidth,
fitFontSize,
wrapText,
wrapByChars,
drawWrappedText,
fitGutter,
// SVG primitives
createNode,
createArrow,
applyHalo,
placeLabel,
collectObstacles,
// Animation controller
createStepController,
// Components
FlowDiagram,
// Figure fit & scroll (anti-clipping)
fitContent,
fitAll,
scrollWrap
}
NoteWhat You’ll Learn
After this module, you will:
- Understand what language models do: next-token prediction
- Know why simple statistical approaches fail at this task
- Grasp how neural networks learn from data (training loop intuition)
- Have a mental model of transformer architecture
- See the roadmap of what you’ll build in this course
Tokenizer = (await import('../../playground/tokenizer.js')).Tokenizer
GPTModel = (await import('../../playground/model.js')).GPTModel
// Load data files
tokenizerData = FileAttachment("../../playground/tokenizer.json").json()
weightsData = FileAttachment("../../playground/weights.json").json()
// Initialize tokenizer and model
tokenizer = new Tokenizer(tokenizerData)
model = new GPTModel(weightsData)A language model predicts the next token.
Given the text def hello(, what comes next? A language model outputs a probability distribution over all possible tokens:
| Token | Probability |
|---|---|
) |
31% |
name |
18% |
self |
12% |
x |
8% |
| … | … |
Notice three points:
Tokens aren’t words. The model works with subword pieces - fragments smaller than words.
hellomight becomehel+lo. This keeps the vocabulary manageable - typically 30,000 to 50,000 tokens - yet handles any text, including rare words and unfamiliar code.The output is probabilities, not a single answer. The model expresses uncertainty. Sometimes
)is clearly right; sometimes several options make sense.This simple task scales remarkably. Predicting the next token well requires understanding syntax, semantics, context, and even reasoning. A model that excels at this task can write code, answer questions, and hold conversations. This is how GitHub Copilot suggests completions, how ChatGPT generates responses, and how your IDE’s autocomplete works - all next-token prediction at scale.
Try It
// Compute predictions
predictions = {
const tok = await tokenizer;
const mod = await model;
if (!inputText || inputText.length === 0) {
return [];
}
const ids = tok.encode(inputText);
const trace = mod.forward(ids);
const logits = trace.logits;
// Get last position logits
const vocabSize = logits.shape[1];
const lastLogits = [];
const offset = (logits.shape[0] - 1) * vocabSize;
for (let i = 0; i < vocabSize; i++) {
lastLogits.push(logits.data[offset + i]);
}
// Softmax
const maxLogit = Math.max(...lastLogits);
const expLogits = lastLogits.map(l => Math.exp(l - maxLogit));
const sumExp = expLogits.reduce((a, b) => a + b, 0);
const probs = expLogits.map(e => e / sumExp);
// Get top 5
const indexed = probs.map((p, i) => ({ prob: p, id: i }));
indexed.sort((a, b) => b.prob - a.prob);
const top5 = indexed.slice(0, 5);
return top5.map(({ prob, id }) => ({
token: tok.idToToken(id).replace(/ /g, '␣'),
probability: (prob * 100).toFixed(1) + '%'
}));
}// Display predictions as a simple table
predictionDisplay = {
if (predictions.length === 0) {
return html`<p style="color: var(--text-muted);">Type something to see predictions.</p>`;
}
return html`
<div style="
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
">
${predictions.map((p, i) => html`
<div style="
padding: 8px 16px;
background: ${i === 0 ? 'var(--diagram-highlight, #f97316)' : 'var(--surface-tertiary, #f1f5f9)'};
color: ${i === 0 ? 'var(--diagram-text-on-highlight, #1c1917)' : 'var(--text-primary, #1e293b)'};
border-radius: 6px;
font-family: var(--pg-mono);
font-size: 14px;
">
<span style="font-weight: 600;">${p.token}</span>
<span style="opacity: 0.8; margin-left: 8px;">${p.probability}</span>
</div>
`)}
</div>
`;
}Try it: Type a function definition like def add( and notice how the model predicts argument names. Then try for i in - the predictions shift based on what typically follows loop constructs.
Predicting the next token sounds simple. How do you do it well?
Why Simple Approaches Fail
The obvious approach: count what tokens typically follow other tokens. After seeing for i in a million times, you learn that range follows.
This is called an n-gram model. It looks at the last N tokens to predict the next one. Simple, fast, and works surprisingly well for common patterns.
But n-grams have a fundamental limitation: they see only a fixed window of tokens.
The Context Problem
Consider this code:
What token fills the blank? A human immediately knows the answer is n - it’s the loop variable. An n-gram model looking at just total += guesses blindly - perhaps 1, x, or value, tokens that commonly follow +=.
N-gram vs Transformer: A Comparison
comparisonExamples = [
{
id: 'loop',
name: 'Loop Variable',
code: 'def calculate_sum(numbers):\n total = 0\n for n in numbers:\n total += █',
ngram: [
{ token: '1', prob: '24%', reason: 'common increment' },
{ token: 'x', prob: '18%', reason: 'common variable' },
{ token: 'value', prob: '15%', reason: 'common name' }
],
transformer: [
{ token: 'n', prob: '72%', reason: 'sees loop variable' },
{ token: 'numbers', prob: '8%', reason: 'sees parameter' },
{ token: '1', prob: '5%', reason: 'fallback' }
],
insight: 'The transformer attends to the loop definition and knows `n` is the iteration variable.'
},
{
id: 'return',
name: 'Return Value',
code: 'if x > 0:\n return x\nelse:\n return █',
ngram: [
{ token: '0', prob: '28%', reason: 'common return' },
{ token: 'None', prob: '22%', reason: 'common return' },
{ token: 'False', prob: '14%', reason: 'common return' }
],
transformer: [
{ token: '-x', prob: '45%', reason: 'sees negation pattern' },
{ token: '0', prob: '25%', reason: 'conditional default' },
{ token: '-1', prob: '12%', reason: 'error sentinel' }
],
insight: 'The transformer recognizes the absolute value pattern from the `if x > 0` condition.'
},
{
id: 'comment',
name: 'Following Comments',
code: '# reverse a string\ndef █',
ngram: [
{ token: 'main', prob: '15%', reason: 'common function' },
{ token: 'get', prob: '12%', reason: 'common prefix' },
{ token: 'calculate', prob: '10%', reason: 'common prefix' }
],
transformer: [
{ token: 'reverse', prob: '68%', reason: 'matches comment' },
{ token: 'rev', prob: '12%', reason: 'abbreviation' },
{ token: 'flip', prob: '6%', reason: 'synonym' }
],
insight: 'The transformer reads the comment and predicts a function name that matches its intent.'
}
]
viewof selectedExample = Inputs.radio(
comparisonExamples.map(e => e.id),
{
label: "Choose an example:",
value: 'loop',
format: id => comparisonExamples.find(e => e.id === id).name
}
)// Display the comparison widget
comparisonWidget = {
const example = comparisonExamples.find(e => e.id === selectedExample);
const t = diagramTheme;
return html`
<div style="margin: 20px 0;">
<pre style="
background: var(--surface-secondary, #1e293b);
color: var(--text-primary, #e2e8f0);
padding: 16px;
border-radius: 8px;
font-family: var(--pg-mono);
font-size: 14px;
line-height: 1.5;
overflow-x: auto;
margin-bottom: 20px;
">${example.code}</pre>
<div style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
">
<div style="
background: var(--surface-tertiary, #f1f5f9);
border-radius: 8px;
padding: 16px;
border-top: 4px solid var(--diagram-edge-stroke, #94a3b8);
">
<div style="
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 12px;
font-size: 14px;
">N-gram Model <span style="opacity: 0.6; font-weight: 400;">(sees last ~3 tokens)</span></div>
${example.ngram.map((p, i) => html`
<div style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin-bottom: 6px;
background: ${i === 0 ? 'var(--diagram-node-fill-alt, rgba(148, 163, 184, 0.3))' : 'transparent'};
border-radius: 4px;
">
<span style="
font-family: var(--pg-mono);
font-weight: 500;
">${p.token}</span>
<span style="
font-size: 13px;
color: var(--text-secondary, #64748b);
">${p.prob} <span style="opacity: 0.7;">· ${p.reason}</span></span>
</div>
`)}
</div>
<div style="
background: var(--surface-tertiary, #f1f5f9);
border-radius: 8px;
padding: 16px;
border-top: 4px solid var(--diagram-highlight, #f97316);
">
<div style="
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 12px;
font-size: 14px;
">Transformer <span style="opacity: 0.6; font-weight: 400;">(sees all context)</span></div>
${example.transformer.map((p, i) => html`
<div style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin-bottom: 6px;
background: ${i === 0 ? 'var(--diagram-highlight-bg, rgba(249, 115, 22, 0.2))' : 'transparent'};
border-radius: 4px;
">
<span style="
font-family: var(--pg-mono);
font-weight: 500;
color: ${i === 0 ? 'var(--diagram-highlight, #f97316)' : 'inherit'};
">${p.token}</span>
<span style="
font-size: 13px;
color: var(--text-secondary, #64748b);
">${p.prob} <span style="opacity: 0.7;">· ${p.reason}</span></span>
</div>
`)}
</div>
</div>
<div style="
margin-top: 16px;
padding: 12px 16px;
background: linear-gradient(90deg, rgba(249, 115, 22, 0.1), transparent);
border-left: 3px solid var(--diagram-highlight, #f97316);
border-radius: 0 6px 6px 0;
font-size: 14px;
color: var(--text-primary, #334155);
">
<strong>Why the difference?</strong> ${example.insight}
</div>
</div>
`;
}The answer depends on context from ten or more tokens earlier. N-grams cannot reach that far. Increase the window size and another problem emerges: this exact sequence is new, so statistics offer no guidance.
Language is full of these long-range dependencies:
- Matching brackets and parentheses
- Variable references spanning multiple lines
- Pronouns referring to earlier nouns
- Comments describing code that follows
A good language model must consider all previous tokens and learn which ones matter for each prediction.
Neural Networks: Learning from Data
N-grams count patterns; neural networks learn them. The difference is fundamental.
A neural network is a function with adjustable parameters. Feed it an input, it produces an output. These adjustable parameters are called weights - numbers multiplied with inputs and summed. They determine which function the network computes, and we adjust them so the function does what we want.
nnLayerNames = ["Input Layer", "Hidden Layer", "Output Layer"]
nnLayerDescs = [
"Raw input data enters here. For text, this might be token embeddings - vectors representing each word.",
"Where the learning happens. Neurons multiply inputs by weights, sum them up, then apply activation functions (like ReLU). Without these non-linearities, stacking layers would be pointless - multiple linear operations just collapse into one.",
"Produces the final result. For language models, this is a probability distribution over possible next tokens."
]
currentNNLayer = {
return {
name: nnLayerNames[nnLayer],
desc: nnLayerDescs[nnLayer]
};
}// Simple neural network visualization
nnDiagram = {
const width = 600;
const height = 280;
const t = diagramTheme;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.classed('diagram-fig', true); // managed figure — opts out of the blanket svg-text resets in custom.scss; every text fill here already reads a diagramTheme token
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', t.bg)
.attr('rx', 8);
// Layer configuration
const layers = [
{ x: 100, neurons: 3, label: 'Input' },
{ x: 300, neurons: 4, label: 'Hidden' },
{ x: 500, neurons: 2, label: 'Output' }
];
const neuronRadius = 18;
const verticalSpacing = 55;
// Draw connections first (behind neurons)
for (let l = 0; l < layers.length - 1; l++) {
const fromLayer = layers[l];
const toLayer = layers[l + 1];
const fromYStart = (height - (fromLayer.neurons - 1) * verticalSpacing) / 2;
const toYStart = (height - (toLayer.neurons - 1) * verticalSpacing) / 2;
for (let i = 0; i < fromLayer.neurons; i++) {
for (let j = 0; j < toLayer.neurons; j++) {
const fromY = fromYStart + i * verticalSpacing;
const toY = toYStart + j * verticalSpacing;
// Highlight connections from/to highlighted layer
const isHighlighted = (nnLayer === l) || (nnLayer === l + 1);
svg.append('line')
.attr('x1', fromLayer.x + neuronRadius)
.attr('y1', fromY)
.attr('x2', toLayer.x - neuronRadius)
.attr('y2', toY)
.attr('stroke', isHighlighted ? t.highlight : t.edgeStroke)
.attr('stroke-width', isHighlighted ? 1.5 : 0.8)
.attr('opacity', isHighlighted ? 0.7 : 0.3);
}
}
}
// Draw neurons
layers.forEach((layer, l) => {
const yStart = (height - (layer.neurons - 1) * verticalSpacing) / 2;
const isActive = nnLayer === l;
for (let i = 0; i < layer.neurons; i++) {
const y = yStart + i * verticalSpacing;
// Neuron circle
svg.append('circle')
.attr('cx', layer.x)
.attr('cy', y)
.attr('r', neuronRadius)
.attr('fill', isActive ? t.highlight : t.nodeFill)
.attr('stroke', isActive ? t.highlight : t.nodeStroke)
.attr('stroke-width', isActive ? 2 : 1.5);
// Show weights symbol in hidden layer
if (l === 1 && isActive) {
svg.append('text')
.attr('x', layer.x)
.attr('y', y)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', t.textOnHighlight)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text('w');
}
}
// Layer label
svg.append('text')
.attr('x', layer.x)
.attr('y', height - 20)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? t.highlightText : t.nodeText)
.attr('font-size', '12px')
.attr('font-weight', isActive ? '600' : '400')
.text(layer.label);
});
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 25)
.attr('text-anchor', 'middle')
.attr('fill', t.nodeText)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text('A Simple Neural Network');
return svg.node();
}// Layer description display
nnLayerDisplay = {
return html`
<div style="
margin-top: 16px;
padding: 16px 20px;
background: var(--surface-secondary, #f8fafc);
border-radius: 8px;
border-left: 4px solid var(--diagram-highlight, #f97316);
">
<div style="
font-weight: 600;
font-size: 15px;
color: var(--text-primary, #1e293b);
margin-bottom: 4px;
">${currentNNLayer.name}</div>
<div style="
font-size: 14px;
color: var(--text-secondary, #475569);
">${currentNNLayer.desc}</div>
</div>
`;
}How Neural Networks Learn
This process matters because transformers are neural networks. The same training loop you’ll see here is exactly how we’ll train our language model in Module 07.
Training follows a simple loop:
- Forward pass: Feed input through the network, get a prediction
- Compute loss: Measure how wrong the prediction is (the loss function quantifies prediction quality)
- Backward pass: Calculate how each weight contributed to the error (these sensitivity values are called gradients)
- Update weights: Nudge weights in the direction that reduces error
Repeat with millions of examples. For language models, this means feeding billions of tokens from books, code, and web text, learning from each wrong prediction. The weights shift until the network predicts well.
(You’ll implement the backward pass yourself in Module 02: Autograd. PyTorch computes gradients automatically, but implementing them sharpens intuition.)
// Training loop visualization
trainingLoopDiagram = {
const width = 600;
const height = 180;
const t = diagramTheme;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.classed('diagram-fig', true); // managed figure — opts out of the blanket svg-text resets in custom.scss; every text fill here already reads a diagramTheme token
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', t.bg)
.attr('rx', 8);
// Steps
const steps = [
{ x: 70, label: 'Input', sublabel: '"def hello("' },
{ x: 185, label: 'Forward', sublabel: 'predict' },
{ x: 300, label: 'Loss', sublabel: 'how wrong?' },
{ x: 415, label: 'Backward', sublabel: 'gradients' },
{ x: 530, label: 'Update', sublabel: 'fix weights' }
];
const y = 90;
const boxWidth = 85;
const boxHeight = 50;
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'train-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', t.edgeStroke);
// Draw arrows between steps
for (let i = 0; i < steps.length - 1; i++) {
svg.append('line')
.attr('x1', steps[i].x + boxWidth/2 + 5)
.attr('y1', y)
.attr('x2', steps[i + 1].x - boxWidth/2 - 10)
.attr('y2', y)
.attr('stroke', t.edgeStroke)
.attr('stroke-width', 1.5)
.attr('marker-end', 'url(#train-arrow)');
}
// Loop back arrow (curved)
svg.append('path')
.attr('d', `M ${steps[4].x} ${y + boxHeight/2 + 10}
Q ${steps[4].x} ${y + 60} ${steps[2].x} ${y + 60}
Q ${steps[0].x} ${y + 60} ${steps[0].x} ${y + boxHeight/2 + 10}`)
.attr('fill', 'none')
.attr('stroke', t.accent)
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', '5,3')
.attr('marker-end', 'url(#train-arrow)');
// "repeat" label
svg.append('text')
.attr('x', width / 2)
.attr('y', y + 70)
.attr('text-anchor', 'middle')
.attr('fill', t.accentText)
.attr('font-size', '11px')
.attr('font-style', 'italic')
.text('repeat millions of times');
// Draw step boxes
steps.forEach((step, i) => {
const g = svg.append('g')
.attr('transform', `translate(${step.x}, ${y})`);
g.append('rect')
.attr('x', -boxWidth/2)
.attr('y', -boxHeight/2)
.attr('width', boxWidth)
.attr('height', boxHeight)
.attr('rx', 6)
.attr('fill', i === 2 ? t.highlight : t.nodeFill)
.attr('stroke', i === 2 ? t.highlight : t.nodeStroke)
.attr('stroke-width', 1.5);
g.append('text')
.attr('y', -8)
.attr('text-anchor', 'middle')
.attr('fill', i === 2 ? t.textOnHighlight : t.nodeText)
.attr('font-size', '12px')
.attr('font-weight', '600')
.text(step.label);
g.append('text')
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('fill', i === 2 ? t.textOnHighlight : t.nodeText)
.attr('font-size', '10px')
.attr('opacity', i === 2 ? 0.9 : 0.7)
.text(step.sublabel);
});
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 25)
.attr('text-anchor', 'middle')
.attr('fill', t.nodeText)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text('The Training Loop');
return svg.node();
}Why This Matters for Language
Neural networks do not merely memorize patterns as n-grams do. They learn representations - internal encodings where similar concepts cluster together.
Consider the word “cat”: - An n-gram sees only the characters c-a-t - A neural network learns a vector where “cat” is close to “dog”, “kitten”, and “pet” but far from “quantum” and “derivative”
In transformers, these representations become even richer: the same word gets different vectors depending on context. “Bank” near “river” differs from “bank” near “money.”
N-grams lack this capacity entirely. They treat “cat sat on the mat” and “dog sat on the rug” as completely unrelated sequences. Neural networks recognize the structural similarity and generalize patterns from one to the other.
This representation learning lets neural networks: - Generalize to unseen word combinations - Handle context by learning which parts of the input matter - Scale with more data and compute
Transformers are neural networks distinguished by how they handle context: through attention.
The Transformer Solution
Attention examines all previous tokens and learns which ones matter.
The architecture flows like this:
stageDescriptions = [
{ stage: "Input", desc: "Text enters as token IDs (integers)" },
{ stage: "Embedding", desc: "Each ID becomes a dense vector (e.g., 768 numbers). These vectors are learned - the model discovers which tokens should have similar vectors based on usage patterns. Words like 'run' and 'jog' end up close together." },
{ stage: "Attention", desc: "Each token gathers information from relevant previous tokens. Here, the pronoun 'it' discovers its referent, and `total +=` finds the loop variable `n`." },
{ stage: "Feed-Forward", desc: "Process each token's combined information through a small neural network. This is where the model 'thinks' about what it learned from attention." },
{ stage: "Repeat", desc: "Stack multiple layers to build deeper understanding. Early layers recognize syntax; later layers grasp meaning." },
{ stage: "Output", desc: "Final vectors become probabilities over the vocabulary" }
]
currentStage = stageDescriptions[animStep]// Animated architecture diagram
architectureDiagram = {
const width = 700;
const height = 200;
const t = diagramTheme;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', t.bg)
.attr('rx', 8);
// Node positions
const nodes = [
{ id: 0, x: 60, y: 100, label: 'Input', sublabel: 'Token IDs' },
{ id: 1, x: 180, y: 100, label: 'Embedding', sublabel: 'Vectors' },
{ id: 2, x: 320, y: 100, label: 'Attention', sublabel: 'Context' },
{ id: 3, x: 460, y: 100, label: 'Feed-Forward', sublabel: 'Process' },
{ id: 4, x: 540, y: 50, label: '×N', sublabel: 'Layers' },
{ id: 5, x: 640, y: 100, label: 'Output', sublabel: 'Probabilities' }
];
// Draw edges first
const edges = [
{ from: 0, to: 1 },
{ from: 1, to: 2 },
{ from: 2, to: 3 },
{ from: 3, to: 5 }
];
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'arch-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', t.edgeStroke);
defs.append('marker')
.attr('id', 'arch-arrow-highlight')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', t.highlight);
edges.forEach(e => {
const from = nodes[e.from];
const to = nodes[e.to];
const isActive = animStep >= e.from && animStep <= e.to;
svg.append('line')
.attr('x1', from.x + 50)
.attr('y1', from.y)
.attr('x2', to.x - 50)
.attr('y2', to.y)
.attr('stroke', isActive ? t.highlight : t.edgeStroke)
.attr('stroke-width', isActive ? 2.5 : 1.5)
.attr('marker-end', isActive ? 'url(#arch-arrow-highlight)' : 'url(#arch-arrow)');
});
// Loop arrow for "Repeat" (curved)
if (animStep >= 4) {
const loopPath = svg.append('path')
.attr('d', 'M 470,70 Q 500,20 530,50')
.attr('fill', 'none')
.attr('stroke', animStep === 4 ? t.highlight : t.edgeStroke)
.attr('stroke-width', animStep === 4 ? 2.5 : 1.5)
.attr('stroke-dasharray', '4,2');
}
// Draw nodes
nodes.forEach((node, i) => {
const isActive = animStep === i;
const nodeWidth = i === 4 ? 50 : 90;
const nodeHeight = i === 4 ? 35 : 50;
const g = svg.append('g')
.attr('transform', `translate(${node.x}, ${node.y})`);
g.append('rect')
.attr('x', -nodeWidth/2)
.attr('y', -nodeHeight/2)
.attr('width', nodeWidth)
.attr('height', nodeHeight)
.attr('rx', 6)
.attr('fill', isActive ? t.highlight : t.nodeFill)
.attr('stroke', isActive ? t.highlight : t.nodeStroke)
.attr('stroke-width', isActive ? 2 : 1.5);
g.append('text')
.attr('y', node.sublabel && i !== 4 ? -6 : 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isActive ? t.textOnHighlight : t.nodeText)
.attr('font-size', i === 4 ? '14px' : '12px')
.attr('font-weight', '500')
.text(node.label);
if (node.sublabel && i !== 4) {
g.append('text')
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isActive ? t.textOnHighlight : t.nodeText)
.attr('font-size', '10px')
.attr('opacity', 0.7)
.text(node.sublabel);
}
});
return svg.node();
}// Attention visualization - shows when on step 2
attentionViz = {
if (animStep !== 2) return html``;
const t = diagramTheme;
const tokens = ['The', 'cat', 'sat', 'on', 'the', 'mat', 'because', 'it', 'was', 'tired'];
const targetIdx = 7; // "it"
// Attention weights from "it" to previous tokens
const weights = [0.05, 0.62, 0.08, 0.02, 0.03, 0.12, 0.05, 0, 0, 0];
const width = 700;
const height = 120;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', t.bg)
.attr('rx', 8);
const tokenSpacing = 65;
const startX = 30;
const tokenY = 80;
const targetY = 35;
// Draw tokens
tokens.forEach((token, i) => {
const x = startX + i * tokenSpacing;
const weight = weights[i];
const isTarget = i === targetIdx;
// Token box
svg.append('rect')
.attr('x', x)
.attr('y', isTarget ? targetY - 12 : tokenY - 12)
.attr('width', 55)
.attr('height', 24)
.attr('rx', 4)
.attr('fill', isTarget ? t.highlight : (weight > 0.3 ? t.highlightBg : t.nodeFill))
.attr('stroke', isTarget ? t.highlight : (weight > 0.3 ? t.highlight : t.nodeStroke))
.attr('stroke-width', isTarget ? 2 : 1);
// Token text
svg.append('text')
.attr('x', x + 27.5)
.attr('y', isTarget ? targetY : tokenY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isTarget ? t.textOnHighlight : t.nodeText)
.attr('font-size', '12px')
.style('font-family', 'var(--pg-mono)')
.text(token);
// Draw attention line from target to this token
if (i < targetIdx && weight > 0) {
const targetX = startX + targetIdx * tokenSpacing + 27.5;
const sourceX = x + 27.5;
svg.append('line')
.attr('x1', sourceX)
.attr('y1', tokenY - 14)
.attr('x2', targetX)
.attr('y2', targetY + 14)
.attr('stroke', t.highlight)
.attr('stroke-width', Math.max(0.5, weight * 5))
.attr('opacity', 0.4 + weight * 0.6);
}
});
// Label
svg.append('text')
.attr('x', startX + targetIdx * tokenSpacing + 27.5)
.attr('y', targetY - 25)
.attr('text-anchor', 'middle')
.attr('fill', t.highlightText)
.attr('font-size', '10px')
.attr('font-weight', '500')
.text('predicting next token');
return svg.node();
}// Attention explanation
attentionExplanation = {
if (animStep !== 2) return html``;
return html`
<div style="
margin-top: 12px;
padding: 12px 16px;
background: var(--diagram-highlight-bg, rgba(249, 115, 22, 0.1));
border-radius: 6px;
font-size: 13px;
color: var(--text-primary, #334155);
">
<strong>Attention in action:</strong> When predicting what comes after "it", the model attends strongly to "cat" (62%) because "it" likely refers to the cat. The line thickness shows attention weight - the model learned this connection, it wasn't programmed.
</div>
`;
}// Stage description display
stageDisplay = {
return html`
<div style="
margin-top: 16px;
padding: 16px 20px;
background: var(--surface-secondary, #f8fafc);
border-radius: 8px;
border-left: 4px solid var(--diagram-highlight, #f97316);
">
<div style="
font-weight: 600;
font-size: 15px;
color: var(--text-primary, #1e293b);
margin-bottom: 4px;
">${currentStage.stage}</div>
<div style="
font-size: 14px;
color: var(--text-secondary, #475569);
">${currentStage.desc}</div>
</div>
`;
}Three key insights:
Full context visibility. Older models read left-to-right, one token at a time. Transformers see the entire input at once. Each layer processes all positions simultaneously, making them highly parallelizable and efficient.
Learned relevance. The attention mechanism learns which tokens matter for each prediction. When predicting after
total +=, it learns to focus on the loop variablen, not the function name.Stacked layers. Multiple transformer layers build increasingly abstract representations. Early layers recognize syntax; later layers grasp meaning.
The modules ahead build each component from scratch.
What You’ll Build
Each module tackles one piece of this architecture:
moduleData = [
{ id: '01', name: 'Tensors', desc: 'The n-dimensional arrays that hold everything - weights, activations, gradients', cat: 'foundation' },
{ id: '02', name: 'Autograd', desc: 'How models learn: automatic gradients without manual calculus', cat: 'foundation' },
{ id: '03', name: 'Tokenization', desc: 'Why "hello" becomes [15339] and how subwords handle any text', cat: 'input' },
{ id: '04', name: 'Embeddings', desc: 'Where meaning lives: turning IDs into rich vector representations', cat: 'model' },
{ id: '05', name: 'Attention', desc: 'The mechanism that lets "it" know what "it" refers to', cat: 'model' },
{ id: '06', name: 'Transformer', desc: 'Putting it all together into a complete architecture', cat: 'model' },
{ id: '07', name: 'Training', desc: 'Teaching the model to predict well through gradient descent', cat: 'training' },
{ id: '08', name: 'Generation', desc: 'Sampling strategies: from greedy to nucleus sampling', cat: 'output' }
]
// Category → phase-palette token (DESIGN_LANGUAGE §2). Each roadmap category
// names a stage of the pipeline the book teaches, so it wears that stage's
// phase hue instead of an ad-hoc tint. One --ph-* token per category; the
// card's tint, border, and label are all derived from it via color-mix, so
// they stay theme-correct in light AND dark with no per-theme hexes.
catPhase = ({
foundation: 'norm', // slate — the structural base (tensors, autograd)
input: 'tok', // violet — tokenization
model: 'attn', // sky — the core model (embeddings, attention, transformer)
training: 'mlp', // teal — training / optimization
output: 'sample' // rose — generation / sampling
})
// Module directory name mapping
moduleNames = ({
'01': 'tensors',
'02': 'autograd',
'03': 'tokenization',
'04': 'embeddings',
'05': 'attention',
'06': 'transformer',
'07': 'training',
'08': 'generation'
})// Render module roadmap
roadmapDisplay = {
// Re-render on theme flip (idiomatic); the colors themselves are pure CSS
// color-mix over the --ph-* / --surface-* / --text-* tokens, so they track
// the theme even without a re-render.
diagramTheme;
return html`
<div style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
margin: 20px 0;
">
${moduleData.map(m => {
// Phase token for this category, then derive tint / border / label from
// it. 70% mix with --text-primary matches the callout-title idiom and
// clears WCAG AA on the tinted surface in both themes (DESIGN_LANGUAGE §6).
const phase = `var(--ph-${catPhase[m.cat]}, #64748b)`;
const bgColor = `color-mix(in srgb, ${phase} 10%, var(--surface-secondary, #f8fafc))`;
const borderColor = phase;
const textColor = `color-mix(in srgb, ${phase} 70%, var(--text-primary, #1e293b))`;
return html`
<a href="../m${m.id}_${moduleNames[m.id]}/lesson.html" style="
display: block;
padding: 16px;
background: ${bgColor};
border-left: 4px solid ${borderColor};
border-radius: 6px;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.1)';"
onmouseout="this.style.transform='none'; this.style.boxShadow='none';">
<div style="
font-size: 13px;
font-weight: 600;
color: ${textColor};
margin-bottom: 4px;
font-family: var(--pg-mono);
">Module ${m.id}</div>
<div style="
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 6px;
">${m.name}</div>
<div style="
font-size: 13px;
color: var(--text-secondary, #475569);
">${m.desc}</div>
</a>
`;
})}
</div>
`;
}Each module builds on the previous ones. By the end, you’ll have a working language model you fully understand - something you built piece by piece.
NoteKey Takeaways
- Language models predict the next token from a probability distribution over the vocabulary
- Simple counting (n-grams) fails because language has long-range dependencies that demand variable context
- Neural networks learn patterns through iterative weight updates - they don’t just memorize, they generalize
- Transformers use attention to dynamically focus on relevant context, however distant
- You’ll build each component from scratch in the modules ahead, from tensors to text generation