/ tutorials / laplace-dirichlet
Sign in
tutorials / laplace-dirichlet
laplace-dirichlet / CLAUDE.md
11.6 KBPreviewCodeBlameHistoryRaw

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:

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=48N = 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.

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#

.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 <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#

↑↓ move↵openescclose