diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml
index c08ba9c..5056b01 100644
--- a/.github/workflows/book.yml
+++ b/.github/workflows/book.yml
@@ -8,7 +8,8 @@ on:
branches: [main]
paths:
- 'src/content/**'
- - 'scripts/emit-latex.ts'
+ - 'src/figures/**'
+ - 'scripts/**'
- '.github/workflows/book.yml'
workflow_dispatch:
@@ -22,6 +23,8 @@ jobs:
node-version: 24
cache: npm
- run: npm ci
+ - name: Install rsvg (SVG → PDF for figures)
+ run: sudo apt-get update && sudo apt-get install -y librsvg2-bin
- name: Emit LaTeX
run: npm run book:tex
- uses: wtfjoke/setup-tectonic@v3
diff --git a/package.json b/package.json
index f1f53dd..b87c225 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
"test": "vitest run",
- "book:tex": "vite-node scripts/emit-latex.ts",
+ "book:tex": "vite-node scripts/emit-figures.ts && vite-node scripts/emit-latex.ts",
"book": "npm run book:tex && tectonic book/gradient-lab.tex"
},
"devDependencies": {
diff --git a/scripts/emit-figures.ts b/scripts/emit-figures.ts
new file mode 100644
index 0000000..6c96374
--- /dev/null
+++ b/scripts/emit-figures.ts
@@ -0,0 +1,23 @@
+/**
+ * Renders every split figure in the print theme and converts it to PDF for
+ * the book (rsvg-convert; brew librsvg locally, librsvg2-bin in CI). Runs
+ * before emit-latex via `npm run book:tex`.
+ */
+
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { figureSvgs } from '../src/figures';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = join(here, '..', 'book', 'figures');
+mkdirSync(outDir, { recursive: true });
+
+for (const [id, render] of Object.entries(figureSvgs)) {
+ const svgPath = join(outDir, `${id}.svg`);
+ writeFileSync(svgPath, render('print'));
+ execFileSync('rsvg-convert', ['-f', 'pdf', '-o', join(outDir, `${id}.pdf`), svgPath]);
+ console.log(`figures/${id}.pdf`);
+}
diff --git a/scripts/emit-latex.ts b/scripts/emit-latex.ts
index 9f0ada1..6dd831c 100644
--- a/scripts/emit-latex.ts
+++ b/scripts/emit-latex.ts
@@ -23,6 +23,7 @@ import { chRefs } from '../src/content/chapterRefs';
import { optTree, OPT_CITE } from '../src/content/optimizerCards';
import { problemCards } from '../src/content/problemCards';
import { richToTex, escapeTex } from '../src/content/rich';
+import { figureSvgs } from '../src/figures';
const SITE = 'https://gradientlab.ai';
@@ -116,11 +117,17 @@ function emitBlocks(blocks: Block[], slug: string): string {
}
function emitFigure(id: string, caption: Rich, opts?: { inBox?: boolean }): string {
+ // Figures whose compute/render split has landed print for real; the rest
+ // keep their labelled placeholder.
+ const body =
+ id in figureSvgs
+ ? `\\includegraphics[width=0.9\\linewidth]{figures/${id}.pdf}`
+ : `\\figplaceholder{${id}}`;
if (opts?.inBox) {
// Floats can't live inside a box; memoir's \legend gives the caption.
return [
'\\begin{center}',
- `\\figplaceholder{${id}}`,
+ body,
`\\legend{${richToTex(caption)}}`,
'\\end{center}'
].join('\n');
@@ -128,7 +135,7 @@ function emitFigure(id: string, caption: Rich, opts?: { inBox?: boolean }): stri
return [
'\\begin{figure}[htbp]',
'\\centering',
- `\\figplaceholder{${id}}`,
+ body,
`\\caption{${richToTex(caption)}}`,
`\\label{fig:${id}}`,
'\\end{figure}'
diff --git a/src/components/HelpModal.svelte b/src/components/HelpModal.svelte
index be2dd78..3bae7d4 100644
--- a/src/components/HelpModal.svelte
+++ b/src/components/HelpModal.svelte
@@ -33,6 +33,7 @@
import { optimizers, optimizerOrder, defaultHyper, type OptimizerId } from '../utils/optimizers';
import GuideVizLayers from './GuideVizLayers.svelte';
import GuideBlocks from './GuideBlocks.svelte';
+ import { figureSvgs } from '../figures';
import ChapterCta from './ChapterCta.svelte';
import { enterCourseFromChapter, startCourseIntro } from '../utils/lessons';
import {
@@ -593,110 +594,6 @@
// tangent — the limit, drawn. Real y = x² geometry, not a sketch. Chords
// run point-to-point on the curve (no stray line ends); only the tangent
// extends, gently, past the anchor.
- const secantFig = (() => {
- const W = 300, H = 140, a = 1.0;
- const xMin = -0.3, xMax = 3.2, yMax = xMax * xMax;
- const px = (x: number) => 14 + ((x - xMin) / (xMax - xMin)) * (W - 28);
- const py = (y: number) => H - 20 - (y / yMax) * (H - 40);
- const N = 48;
- const curve = 'M ' + Array.from({ length: N + 1 }, (_, i) => {
- const x = xMin + (i / N) * (xMax - xMin);
- return `${px(x).toFixed(1)},${py(x * x).toFixed(1)}`;
- }).join(' L ');
- const at = (x: number) => ({ x: px(x), y: py(x * x) });
- const chords = [1.9, 1.15, 0.6].map((h, i) => ({
- x1: at(a).x, y1: at(a).y, x2: at(a + h).x, y2: at(a + h).y,
- end: at(a + h), o: 0.3 + i * 0.18
- }));
- // The tangent (slope 2a), drawn a touch past the point on both sides.
- const tan = (x: number) => a * a + 2 * a * (x - a);
- const tangent = { x1: px(a - 0.75), y1: py(tan(a - 0.75)), x2: px(a + 1.15), y2: py(tan(a + 1.15)) };
- // The sweep: a quiet curved arrow from the outermost chord down onto the
- // tangent — the direction "h → 0" travels, so the reader doesn't have to
- // infer that top-to-bottom means the nudge shrinking.
- const lerp = (A: { x: number; y: number }, B: { x: number; y: number }, t: number) =>
- ({ x: A.x + (B.x - A.x) * t, y: A.y + (B.y - A.y) * t });
- const s0 = lerp({ x: chords[0].x1, y: chords[0].y1 }, chords[0].end, 0.62);
- const s1 = { x: px(a + 0.92), y: py(tan(a + 0.92)) - 5 };
- const mid = lerp(s0, s1, 0.5);
- const nx = s1.y - s0.y, ny = -(s1.x - s0.x);
- const nm = Math.hypot(nx, ny);
- const ctrl = { x: mid.x + (nx / nm) * 16, y: mid.y + (ny / nm) * 16 };
- const sweep = `M ${s0.x.toFixed(1)},${s0.y.toFixed(1)} Q ${ctrl.x.toFixed(1)},${ctrl.y.toFixed(1)} ${s1.x.toFixed(1)},${s1.y.toFixed(1)}`;
- return { W, H, curve, chords, tangent, p: at(a), pEnd: at(a + 1.9), sweep, ctrl };
- })();
-
- // Curvature figure A: same slope underfoot, two different futures — a tight
- // curve and a relaxed one sharing one tangent at the marked point. Curve
- // ends are where the labels live, clear of all three lines.
- const bendFig = (() => {
- // Screen y grows DOWNWARD, so a loss curve that bends UP (positive λ)
- // needs its quadratic term SUBTRACTED. Staged as a descent to the right:
- // the sharp curve bottoms out and curls back up; the gentle one keeps
- // rolling — same tangent at the marker.
- const W = 300, H = 126, x0 = 104, y0 = 82, m = 0.35;
- const mk = (c: number, lo: number, hi: number) => {
- const f = (dx: number) => y0 + m * dx - c * dx * dx;
- const pts: string[] = [];
- for (let dx = lo; dx <= hi; dx += 4) pts.push(`${(x0 + dx).toFixed(1)},${f(dx).toFixed(1)}`);
- return { d: 'M ' + pts.join(' L '), end: { x: x0 + hi, y: f(hi) } };
- };
- const sharp = mk(0.0075, -78, 88);
- const gentle = mk(0.0015, -92, 118);
- return { W, H, x0, y0, sharp, gentle,
- tan: { x1: x0 - 86, y1: y0 + m * -86, x2: x0 + 100, y2: y0 + m * 100 } };
- })();
-
- // Curvature figure B: the (1 − γλ) multiplier, run honestly. Four regimes of
- // real gradient descent on y = x², dots at α_k = (1−γλ)^k · α₀.
- const regimeFig = (() => {
- const PW = 110, H = 128, pad = 14;
- const panel = (gl: number, a0: number, latex: string, word: string, i: number) => {
- const ox = i * PW;
- const px = (x: number) => ox + PW / 2 + x * (PW / 2 - pad);
- const py = (y: number) => H - 34 - y * (H - 62);
- const N = 30;
- const curve = 'M ' + Array.from({ length: N + 1 }, (_, k) => {
- const x = -1.12 + (2.24 * k) / N;
- return `${px(x).toFixed(1)},${py(x * x).toFixed(1)}`;
- }).join(' L ');
- const dots: { x: number; y: number }[] = [];
- let aK = a0;
- for (let k = 0; k <= 6 && Math.abs(aK) <= 1.12; k++) {
- dots.push({ x: px(aK), y: py(aK * aK) });
- aK = (1 - gl) * aK;
- }
- const hops = 'M ' + dots.map(d => `${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' L ');
- return { ox, curve, dots, hops, latex, word, lx: ox + PW / 2 };
- };
- // The diverging run starts closer in, so its growing bounces stay on
- // stage long enough to be seen growing.
- return { W: PW * 4, H, PW, panels: [
- panel(0.35, -1, String.raw`\gamma\lambda = 0.35`, 'glide', 0),
- panel(1.0, -1, String.raw`\gamma\lambda = 1`, 'one hop', 1),
- panel(1.75, -1, String.raw`\gamma\lambda = 1.75`, 'bounce in', 2),
- panel(2.2, -0.5, String.raw`\gamma\lambda = 2.2`, 'diverge', 3)
- ] };
- })();
-
- // Proof figure, right panel: the rate of change ‖∇ℒ‖cosφ as the direction u
- // sweeps from along ∇ℒ (φ=0, max) through a contour (φ=90°, zero) to −∇ℒ
- // (φ=180°, min). A plain cosine — the claim, plotted.
- const proofCurve = (() => {
- const x0 = 252, x1 = 378, yc = 80, amp = 46, N = 60;
- const asc: string[] = [], desc: string[] = [];
- for (let i = 0; i <= N; i++) {
- const th = Math.PI * (i / N);
- const x = x0 + (i / N) * (x1 - x0), y = yc - Math.cos(th) * amp;
- const pt = `${x.toFixed(1)},${y.toFixed(1)}`;
- if (th <= Math.PI / 2 + 1e-9) asc.push(pt);
- if (th >= Math.PI / 2 - 1e-9) desc.push(pt);
- }
- return { x0, x1, yc, amp,
- ascD: 'M ' + asc.join(' L '), descD: 'M ' + desc.join(' L '),
- p0: { x: x0, y: yc - amp }, p90: { x: (x0 + x1) / 2, y: yc }, p180: { x: x1, y: yc + amp } };
- })();
-
// 6) The optimizer family tree, drawn as an actual tree. DATA-DRIVEN: each
// node lists a parent (and reuses RACE_COLORS); the tidy left→right layout
// (x = lineage depth, y = leaf order, parents centred on their children)
@@ -1103,35 +1000,7 @@
{:else if id === 'downhill-proof'}
-
+ {@html figureSvgs['downhill-proof']('app')}
{@html cap}
{:else if id === 'gamma-regimes'}
@@ -1189,28 +1058,7 @@
training time →
{:else if id === 'derivative-secant'}
-
+ {@html figureSvgs['derivative-secant']('app')}
{:else if id === 'derivative-slices-3d'}
{#await import('./GuideGradient3D.svelte') then m}
@@ -1234,35 +1082,9 @@
drag to orbit
{:else if id === 'curvature-bend'}
-
+ {@html figureSvgs['curvature-bend']('app')}
{:else if id === 'curvature-regimes'}
-
+ {@html figureSvgs['curvature-regimes']('app')}
{:else if id === 'landscape-two-views'}