From 088bfea277a42cc312227fbbbdb542e097a5c91b Mon Sep 17 00:00:00 2001 From: NeoVand Date: Thu, 2 Jul 2026 06:25:27 -0500 Subject: [PATCH] =?UTF-8?q?feat(figures):=20the=20compute/render=20split?= =?UTF-8?q?=20lands=20=E2=80=94=20four=20figures=20print=20for=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/figures/ begins: each figure is pure compute + one SVG-string render with two targets. 'app' keeps CSS variables and KaTeX foreignObject labels (the exact markup the hand-written figures used); 'print' is the day palette concretized with plain SVG text labels (rasterizers have no foreignObject). The app's chapter shell renders the same strings via {@html}; scripts/emit-figures.ts writes them as SVG and converts to PDF via rsvg-convert, and the emitter swaps its placeholder for \includegraphics wherever a split figure exists. Migrated: derivative-secant, curvature-bend, curvature-regimes, downhill-proof — the book now carries four honest simulations. Verified by a new geometry ritual: every path/line/circle/label of all four figures byte-compared against main in the DOM. It caught two transcription slips before commit (the bend figure's ' large'/' small' label words, and a whitespace text node between the regime label spans) — and one latent bug worth knowing: the proof figure's panel divider NEVER rendered on main (.fig-contour only styles children of .fig, and this figure's wrapper is .proof-fig); the baked styles now draw the faint divider the markup always intended. CI installs librsvg and watches src/figures/. App: 199 tests, svelte-check clean, build green; book compiles at 295KB. Co-Authored-By: Claude Fable 5 --- .github/workflows/book.yml | 5 +- package.json | 2 +- scripts/emit-figures.ts | 23 ++++ scripts/emit-latex.ts | 11 +- src/components/HelpModal.svelte | 192 +------------------------------- src/figures/curvature.ts | 107 ++++++++++++++++++ src/figures/derivative.ts | 64 +++++++++++ src/figures/index.ts | 20 ++++ src/figures/proofCosine.ts | 55 +++++++++ src/figures/theme.ts | 89 +++++++++++++++ 10 files changed, 378 insertions(+), 190 deletions(-) create mode 100644 scripts/emit-figures.ts create mode 100644 src/figures/curvature.ts create mode 100644 src/figures/derivative.ts create mode 100644 src/figures/index.ts create mode 100644 src/figures/proofCosine.ts create mode 100644 src/figures/theme.ts 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'}