Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/book.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ on:
branches: [main]
paths:
- 'src/content/**'
- 'scripts/emit-latex.ts'
- 'src/figures/**'
- 'scripts/**'
- '.github/workflows/book.yml'
workflow_dispatch:

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions scripts/emit-figures.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
11 changes: 9 additions & 2 deletions scripts/emit-latex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -116,19 +117,25 @@ 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');
}
return [
'\\begin{figure}[htbp]',
'\\centering',
`\\figplaceholder{${id}}`,
body,
`\\caption{${richToTex(caption)}}`,
`\\label{fig:${id}}`,
'\\end{figure}'
Expand Down
192 changes: 6 additions & 186 deletions src/components/HelpModal.svelte

Large diffs are not rendered by default.

107 changes: 107 additions & 0 deletions src/figures/curvature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* The curvature chapter's two figures: the shared-tangent bend comparison,
* and the (1 − γλ) multiplier run honestly across four regimes. Pure compute
* + SVG strings shared by the app and the book.
*/

import { type FigTheme, pal, texLabel, katexHtml } from './theme';

// ---------- the bend: same slope, two futures ----------

export function computeBend() {
// 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 } };
}

export function bendSvg(theme: FigTheme): string {
const f = computeBend();
const c = pal(theme);
return `<svg viewBox="0 0 ${f.W} ${f.H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<line x1="${f.tan.x1}" y1="${f.tan.y1}" x2="${f.tan.x2}" y2="${f.tan.y2}" stroke="${c.textTertiary}" stroke-width="1" stroke-dasharray="3,3.5" stroke-opacity="0.55" />
<path d="${f.sharp.d}" fill="none" stroke="#f87171" stroke-width="1.6" stroke-opacity="0.9" />
<path d="${f.gentle.d}" fill="none" stroke="#34d399" stroke-width="1.6" stroke-opacity="0.9" />
<circle cx="${f.x0}" cy="${f.y0}" r="3" fill="#f59e0b" stroke="#fff" stroke-width="1.2" />
${texLabel(theme, String.raw`\lambda`, { x: f.sharp.end.x + 5, y: f.sharp.end.y - 8, w: 70, h: 18, color: '#f87171', suffix: ' large' })}
${texLabel(theme, String.raw`\lambda`, { x: f.gentle.end.x + 5, y: f.gentle.end.y - 8, w: 70, h: 18, color: '#34d399', suffix: ' small' })}
</svg>`;
}

// ---------- the multiplier: four regimes, run for real ----------

export function computeRegimes() {
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)
] };
}

export function regimesSvg(theme: FigTheme): string {
const f = computeRegimes();
const c = pal(theme);
const panels = f.panels
.map((pn, i) => {
const divider =
i > 0
? `<line x1="${pn.ox}" y1="10" x2="${pn.ox}" y2="${f.H - 30}" stroke="${c.border}" stroke-width="1" stroke-opacity="0.5" />\n`
: '';
const dots = pn.dots
.map(
(dt, k) =>
`<circle cx="${dt.x}" cy="${dt.y}" r="${k === 0 ? 2.8 : 2}" fill="#f59e0b" fill-opacity="${k === 0 ? 1 : 0.8}" stroke="${k === 0 ? '#fff' : 'none'}" stroke-width="1" />`
)
.join('\n');
const label =
theme === 'app'
? `<foreignObject x="${pn.ox + 4}" y="${f.H - 27}" width="${f.PW - 8}" height="27"><span class="fig-tex" style="display:block;text-align:center">${katexHtml(pn.latex)}</span> <span class="fig-word" style="text-align:center;margin-top:2px">${pn.word}</span></foreignObject>`
: `<text x="${pn.lx}" y="${f.H - 16}" text-anchor="middle" font-family="Georgia, serif" font-style="italic" font-size="11" fill="${c.textPrimary}">${pn.latex.replace(/\\gamma\\lambda/, 'γλ').replace(/[\\{}]/g, '')}</text>` +
`<text x="${pn.lx}" y="${f.H - 4}" text-anchor="middle" font-size="9.5" fill="${c.textTertiary}">${pn.word}</text>`;
return (
divider +
`<path d="${pn.curve}" fill="none" stroke="#10b981" stroke-width="1.4" stroke-opacity="0.6" />\n` +
`<path d="${pn.hops}" fill="none" stroke="#f59e0b" stroke-width="1" stroke-opacity="0.55" />\n` +
dots +
'\n' +
label
);
})
.join('\n');
return `<svg viewBox="0 0 ${f.W} ${f.H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
${panels}
</svg>`;
}
64 changes: 64 additions & 0 deletions src/figures/derivative.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The derivative chapter's secant-sweep figure: chords leaning on y = x² a
* nudge h away, tilting into the tangent as h → 0. Pure compute + an SVG
* string shared by the app and the book.
*/

import { type FigTheme, pal, texLabel } from './theme';

export function computeSecant() {
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.
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 };
}

export function secantSvg(theme: FigTheme): string {
const f = computeSecant();
const c = pal(theme);
const chords = f.chords
.map(
s =>
`<line x1="${s.x1}" y1="${s.y1}" x2="${s.x2}" y2="${s.y2}" stroke="${c.textTertiary}" stroke-width="1" stroke-opacity="${s.o}" />` +
`<circle cx="${s.end.x}" cy="${s.end.y}" r="1.8" fill="${c.textTertiary}" fill-opacity="${s.o + 0.2}" />`
)
.join('\n');
return `<svg viewBox="0 0 ${f.W} ${f.H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<marker id="sweep-head" viewBox="0 -4 8 8" refX="6.5" refY="0" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,-2.8 L6.5,0 L0,2.8" fill="none" stroke="${c.textTertiary}" stroke-width="1.1" /></marker>
</defs>
<path d="${f.curve}" fill="none" stroke="#10b981" stroke-width="1.6" stroke-opacity="0.65" />
${chords}
<line x1="${f.tangent.x1}" y1="${f.tangent.y1}" x2="${f.tangent.x2}" y2="${f.tangent.y2}" stroke="#3b82f6" stroke-width="1.7" stroke-opacity="0.95" />
<path d="${f.sweep}" fill="none" stroke="${c.textTertiary}" stroke-width="1" stroke-opacity="0.75" marker-end="url(#sweep-head)" />
<circle cx="${f.p.x}" cy="${f.p.y}" r="3" fill="#f59e0b" stroke="#fff" stroke-width="1.2" />
${texLabel(theme, String.raw`\alpha`, { x: f.p.x - 10, y: f.p.y + 8, w: 24, h: 22 })}
${texLabel(theme, String.raw`\alpha + h`, { x: f.pEnd.x - 16, y: f.pEnd.y - 24, w: 44, h: 22, dim: true })}
${texLabel(theme, String.raw`h \to 0`, { x: f.ctrl.x + 4, y: f.ctrl.y - 8, w: 46, h: 22, dim: true })}
</svg>`;
}
20 changes: 20 additions & 0 deletions src/figures/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Figures that have taken the compute/render split: one function returns the
* SVG string for either target. The app's chapter shell renders these via
* {@html}; the book pipeline writes them to files. Ids match the figure
* blocks in src/content/chapters/.
*/

import type { FigTheme } from './theme';
import { secantSvg } from './derivative';
import { bendSvg, regimesSvg } from './curvature';
import { proofCosineSvg } from './proofCosine';

export type { FigTheme } from './theme';

export const figureSvgs: Record<string, (theme: FigTheme) => string> = {
'derivative-secant': secantSvg,
'curvature-bend': bendSvg,
'curvature-regimes': regimesSvg,
'downhill-proof': proofCosineSvg
};
55 changes: 55 additions & 0 deletions src/figures/proofCosine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* The steepest-descent proof figure: left, the gradient's shadow on a unit
* direction u; right, that shadow swept through 180° tracing a cosine. All
* plain SVG (no foreignObject), so app and print share one path exactly.
*/

import { type FigTheme, pal } from './theme';

export function computeProofCosine() {
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 } };
}

export function proofCosineSvg(theme: FigTheme): string {
const f = computeProofCosine();
const c = pal(theme);
const lbl = `font-size="10.5" font-weight="600"`;
return `<svg viewBox="0 0 420 156" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" aria-hidden="true" style="display:block;width:100%;height:auto">
<defs>
<marker id="pf-grad" viewBox="0 -5 10 10" refX="8" refY="0" markerWidth="5.5" markerHeight="5.5" orient="auto"><path d="M0,-5L10,0L0,5" fill="#f59e0b" /></marker>
<marker id="pf-u" viewBox="0 -5 10 10" refX="8" refY="0" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,-5L10,0L0,5" fill="${c.textTertiary}" /></marker>
</defs>
<line x1="210" y1="16" x2="210" y2="146" fill="none" stroke="#10b981" stroke-width="1" style="stroke-opacity:0.16" />
<line x1="36" y1="112" x2="174" y2="112" stroke="${c.textTertiary}" stroke-width="1.2" stroke-dasharray="3,3" stroke-opacity="0.6" marker-end="url(#pf-u)" />
<line x1="36" y1="112" x2="130.5" y2="112" stroke="#3b82f6" stroke-width="4.5" stroke-linecap="round" />
<line x1="130.5" y1="48.3" x2="130.5" y2="112" stroke="${c.textTertiary}" stroke-width="1" stroke-dasharray="2.5,2.5" stroke-opacity="0.75" />
<path d="M 123.5,112 L 123.5,105 L 130.5,105" fill="none" stroke="${c.textTertiary}" stroke-width="1" stroke-opacity="0.75" />
<line x1="36" y1="112" x2="128.6" y2="49.5" stroke="#f59e0b" stroke-width="2.6" marker-end="url(#pf-grad)" />
<path d="M 64,112 A 28,28 0 0 0 60.4,97.6" fill="none" stroke="${c.textTertiary}" stroke-width="1.2" />
<circle cx="36" cy="112" r="2.8" fill="${c.textPrimary}" />
<text x="134" y="46" ${lbl} text-anchor="start" fill="#f59e0b">∇ℒ</text>
<text x="178" y="116" ${lbl} text-anchor="start" fill="${c.textSecondary}">u</text>
<text x="72" y="105" ${lbl} text-anchor="middle" fill="${c.textTertiary}">φ</text>
<text x="83" y="128" ${lbl} text-anchor="middle" fill="#3b82f6">‖∇ℒ‖ cos φ</text>
<line x1="${f.x0 - 8}" y1="${f.yc}" x2="${f.x1 + 8}" y2="${f.yc}" stroke="${c.textTertiary}" stroke-width="1" stroke-dasharray="3,3" stroke-opacity="0.45" />
<path d="${f.ascD}" fill="none" stroke="#f59e0b" stroke-width="2.4" stroke-linecap="round" />
<path d="${f.descD}" fill="none" stroke="#10b981" stroke-width="2.4" stroke-linecap="round" />
<circle cx="${f.p0.x}" cy="${f.p0.y}" r="3.1" fill="#f59e0b" />
<circle cx="${f.p90.x}" cy="${f.p90.y}" r="3.1" fill="${c.textTertiary}" />
<circle cx="${f.p180.x}" cy="${f.p180.y}" r="3.1" fill="#10b981" />
<text x="${f.p0.x - 3}" y="${f.p0.y - 8}" ${lbl} text-anchor="start" fill="#f59e0b">along ∇ℒ</text>
<text x="${f.p90.x}" y="${f.p90.y - 9}" ${lbl} text-anchor="middle" fill="${c.textTertiary}">contour · flat</text>
<text x="${f.p180.x + 3}" y="${f.p180.y + 13}" ${lbl} text-anchor="end" fill="#10b981">along −∇ℒ</text>
</svg>`;
}
Loading
Loading