Repository navigation
Speculative Lazy Engine
Speculative design. Open, not settled — thinking and proposals, not shipped behavior. See Roadmap for what's in flight.
Status: speculative (2026-09-29). Two problems with one cause. <Head> tags in prose
(Speculative Prose Pipeline step 3) should evaluate at build and in the browser alike, which
today means loading every package. And the site build takes seven minutes, and every page
ships seven megabytes of script, because every page is built against, and preloads,
everything. The fix for both is an engine whose core is small and which loads the rest per
expression, from what the expression actually uses.
From main's CI run on 2026-09-29 and one local experiment:
Library dists (pnpm -r run build) |
~21 s |
| Site: client + server bundles | 419 s |
| Site: rendering 2,154 pages | 17 s |
| Site with 10 symbol pages instead of ~2,000 (local) | bundling still 334 s |
| Output | 393 MB: 330 MB of it reference HTML, 47 MB assets, 31 MB of JS in 4,494 files |
| What every page preloads | 45 modules, 7.2 MB (the theme chunk alone 5.6 MB, 1.6 MB gzipped) |
What that says:
- The page count is not the cost. With ten symbol pages instead of two thousand, bundling still took five and a half minutes (on a laptop, against seven on CI for all of them). Bundling the shared code is the cost: compute-engine (core 3.3 MB, integration rules 6.7 MB, compile 1.7 MB, minified), every symbol package, MathLive, KaTeX, AsciiMath, Mermaid, bundled once for the client, again for SSR, and again for the session worker. All of it through VitePress 1's vanilla Vite 5 and Rollup, not the Rolldown the rest of the repo builds with.
-
The site bundles package sources, not dists.
config.mts'ssrcAliasespoint every@enumeratio/*import atsrc/*.ts, so dev never reads a stale dist; the price is that the site build re-transforms and re-bundles all 7 MB of package TypeScript that the 21 s dist build already produced. - The theme carries the engine. The theme chunk, preloaded on every page including the home page, contains compute-engine and its integration rules, AsciiMath, and the preloads for all of Mermaid's diagram types. A page with no live cell pays for all of it.
-
Every reference page loads the whole reference.
virtual:reference-entriesis one 6.9 MB chunk of every entry, which each symbol page imports to show one. -
Every page inlines the page map. VitePress puts its hash map of all 2,154 pages
(116 KB) into every HTML file, about 250 MB of the 393 MB output.
metaChunk: truemoves it to one shared file.
The manifest already knows every head without loading code: its signatures, attributes, and the package that declares it (Manifest). Definitions are Epsil, so what a definition refers to can be read off it. That is enough to resolve an expression's code from the expression:
-
Parse to an AST: extended MathJSON, with prose boxes around it where it came from
markdown (a
<Head>tag, a${…}hole). - Collect the heads the AST uses.
- Close over them: each head's declaring package (manifest), each package's declared dependencies (declare order: residues ← numerals ← number-theory), and the heads each definition's body uses, transitively. The closure is a list of packages, in order.
- Load and declare those packages into a core engine (compute-engine plus boxes, the manifest and the declaration machinery), then evaluate.
The same resolver runs at build, in Node, and in the browser, where each package is a dynamic chunk: a page imports what its expressions need and nothing else. The session worker does the same per notebook. A page with no expressions loads no engine.
What makes this harder than it sounds:
-
Global effects. Some packages change more than their own heads: they override a
native head (the manifest's
overrides), hook canonicalisation (Multiply'sInvisibleOperatorseam), extendElementor LaTeX parsing, or register samplers. The manifest has to mark these, and the resolver loads a marked package whenever an expression uses a head it touches; the few that touch everything belong in the core. - Heads reached only at run time. A rewrite rule or a random sampler can produce a head the AST never named. Such a package declares what it can produce, in its record, so the closure includes it; failing that, evaluation that meets an unloaded head can load it and resume, where evaluation is async (the browser, the worker), but not in a synchronous build step.
- Which heads can stand in prose. A tag's closure has to resolve before evaluation. So at first only heads whose closure is static may be outer wrappers, and the manifest can say which: the rest are an error at build, not a surprise at run time.
Build-time evaluation then costs only the closures the pages' tags need, loaded once per build in Node, not bundled at all: SSR can import packages from their dists.
The resolver is in @enumeratio/manifest (createResolver, plan, packagesFor): a host
lists its libraries by package, and an engine gets only what an expression's names need. Held
to the full engine over every reference example (6,249 of them, in reference's
tests/resolver.test.ts, every head nightly), it agrees on all but two heads. What building
it taught:
-
The records don't say who declares everything. A collection family filed under
reference/entrieshas no package on its signature, so the manifest can't place it. What a library declares is found by declaring it, into a fresh engine over what it requires, and committed (DECLARERS, regenerated bycollect-declarers.ts, with a drift test). -
A library's requirements aren't only its
package.json. statistics takes combinatorics' carrier types from the host, so the host says so (requires); and it fillsCombinatorialStat's table without redeclaring the head, so it names what it serves (names). -
Widenings can't be told apart from their types. Five packages redeclare
Add, each with(value+) -> value. Some widenings are only for their own values, but structures'Floor(x, m)and number-theory'sFibonacci(1.5)apply to plain numbers, and nothing in the type says which is which. So every package that redeclares a name counts, and the arithmetic heads pull in analytic, hypercomplex, residues, numerals and adeles (and what they require) wherever they appear. The median example needs 3 of the 18 libraries; 39% need one or none; the arithmetic chain is most of the rest. -
Some declarations depend on who went last.
widenSignatureassigns a signature rather than adding one, so adeles'Fibonacciclobbers number-theory's, which is why the full engine declares number-theory last even though adeles depends on it; modular'sInversedoes the same to combinatorics' permutation overload. The resolver can't honour both a dependency and a "declare me last", so these two are listed as known, and the fix is in the code: an overload added, not assigned.
Both of the last two point the same way. While a package widens a native head by wrapping it, which package's code runs depends on declare order and loading all of them is the only safe answer. When an overload is data (a typed row in the record, dispatched by type), the resolver can load a widening only when an argument's type is that package's, and order stops mattering. That's the manifest's dispatch step (Manifest), and it is what makes the engine small in practice, not just in principle.
Symbols needn't all live in this repo. A registry is somewhere else the resolver can look a name up, and load its definition from, with the same contract as a local package.
What a registry serves. Static files, no server logic, so it can sit on a CDN or in a repo:
-
index.json: every name it offers, each with its record's front matter (summary, signatures with types, attributes), the version, and a content hash per version. - per name and version: the record (
index.mdandexamples.tsv, exactly our format), and the definition.
What a definition is. In order of preference:
-
Epsil, as data. A MathJSON
Functionbody, declared as a head with its signature. Loading it runs no code beyond the engine, so a definition from anywhere is as safe as evaluating any expression (and evaluation already has deadlines and memory caps). Its dependencies are the names in its body, so the resolver closes over them the same way, locally or back into the registry. This is what makes Epsil-only definitions matter beyond style. - Compiled from that Epsil, by the client: a registry never ships code for it.
-
A code package (an ES module exporting
declare), for kernels Epsil can't express yet. Only from registries the host trusts by origin, pinned by an integrity hash, and run in the session worker, never the page's own realm.
Names. The catalogue is one global namespace, and a registry mustn't be able to shadow
it. Two spellings, both from Namespaces §3: the qualified call, Resource("registry/Name")(x)
(Wolfram's ResourceFunction, which compute-engine already evaluates as resolve-then-apply),
and a document-level binding, Needs("registry/Name@1.2"), after which the bare name works.
An unqualified name only a registry has is an error that says which binding would fix it, not
a silent fetch.
Resolve before canonicalising. Parsed text can name a head nobody has declared yet, and
compute-engine keeps that repairable only until canonicalisation (Namespaces §3.2: the
unary Foo(3) is an InvisibleOperator before, a Multiply after). So the resolver works on
the non-canonical parse: collect names, load what declares them, then canonicalise. The same
order applies to local packages, and it's how a <Head> tag in prose resolves. A registry symbol may declare new heads, and add typed overloads once
overloads are data (above), but never widen someone else's head by wrapping it: that's the
order-dependence problem, imported.
Pinning. A document or a site keeps a lockfile of what it resolved (name → version, hash). The build fetches and caches against it, and the browser uses the same pins, so a
page means at run time what it meant at build. An unpinned name is resolved once, then
pinned.
Trust by testing. A registry record carries its examples, so installing a symbol can run
them: an example that doesn't evaluate to its expected (or its known) is a failed install,
or at least a visible flag on its page. The same machinery that holds our own records to their
examples holds someone else's.
Our packages as the first registry. Each package already has everything a registry entry needs: records, a manifest slice, a dist. Serving them through the registry protocol, and having the site resolve through it, dogfoods the design before anyone else publishes through it.
The engine is one half. The other is not bundling what a page doesn't use, and not re-bundling what's already built. In rough order of payoff over effort:
-
metaChunk: true: a config line; the output drops by about 250 MB. Doesn't touch build time. - Engine out of the theme. Nothing on a static page needs compute-engine; the elements that do (cells, outputs, plots) import it on first use. This is the lazy engine's first consumer and cuts every page's preload to the framework plus KaTeX.
- Per-page reference data. A symbol page gets its entry through its route's params (or a JSON file per head), not the 6.9 MB chunk of all of them.
- Mermaid on demand: its diagram types load when a page has a diagram, not preloaded on every page.
- The site on Rolldown. VitePress 2 builds with Rolldown-Vite; the repo already does. Likely the largest single cut to bundling time, at the cost of an alpha VitePress.
-
Packages as built artifacts, not sources. Build the site against the packages'
dists, which are already built and cached (the
vptask cache knows when a package's inputs changed), and keepsrcAliasesfor dev only. A change to the analytic package then rebuilds that package's dist and re-bundles the site against it, but no other package's TypeScript is transformed again. A stale dist becomes a cache problem, which the task graph already solves, rather than a correctness one. -
Heavy pages apart. The
explore/rigs, the worksheet and the bench viewer are apps, not documents. They can build as separate Vite entries (or a separate site) that the docs link to, so the docs build doesn't bundle WebGPU portraits, xterm or the benchmark data.
Items 2 to 4 and 6 are also the runtime win: the same splitting that makes the build cheaper makes a page load what it shows.
-
metaChunk, Mermaid on demand, per-page reference data: small, measurable, no design. -
The resolver over the manifest(built, above). Next: the theme drops its static engine import, and elements resolve on first use. -
<Head>tags in record prose evaluated at build through the resolver (prose pipeline step 3), then in the browser the same way. - The site on VitePress 2 / Rolldown, built against dists; the heavy apps split out.
-
Overloads as data. Typed rows dispatched by argument type, so a widening loads only when a value of its package's type turns up, and declare order stops mattering. The prerequisite for the resolver to stay small around arithmetic, and for registries to add overloads safely.
-
The registry's first version. Epsil definitions only, one registry (our own packages), lockfile and examples-as-install-check included.
-
What's in the core. compute-engine and boxes certainly; the manifest and the declare machinery; which of our overrides of native heads are universal enough to live there.
-
Where closures come from. Computed at build from the manifest and the Epsil definitions (a generated table, checked by a drift test like the manifest's), or at resolve time. The table is cheaper at run time and reviewable.
-
Async evaluation. Load-and-resume on an unloaded head needs evaluation to be interruptible; compute-engine's evaluate is synchronous. The build can't rely on it.
-
VitePress 2. Alpha as of this writing; worth a spike to measure before committing.