CLAUDE.md#
Notes for anyone, human or agent, working in this repository. Read README.md first for what the tutorial contains.
The shape of the thing#
A Vite + React app that embeds numbl as a library
(numbl/browser, createNumblSession). This is the seqlab / barycentric-rational
pattern, not the numbl-project.json + site-viewer pattern: there is no project
file and the deploy is a plain vite build.
src/
App.tsx sidebar, hash routing, chapter switching
ui/ Lab (one runnable section), ChapterView, Controls, Markdown
engine/ the numbl session wrapper
plot/ a small hand-rolled SVG layer, plus a canvas field plot
figures/ static diagrams, one .tsx per part, keyed in figures/index.ts
matlab/ the shared .m library, shown to the reader on its own page
chapters/
intro/ the introduction: front matter, no labs
onedim/driver.m the driver for chapters 1-4, plus their shared panels
twodim/driver.m the driver for chapters 5-7, plus their shared panels
chNN/ per-chapter text.md, editable .m script, chapter.ts
ch11/panels.tsx also the offline-results upload for the benchmark plot
bench/ offline ports of the two best methods for real MATLAB,
Python and Julia; the JSON they write (schema
"laplace-dirichlet-bench-1", documented in bench/README.md)
loads into chapter 11's work-precision plot
A chapter is prose in one text.md with <!--lab:id--> markers where its labs
go. A lab is a driver (fixed, hidden), an editable script (the method), a list of
declarative controls, and a render function. Each lab owns one numbl session for as
long as it is on screen: moving a control rewrites params.json and re-runs, while
editing the script reboots the session, because the script is compiled into main.m
at boot.
main.m is driver.m + "\n" + the editable script, so the script's functions
become local functions of the driver and the driver can call them. That is the
whole of the contract; each driver's header comment states which functions its
script must define.
Working with numbl#
This tutorial needs numbl 0.4.19 or later. It runs correctly on 0.4.18 and is drastically slower there: the L-shaped sweep in Chapter 9 takes about three minutes instead of six seconds, and Chapter 8 about six seconds instead of a fifth of one.
The reason is worth recording. Most of what this section used to say was a list
of ways to fall off numbl's JIT fast path, found by profiling this tutorial: a
switch anywhere in a call chain, a struct parameter, A(k,:) = row with a
loop index, a bare tic;, and the expensive one: the rule that nothing nested
under a loop the JIT had declined was compiled either, so a single unsupported
line could put a whole solver on the interpreter. They were fixed
upstream in 0.4.19, and the drivers here were rewritten back into ordinary
MATLAB once they were: the sweeps are plain for loops again. If you find a
comment describing a recursion-instead-of-a-loop workaround, it is stale; the
git history has the old shape.
What is left is worth knowing.
Keep the timed loop type-stable#
numbl specialises a loop on the types flowing through it. A loop whose iterate
is assigned from several different functions, or whose body indexes a cell
array, is not something it can specialise, and it runs interpreted. That is
usually harmless, since the expensive callee inside is still compiled, but when
the loop being measured is the thing under test the overhead lands in the
number on screen. Two consequences visible in ch08/iterative.m:
- each iterative method has its own loop rather than one loop with a switch inside it, and multigrid keeps its grid hierarchy in one flat vector rather than a cell array;
- residual and error histories accumulate as squares and are square-rooted
once at the end. A
sqrtinside the loop costs about 60 per cent on the sweep, because sqrt may return a complex value and the history array is then no longer a plain real array.
Neither is a correctness matter. Check with %!numbl:assert_jit inside a loop
body (it throws if that unit ran interpreted) and NUMBL_JIT_LOG=1, which
prints every JIT decline with its reason.
jsondecode of a JSON array of arrays gives a cell of column vectors#
MATLAB gives a matrix when the rows are the same length. The L-shaped driver
unpacks p.nsm by hand.
jsonencode refuses complex values, and flattens single-row matrices#
jsonencode([1 2 3]) is [1,2,3], not [[1,2,3]], so anything matrix-shaped
goes through lp_rows, which converts it to a cell array of rows first. NaN and
Inf cross as null, which is why the TypeScript side uses Num = number | null
everywhere and every plot helper skips nulls. A value whose imaginary part is
entirely zero counts as real and encodes fine.
Backslash densifies sparse matrices#
A \ b converts a sparse A to dense before calling LAPACK, so there is no
sparse direct solver here. That is why Chapter 5 stops at and why
Chapters 6 to 10 are about methods that do not need one. The text says so where
it matters.
Scalar loops are often faster than vectorised slices#
A hand-written double loop over a stencil beats the equivalent
A(2:end-1,...) expression by about a factor of three, because the vectorised
form allocates several temporaries per sweep. The kernels in
ch08/iterative.m are written as loops for that reason, which also happens to
make them readable.
The same holds for building a dense kernel matrix, and there the measurement is worth keeping. Three shapes for the Nyström matrix, at n = 1896:
| shape | numbl | MATLAB | Julia |
|---|---|---|---|
| scalar double loop | 50 ms | 9 ms | 7 ms |
implicit expansion, x - x' |
99 ms | 13 ms | 82 ms |
outer products, x * ones(1,n) |
117 ms | 62 ms | — |
So the loop wins everywhere the tutorial runs, and ch10/bie.m and
ch11/pacman.m both use it. The third row is what those files used to
contain; the idiom is not required by numbl, which supports implicit expansion
and is itself faster with it, and it was costing real MATLAB a factor of
seven in that stage. If you find it anywhere else, it is worth an experiment.
NumPy is the exception that proves the rule: it cannot run the loop at all, so
the Python port in bench/ assembles with whole-array expressions in place,
and the chapter's prose makes that asymmetry part of the point.
The browser is slower than the CLI#
The CLI uses a native LAPACK addon; the browser falls back to a TypeScript
implementation, and it announces which one on console.error. Dense solves are
noticeably slower in the page than in npm run test:matlab. Nothing in the
tutorial depends on the absolute numbers, but do not quote a CLI timing as if it
were what a reader will see.
Measuring time honestly#
Timings come from tic/toc inside the MATLAB layer, around the solve only, so
they exclude session boot, JSON encoding and rendering. Each method gets a warm-up
call before its first timed solve, because the first call also pays for compilation.
Below about 0.1 ms the measurement is dominated by fixed overhead and the
work-precision curves flatten out on the left; that is the clock, not the method.
Chapters 10 and 11, whose numbers are compared against other environments or quoted in the prose, keep the fastest of several runs: at least three whenever a solve is cheap enough to allow it, many more when it is very cheap, one when it is expensive, with the loop bounded to about 0.6 s per point.
while rep == 0 || (rep < 50 && tot < 0.6 && (rep < 3 || tot < 0.1))
That rule is not cosmetic. A single measurement of a twenty-millisecond solve on
this hardware varies by a factor of two with whatever else the machine is doing,
which is enough to reorder neighbouring points on a work-precision plot and, in
the offline benchmarks, to make one language look several times faster than
another. The three scripts under bench/ implement the same rule, and any new
cross-environment comparison must too. The earlier chapters still use the older
rule (repeat only when under 5 ms), which is fine because nothing outside those
chapters is compared against their numbers.
Testing#
npm run test:matlab # ~2 min: the .m layer through the numbl CLI
npm run build && npm run test:browser [--shots]
Every assertion in test:matlab is a number stated in the prose: an observed order,
an error at a given resolution, a ratio between two methods. If one fails, either the
code or the text is wrong, and the text is not allowed to win by default.
test:browser opens every chapter, presses run on the labs that wait to be asked,
and checks that nothing throws and that the figures contain data. It cannot judge
whether a picture is any good; --shots writes full-page screenshots into tmp/ so
that a human, or an agent with vision, can look.
Static figures#
src/figures/ holds the diagrams that are drawn rather than computed: the problem
statement, the stencils, the roundoff-floor V, the V-cycle, the re-entrant corner,
the close-evaluation spike. They are addressed from the prose by <!--fig:id-->, the
same way labs are addressed by <!--lab:id-->, and resolved through the registry in
figures/index.ts. They exist because several chapters, Chapters 8 to 10 in
particular, show nothing at all until the reader presses Run.
They are plain inline SVG with no data behind them, written against a 716-unit-wide
viewBox and width="100%", using plot/palette.ts for colour so that a curve
labelled fd2 is the same blue everywhere. Two habits are worth keeping. Text is
placed by hand, so a caption or a legend that fits at one size may collide at
another: render and look. And the introduction's accuracy-against-cost picture is a
schematic, which its caption says out loud; do not let it drift into looking like
measured data.
To look at them, point a headless browser at each chapter and screenshot
figure.static-figure; npm run test:browser --shots writes whole pages, which is
coarser but needs no extra script.
Deployment#
.cofferdam/workflows/deploy.yml runs on every push to main: npm ci,
npm run build, then upload-pages-artifact and deploy-pages, which the
vault substitutes with its own implementations to publish the repository's
site. Two constraints shape it. actions/cache is not implemented in the
vault's CI, so setup-node must not be given cache: npm. And a vault site is
served sandboxed, in an opaque origin, so localStorage and friends throw;
the app uses none, and adding one would break the deployed page while working
perfectly in npm run dev.
Watch a deploy with cofferdam run list --limit 3 --json and
cofferdam run view <n> --log.
Plotting#
src/plot/ is a small SVG layer with no chart library. palette.ts records which
colour does which job and the validator run that justifies the set; if you add a
series, re-run the validator rather than picking a hex that looks nice. Fields are
drawn into a canvas at one pixel per grid cell and scaled, so a 512-by-512 grid costs
no more to draw than a 16-by-16 one, and points outside the domain are passed as
null and left as background.
Things deliberately not done#
- A sparse direct solver, for the reason above.
- Graded meshes in Chapter 9. The chapter names local refinement and singular enrichment as the standard remedies and then demonstrates a third, because the third is the one that is short enough to read.
- Close-evaluation quadrature in Chapter 10. The failure is shown and quantified; the fix is a chapter of its own.
- Neumann and mixed boundary conditions, anywhere.