# CLAUDE.md Notes for anyone, human or agent, working in this repository. Read [README.md](README.md) first for what the tutorial contains. ## The shape of the thing A Vite + React app that embeds [numbl](https://numbl.org) 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 `` 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 `sqrt` inside 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 $N = 48$ 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. ```matlab 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 ``, the same way labs are addressed by ``, 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 `.mochi/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 `mochi run list --limit 3 --json` and `mochi run view --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.