Let's run SICM
in the browser
I set up a slackbot for our reading group a few weeks ago. We're reading through SICM, and needed a way to run the programs in the book.
I thought a slackbot would be just the thing: we could collaborate together in the chat channel, and Emmybot would answer us with results. But the format was super clunky. Emmybot always replies as a threaded message, so you have to click to see the reply. Each message sent becomes part of the state of the Emmy instance, allowing functions to be defined and called, but those functions were distributed across many slack threads, making it hard to see what was loaded -- and all that state went away whenever the server rebooted.
A threaded messaging channel, it turns out, is not a good repl. In retrospect this is fairly unsurprising.
Additionally, half the replies were huge stack traces, as we wrestled with the conversion of scmutils/MIT Scheme to Emmy/Clojure.
And there were no graphics. I mean… what is even the point of learning physics if you don't get some fizzy graphics out of it?
So a couple days ago that old imp of the perverse snuck over and whispered, “maybe you could do it in JavaScript?”
Famous last words, I know, but… how hard could it be?
A bit tricky, as it turned out, but all the plumbing was there, thanks to a ton of work from the authors of Emmy, SCI/scittle, CodeMirror, Reagent, Mafs, MathBox, Leva, and more -- this builds on a tall stack.
So here's Bemmy: a Browser Emmy.
It has pages, like a notebook, but structured as two panes so the code is consecutive. You can send a page to someone to share it, via url. You know what state it's in, because you can see all the code on the page, and can just reload the browser if it gets corrupted.
It also has graphics: both the boring old 2D kind, but also 3D! And animated! This is pretty good. There's been very little use against real SICM problems, so new functions will be needed, but the foundations are there.
It also has an SICM to Emmy converter. It's not perfect, but it is surprisingly good. This is maybe the bonus feature I'm happiest about — it saves us either hand translation or an LLM call, both of which have proven flakier than this static translator.
Bemmy is a work in progress. We're still in Chapter 1 of SICM, so we've got a long way to go, and Bemmy will adapt with us as we find places it doesn't work well or things we'd like it to do.
I've been building bespoke tooling almost as long as I've been using computers, which is quite a long time. Every time I put a tool in someone's hands that makes them more powerful I still get a rush. We live in an age where I can get that rush almost every day. Things that used to take weeks, like fighting through issues in the Babashka build process while getting Emmy into the browser, are now just a few hours of LLM time.
It's not just the time savings, it's the tactical and motivational energy. Those things take weeks partly because they're highly energy intensive work, and when I have that energy I'd rather put it toward creating things than grinding through the fractally fiddly surface area of getting other people's software to work together. Building all the circuitry in my head needed to do the grinding is itself a grind, and requires a particular and somewhat unpleasant mental state.
Having a tool that can glue A and B together, even if that seam is of shoddy construction, means we can much more rapidly create tools that help our communities prosper.
LLMs.
The hot glue guns of code.
SICM the book is a beautiful artifact; Emmy the library is its
faithful Clojure(Script) descendant.1
Until now, running an exercise from one against the other meant
firing up a JVM REPL — or shrugging and reading the math. Bemmy
puts both in a browser tab. emmy.env is pre-referred
so D, simplify, up,
Lagrange-equations, find-path are
all in scope from the first keystroke; mafs.cljs and mathbox.cljs
draw the curves; localStorage keeps your pages; a URL hash
carries them to anyone you paste it to.
The substrate
Scittle is the Small Clojure Interpreter wrapped for use in a
<script> tag — Clojure-the-language, evaluated
at runtime in the browser, with no shadow-cljs build for the
user's code.2 It ships with Reagent and a
plugin system; we built Bemmy as a Scittle plugin that registers
Emmy's seventy-five namespaces with the SCI context. The part
that matters:
(ns scittle.emmy
(:require [emmy.sci]
[mafs.sci]
[mathbox.sci]
[scittle.core :as scittle]))
(defn init []
(scittle/register-plugin! ::emmy emmy.sci/config)
(mafs.sci/install!)
(mathbox.sci/install!)
(scittle/eval-string
"(require '[emmy.env :refer :all])"))
With that loaded, the first eval is the Euler–Lagrange equation for a harmonic oscillator, returned as an Emmy expression and rendered inline as TeX:
(defn L-harmonic [m k]
(fn [local]
(let [q (coordinate local)
v (velocity local)]
(- (* 1/2 m (square v))
(* 1/2 k (square q))))))
(simplify (((Lagrange-equations (L-harmonic 'm 'k))
(literal-function 'q))
't))
emmy.expression.render/->TeX; if a TeX string
comes back, KaTeX renders it.
The editor
A <textarea> would have shipped on day one but
read like a regression after thirty seconds with an Emmy
expression. The editor is CodeMirror 6 with
@nextjournal/clojure-mode for paredit-style
structural editing, the standard CM
defaultHighlightStyle in light mode and
oneDark in dark mode, and a vim toggle in the
toolbar for the muscle-memory crowd.3
Cmd-Enter splits the buffer into top-level forms, evaluates each
in sequence against the live SCI context, and notebooks the
results — every form's value, not just the last.
Pages are named source buffers persisted to localStorage. There's a small dropdown to the left of the user-tabs strip — Welcome ▾, SICM ▾, depending on which template is showing — that lists read-only system pages we ship with the build. Pick one; the editor loads it. Type a single keystroke and it forks transparently into a fresh user page named Welcome 1 and switches to it.4 The system page itself stays canonical, gets fresh content on every build, and never collides with edits....
The translator
SICM is written in scmutils Scheme, not Clojure. To stop reading
it with one hand and translating with the other, Bemmy ships a
SICM → Emmy shelf — toggle it from the toolbar, paste
Scheme on the left, the right pane shows the Clojure that comes
out of a small parser-transformer-emitter pipeline. Insert at
cursor drops the result into the editor at the caret. The
translator handles curried defines,
let / let* / letrec,
internal define lifting to letfn, named-let
rewriting to loop, do-loops,
values / receive, and a wide rename
map for Scheme primitives that have direct Clojure
analogues.5
The signature transformation is the curried define —
a SICM idiom that the obvious recursive descent almost
handles, then trips on:
;; in
(define ((L-harmonic m k) local)
(let ((q (coordinate local))
(v (velocity local)))
(- (* 1/2 m (square v))
(* 1/2 k (square q)))))
;; out
(defn L-harmonic [m k]
(fn [local]
(let [q (coordinate local)
v (velocity local)]
(- (* 1/2 m (square v))
(* 1/2 k (square q))))))
Visualization
Visualization is where SICM most rewards being run rather than read. Phase portraits, optimized paths, animated wave equations, Lagrangian surfaces — the book is full of plots whose authors assumed you'd be sitting at scmutils with a graphics frame already open. mafs.cljs and mathbox.cljs are the same libraries emmy-viewers wraps for Clerk, and most of emmy-viewers itself turns out to be Clerk-agnostic Reagent components.6 Bring those across, mount them in our result pane when an eval'd value smells like Reagent hiccup, and SICM book code that draws becomes SICM book code that draws.
The simplest API is plot — anything callable that
returns a number for a numeric input. Defaults to
[-5, 5] × [-5, 5]; pass extra args to override.
(plot Math/sin)
(plot Math/cos [(- Math/PI) Math/PI])
;; The path returned by find-path is just a callable polynomial:
(plot (find-path (L-harmonic 1.0 1.0)
0.0 1.0
(/ Math/PI 2) 0.0
2)
[0 (/ Math/PI 2)] [0 1.2])
find-path minimizes the action functional and
returns a polynomial. Mafs's OfX just samples it.
plot-with-params wires a Reagent atom of parameters
through a Leva control panel into a Mafs OfX. Drag
a slider, watch the curve update — the kind of "what does this
coefficient even mean" exploration that's tedious without a
live tool.7
(plot-with-params
(fn [{:keys [omega gamma]} x]
(* (Math/exp (- (* gamma x)))
(Math/cos (* omega x))))
{:omega {:value 2 :min 0.5 :max 6 :step 0.05}
:gamma {:value 0.3 :min 0 :max 1.5 :step 0.01}}
[0 (* 4 Math/PI)] [-1.2 1.2])
plot-with-params calls share it.
SICM is more imperative than functional about plotting. The book
builds a frame, mutates it with
graphics-clear and plot-function, then
leaves the result on screen. To translate book code that uses
this, Bemmy ships a small shim that backs frame
with a Reagent atom holding a viewBox and a vector of drawables,
mutates it in place, and auto-renders whenever a frame atom
turns up as the value of the last form
evaluated.8 The book's
(plot-function win f t0 t1 step) works as written.
And then animation. animate takes a function of
(t, x), mounts a 60Hz timer at component mount,
rebuilds the OfX curve every tick. Travelling waves, standing
waves, harmonic-oscillator phase evolution — all one form
away.9
(animate (fn [t x] (Math/sin (- x t)))
[(- (* 2 Math/PI)) (* 2 Math/PI)]
[-1.5 1.5])
Three dimensions
mathbox.cljs is the same trick at WebGL fidelity: a Reagent
wrapper around the JavaScript MathBox library, which sits on
Three.js. The data → draw composition pattern is the same as
Mafs's, just one dimension richer. Interval emits
points along t; Line draws them.
Area emits a grid; Surface draws it.
Camera orbits on click-and-drag.10
[mathbox/MathBox
{:container {:style {:height "400px" :width "100%"}}}
[mb/Cartesian
{:range [[-2 2] [-2 2] [-2 2]] :scale [1 1 1]}
[mb/Axis {:axis 1}] [mb/Axis {:axis 2}] [mb/Axis {:axis 3}]
[mb/Area
{:rangeX [-1.5 1.5]
:rangeY [-1.5 1.5]
:width 32
:height 32
:channels 3
:expr (fn [emit q v i j time]
(emit q (* (sin time) (- (* 0.5 v v) (* 0.5 q q))) v))}]
[mb/Surface {:shaded true :color "#3090ff"}]]]
The time argument in MathBox's :expr
callback is the global animation clock — referenced inside the
curve's expression, the curve animates with no separate timer.
The harmonic oscillator's circular phase portrait, unwound along
a time axis, becomes a rotating helix; the harmonic Lagrangian
L(q, v) = ½v² − ½q² is a saddle surface over its
phase space. Both ship pre-loaded on the 3D system page.
Sharp edges
A few of the things we hit that didn't go in the body. SCI's
analyzer resolves symbols at analysis time, which means forward
references between defns inside the user's
(uncompiled) source fail until you sprinkle (declare …)
at the right spots.11
cljs.core/Atom isn't a SCI-resolvable symbol, so
(instance? cljs.core/Atom v) aborts the init
eval-string mid-stream — replaced with a try-deref + shape
check. The hiccup detector at the result-pane boundary uses
(or (keyword? h) (fn? h)) for the head; symbols
are deliberately excluded so the quoted-form fragments
emmy-viewers returns don't false-trigger a render. And every
result row gets a :key prefixed with an
eval-id that bumps on each Cmd-Enter, forcing
form-2 Reagent components — Leva-driven plots, animated curves
— to remount cleanly when the source changes.
The Share button in the toolbar copies a URL whose hash
carries the current page's name and source as base64-of-utf8
JSON. On load, if the hash starts with #s=, Bemmy
decodes it, adds the page to your localStorage with a
collision-safe name (your existing Welcome stays put;
the imported one becomes Welcome 1), switches to it,
and clears the hash so a refresh doesn't re-import. The shared
page survives reload and stays around until you delete it.
Where it lands
Five megabytes of JavaScript, twenty woff2 files, a SCI evaluator, Emmy's seventy-five namespaces, two visualization libraries, a CodeMirror, a Leva, a Three.js. It serves from any static host with a permissive MIME type — no backend, no build on the deploy side. Open the page; in a few seconds you have an editor, a Lagrangian, a plot. Drag a slider; the curve responds. Let the harmonic oscillator's path-finder run; watch a polynomial trace from (0, 1) to (π/2, 0). Paste an excerpt from the book into the translator shelf; click Insert; evaluate. The book runs.