Skip to content

Speculative Vdom Markup

enumeratio edited this page Oct 4, 2026 · 34 revisions

The vdom as markup: FullForm in JSX, read without knowing the symbols, evaluated lazily

Speculative design. Open, not settled — thinking and proposals, not shipped behavior. See Roadmap for what's in flight.

Status: direction agreed (2026-09-29); slices 1 (#439), 2 (#444), 3 (#445), pins (#446) and the search path (#449), npm (#453), the install check (#485), the JS entry (#488), version ranges (#505), the system range and hosts (#509), the version check (#521), first-use resolution on the site (#538) and the elements reading as the markup does (#544) landed; markup on pages and placed named slots in review; open points at the end. Supersedes Speculative Vdom Canonical Form.

Vdom says an expression and its component form are one tree under a renaming. Today that tree reaches authors through generated Vue and React wrappers (@enumeratio/frontend/vue, /react, from generate.ts), which tie the syntax to two frameworks and drift from the elements they wrap. They go, and in their place:

  1. one markup syntax that is FullForm written as JSX: any expression, readable into MathJSON without knowing anything about the symbols it names;
  2. a small reader, engine-free, over a tiny JSX-like parser;
  3. evaluation that declares only what the expression names, resolved by name through a registry, so no host installs, or even knows, the whole catalogue;
  4. . as the namespace separator, which Epsil already parses as field access.

1. The syntax

The point is that the reader needs no symbol knowledge: every rule is about the shape of the markup, never about which head it is.

markup MathJSON
<Binomial>…</Binomial> an application; the tag is the head, verbatim
element children and text tokens, in order positional arguments
a text run whitespace-separated tokens, each an atom: a number (2, -1.5), a symbol (n, NaN), a string ("hello")
<NaN/>, any element with no children a symbol, the same as the bare token NaN
<Apply>Random</Apply> a call with no arguments: ["Random"] (compute-engine canonicalises Apply(Random) to it; measured). <Apply><Random/></Apply> reads the same
PlotRange="All", s="2" a named slot: a trailing KeyValuePair(PlotRange, All), the pair optionsOf reads, or a parameter's place (§1.2)
Foo alone KeyValuePair(Foo, True)
value="…" (lowercase: reserved) the element's arguments as Epsil, in place of children (§1.3)
<Stats.Mean> a namespaced head (§4)
<Plot PlotRange="All">
  <Sin><Multiply>k x</Multiply></Sin>
  <Tuple>x 0 10</Tuple>
</Plot>

is Plot(Sin(k x), (x, 0, 10), PlotRange -> All).

1.1 What a JSX parse gives, and the pass on top

A JSX parser (and htm, §2) gives element children as separate nodes and drops whitespace-only runs that contain a line break, but leaves a text run as one string: <Binomial>n 2</Binomial> has the single child "n 2". Splitting it into tokens is our pass, and the only one. Element tags keep their case, and Stats.Mean comes through as the tag name (both measured with htm).

< can't sit in htm's or JSX's text, even inside a string, so a string holding one writes it \u003c (the printer escapes <, >, {, }, &, ' and backticks that way). A number keeps its spelling where a JSON number wouldn't (1e3 is { num: "1e3" }), so a round trip is exact.

A childless element can't be told from an empty one: <Random/> and <Random></Random> parse the same (and a formatter rewrites one into the other). So childless is a symbol, and the rare nullary call is spelled with Apply, which is its FullForm anyway.

1.2 Attributes are named slots

Any attribute but value is a named slot: read as a trailing option pair, whatever the head. The reader doesn't know whether PlotRange on Plot is an option or s on PolyLog a parameter; a caller that does passes paramsOf (the head's parameter names, from the manifest's records) and the reader places them.

  • For an option, the pair is already the form options take (Wolfram's rules, optionsOf).
  • For a named parameter, the slot takes the parameter's position and the children fill the others in order: <PolyLog s="2">z</PolyLog> is PolyLog(2, z). A gap before a given parameter, or more children than parameters, is an error. A library's own parameter names aren't published yet (§4.3), so a namespaced head's slots stay pairs.
  • Kept apart from the children, named slots are also how the head is drawn: the subscript slots of a function (<PolyLog s="2">z</PolyLog> as Li₂(z), ζ_H's H), separate from the arguments in parentheses.

A slot's value is a single token, or Epsil when it can't be one (PlotRange="(-1, 1)"), parsed like value (§1.3). A slot whose value is a tree nobody wants as Epsil is written as the pair itself, as a child: <KeyValuePair>PlotRange <List>-1 1</List></KeyValuePair>.

Case is the author's: the attribute is spelled as the option or parameter is. Kebab-case is the web components' concern (HTML folds case), not this syntax's.

1.3 value: Epsil, in an attribute

Epsil in a text run needs too much escaping (<, >, {, }), so Epsil only ever sits in an attribute. One reserved, lowercase attribute, on any element:

  • value="…" holds the element's arguments as Epsil, and the element then has no children: <Binomial value="n, 2" /> is <Binomial>n 2</Binomial>.
  • Lowercase is what keeps it from colliding: slots are PascalCase, like the options and parameters they name.
  • For a whole expression, the element is the reading itself: Wolfram's ToExpression (<ToExpression value="Sin(k * x)" /> is Sin(k * x), the head gone once read). Not InputForm: that is the display wrapper, printing as input; this is the reading.

This is also what <Notatio expr> did, so it goes: <ToExpression value="…" /> evaluated and rendered for the page's environment replaces it.

On a page (web/.vitepress/notatio-markup.ts), markup in a markdown page is read at build with the parameters placed, and shown as its value: an evaluated <notatio-out>, inline in a sentence, displayed when it has lines of its own. A tag that is also a Vue component (<Symbol>, VitePress's <Content>) stays the component, code is left alone, and markup that doesn't read fails the build.

2. The reader

readMarkup(node, { parseText }) -> MathJSON, in @enumeratio/formats next to parseExpression (writing belongs to notatio; this is a format). It reads one neutral shape, which is htm's h(tag, props, ...children) output:

interface MarkupNode {
  tag: string;                                   // "Plot", "Stats.Mean"
  props: Readonly<Record<string, string | true>> | null;
  children: readonly (MarkupNode | string)[];
}
  • The parser is htm (the Preact ecosystem's JSX-like parser, 1.2 KB, no dependencies): parseMarkup(text) is htm over the text with this h. No MDX dependency; MDX's and estree's JSX nodes are the same shape and get a ten-line adapter if a consumer wants one.
  • It returns non-canonical MathJSON plus errors (parseExpression's contract), so resolution (§3) runs before canonicalisation.
  • It is engine-free, and so is anything a page loads eagerly. The theme may statically import only the markup structure; Epsil (value, Epsil slot values), $…$ islands, evaluation and reduction load on first render. A static import of the Vue wrappers put compute-engine in the chunk every page preloads (5.6 MB; 93 KB without it), checked after a build by grep -c ComputeEngine on the theme chunk being 0. compute-engine's epsil subpath isn't engine-free (on 0.139, epsil.js imports the 2.5 MB chunk holding the engine), so parseText is injected and supplied from the lazy side. Upstream ask: an epsil entry that parses and serialises without the engine.

The printer is its inverse: markupOf(expr) emits one spelling, and readMarkup(parseMarkup(markupOf(e))) equals e over every reference example, as a test. Canonical: tokens for atom arguments, elements for the rest, trailing atom-valued options as slots in order (a tree-valued one stays a KeyValuePair child), Apply for nullary calls and heads no tag can spell, heads namespaced where not global (§4). No value: that's for authors. Over every reference example (6,600-odd), the trip is exact.

The reader reads the structural tree only. What a page shows is what evaluation returns (boxes, or a component head such as ComplexPlot), realized for the environment by the renderer (lit elements on a page); <notatio-slider name="k"> is renderer output, never reader input.

3. Evaluation by name

The resolver (Speculative Lazy Engine §2, createResolver in @enumeratio/manifest) already declares only the packages an expression's names need, but through one table generated from our own packages (DECLARERS, SYMBOLS): it has to know every name up front. A registry interface goes between the resolver and that table:

interface Registry {
  /** What declares `name` ("Mean", or qualified "Statistics.Mean"), or undefined. */
  resolve(name: string): Promise<Resolution | undefined>;
}
type Resolution =
  | { kind: "library"; library: Library }                                   // a package
  | { kind: "definition"; head: string; json: unknown; signature: string }; // Epsil, as data
  • Our manifest is one registry. A remote one (Speculative Lazy Engine §3: an index per namespace, a record and definition per name) is another.
  • Nothing lists all names: a registry is asked about the names an expression uses, and a remote one fetches its index per namespace, not per catalogue.
  • An Epsil definition is declared head by head, its body's names resolved the same way, to a fixpoint. A code package stays whole.
  • A name nobody resolves stays symbolic and is reported (Namespaces §5.3).
markup --htm--> MarkupNode --readMarkup--> MathJSON (non-canonical)
  -> names (heads, symbols, qualified names) -> registry.resolve each -> declare
  -> box (canonicalise) -> evaluate -> render for the environment
  (named slots are placed in the reading, given paramsOf)

Built as createRegistryResolver(registry).ensure(ce, json) in @enumeratio/manifest, after readMarkupText, then ce.box(json).evaluate(): the same at build and on a page. manifestRegistry(libraries) serves our packages, definitionRegistry(namespace, defs) a curator's Epsil definitions, combineRegistries(...) composes them. Held to the full engine over a sample of reference examples' markup, bare and qualified by their package's namespace (reference's markup-registry.test.ts).

3.1 Dependencies and pins

A symbol's dependencies are the names its definition uses, so they're read off the body, not declared by hand. What a definition declares is which version of each:

  • System names (our packages, compute-engine's) are never pinned: they move with the system, versioned as a whole. Packages stay the unit of loading code.
  • Everything else is named qualified and pinned in the definition's requires ({ "ada.Twice": "sha256-…" }). A definition's pin is the SHA-256 of its signature, body and requires, so a pin names content, not a mutable version label.
  • Its body is declared against those pins: each pinned name is replaced by that version's own head (ada_Twice_1a2b3c4d), so a dependency can't change under it, and two versions of one name live in one engine (a diamond resolves to two heads, not a conflict).
  • A remote name used without a pin is refused, not resolved to the latest.
  • The expression's own qualified names take a lock (name → pin); unlocked, they take the latest, and ensure reports what it chose, which is the lock entry to keep.

Built: pinOf, versions in definitionRegistry, requires and the lock in createRegistryResolver. Open: a contract looser than a pin (any version with this signature whose examples pass, the examples being the install check), and which system version a remote definition was written against.

4. . as the namespace separator

Epsil already parses . (measured, compute-engine 0.139):

Stats.Mean(x)            -> ["MemberCall", "Stats", "'Mean'", "x"]
canonical                -> ["Apply", ["Field", "Stats", "'Mean'"], "x"]
xs.Mean                  -> ["Field", "xs", "'Mean'"]        record access
xs.Mean()                -> ["MemberCall", "xs", "'Mean'"]   protocol functions only

MemberCall decides at canonicalisation between a field of the receiver (a record holding a function), a protocol function on the receiver's type (c.area(2) is area(c, 2)), and a qualified protocol call (Comparable.compare(x, y)). It does not turn an arbitrary unary function into a property: [1, 2, 3].Mean() is dot-call-not-a-protocol-function, even for the native Length. So xs.Mean() is ours to give meaning through protocols (Structures), and nothing else claims it.

A namespace is the first case: a constant symbol whose value is a record of functions. With no change to compute-engine:

ce.declare("Stats_Mean", { signature: "(list) -> number", evaluate: … });
ce.declare("Stats", { type: "dictionary<function>", isConstant: true,
                      value: ["Dictionary", ["Tuple", "'Mean'", "Stats_Mean"]] });
ce.box(["MemberCall", "Stats", "'Mean'", ["List", 1, 2, 3]]).evaluate();  // 42

So a namespace doesn't collide with field access; it is field access. <Stats.Mean> reads into the same node Epsil parses. The resolver sees MemberCall(N, m, …) / Field(N, m) with a free base N, asks the registry for N.m, declares the member under an internal name (. isn't legal inside a compute-engine symbol name), and declares N as a record of only the members resolved so far, reassigning it as members join (a constant can't be extended). A name already taken (a variable, a head) is never made a namespace; the qualified name is reported unresolved.

Rules that keep the readings apart:

  • A namespace is a capitalised name that is neither a head nor a protocol; the registry refuses one that is.
  • A bound variable shadows a namespace; only free symbols resolve.
  • Protocols are ours. We define the generic protocols; a registry's symbols may implement them but never declare one, so xs.Mean() can't collide across sources. If external protocols are ever wanted, they'd need the same namespacing and conflict rules as names (§4.1).
  • Fields before protocol functions, always: if property-style calls ever widen past protocols (ours or upstream), Stats.Mean(x) must stay a namespace lookup, never Mean(Stats, x).

This settles Namespaces §3.3's separator: ., no new syntax, fine in Markdown code spans, and what JSX already uses.

4.1 Which namespaces, and the search path

  • Per curator in a registry (ada.WeakCompositions), and per package for our own (Statistics.Mean). Package namespaces aren't required of every package, but are the default.
  • compute-engine's heads are the root.
  • A search path brings chosen namespaces' names into the global one. A signature conflict between two of them is an error when the search path is set up, not at evaluation, and the setup resolves it explicitly, per name (exclude one, alias one, prefer one). Promotion (Namespaces §4) is adding to the default search path, so a qualified reference keeps working.

Built as searchPath(registry, { use, prefer, exclude }): every conflict is listed at once (SearchPathError), a name the system has can only be excluded, and with a path ensure writes the bare heads it brings in qualified (Sq(3) is bob.Sq(3)), symbols untouched, and returns that expression. A registry serves a namespace's index (names) for this; nothing else needs one. Aliasing (ada.Sq as AdaSq) isn't built.

4.2 Libraries, and catalogs of them on package hosts (npm first)

A library is a published package of symbols; a registry of libraries is a catalog.

npm already has most of what a registry needs, and a CDN in front of it:

  • Immutable versions and integrity. A published version can't change (only be deprecated, or unpublished within 72 hours), and each tarball carries a SHA-512.
  • Namespaces with owners. A scope (@ada/…) belongs to its owner, so a curator's namespace is its scope: @ada/primes serves ada.*. That settles who may publish into a namespace, which nothing else here does.
  • Static files over HTTP. jsDelivr, unpkg and esm.sh serve any file of any version with CORS, so a browser fetches one symbol's files without installing anything, and the build fetches the same URLs.
  • Dependencies and semver, for installing; a document's lock is still ours (below).

What it lacks is per-symbol identity: npm pins a tarball, not a symbol. That lives inside the package, so npm is the transport and the index is ours.

The marker is a package.json field, plus a keyword for search:

{
  "name": "@ada/primes",
  "keywords": ["enumeratio-library"],
  "enumeratio": { "namespace": "ada", "index": "./reference/index.json", "system": ">=0.1" },
  "exports": { ".": "./dist/index.js", "./reference/*": "./reference/*" },
  "peerDependencies": { "@cortex-js/compute-engine": "*" }
}
  • namespace must be the scope's name (an unscoped package has to state one, and a registry may refuse it); system is the range of the system it was written against.
  • reference/<Name>/ is a reference folder as ours are (index.md, examples.tsv), plus definition.json: signature, body, requires.
  • reference/index.json is generated at pack time: each name's signature, pin and requires, so a search path and a resolver read one small file per namespace, then fetch only the definitions an expression uses.
  • The examples are the install check: a definition whose examples don't evaluate to their expected fails to install, or is flagged.

As a plain library too. The same package's exports["."] is JavaScript generated at pack time, so it works without a registry:

  • declare(ce) declares every definition into a compute-engine, for a host that installs the package the ordinary way;
  • each symbol compiled to a function (compute-engine's JavaScript target), for a caller with no engine at all: import { NextPrime } from "@ada/primes".

Only the Epsil in reference/ is ever read by a resolver; the JavaScript is a convenience generated from it, never a second source. A package whose symbols need code (a kernel Epsil can't express) is a code package, loaded only from origins the host trusts (Speculative Lazy Engine §3).

Dependencies. A package's dependencies name the packages whose symbols its definitions pin, so installing it installs them; the pins in index.json decide what's used. Our own packages would publish the same way, which is the dogfood.

Built: catalog(["@ada/primes@1.0.0", …]) reads exact versions over jsDelivr (the fetch is injectable), checks the scope, and serves a definition only if it hashes to its index's pin; scripts/pack-library.ts <dir> writes the index.

The install check is built too: packing writes each symbol's examples from its record as examples.json (not part of the pin, so fixing a test doesn't change the symbol), and the index counts them. With check: { engine, mode }, the resolver runs a definition's examples in a scratch engine, its name locked to its pin, before declaring it, once per pin: enforced, a failing example refuses it (and whatever depends on it); flagged, it is declared and the failures reported.

The JavaScript entry is built too: packing writes dist/index.js and index.d.ts. declare(ce) declares every definition under the same pinned heads the registry uses, after the packages its pins point into (a namespace's package is the dependency in its scope, so @bob/extra imports @ada/primes). Each definition that compute-engine compiles to self-contained JavaScript is exported as a plain function, and checked against its examples at pack time. Code that needs compute-engine's compiled runtime (_SYS: special functions, and the list broadcasting even a call of another definition gets) isn't exported, since that runtime isn't public: an upstream ask, to export it from compute-engine/compile so every compiled symbol can ship standalone.

Ranges are built too: lockLibraries(["@bob/extra@^2.0.0"], { lock }) returns the exact versions to read, closed over the symbol packages they depend on (a dependency is one when its package.json has an enumeratio field; others are npm's business). Per package, the highest version every range asking for it admits (semver), one version per package; a locked version holds while every range still admits it, and ranges no version meets throw, naming who asked. The version list comes from jsDelivr's data API.

The system range is checked too. The system is versioned as a whole, by the repository's own version (SYSTEM_VERSION, 0.0.0 until we release), so a package states the range it was written against ("system": "0.x"). lockLibraries chooses the highest version the ranges admit whose system admits the system's version, and moves a lock that no longer runs; the catalog refuses a package that doesn't. Open: whether the range should live in npm's own engines ("engines": { "enumeratio": "0.x" }), which the registry's metadata carries per version, so choosing needn't read each candidate's package.json.

Attributes, named slots and notation are published too:

  • definition.json may carry attributes (HoldAll for now) and defaults. Both are part of the pin, when present, since they change what a call evaluates to.
  • Options are compute-engine's named arguments: optional parameters named in the signature ((x: number, factor: number?) -> number), so they're per signature, and a call gives them by name (Scaled(3, factor: 5)). compute-engine has no defaults, so defaults: { "factor": 2 } fills what a call leaves out.
  • HoldAll puts a call's arguments into the body as written. compute-engine evaluates a Function's arguments when it applies one, even for a lazy head.
  • The index lists each symbol's params (its Function's parameter names, which markup places by), defaults and attributes.
  • Notation is data, reference/<Name>/notation.json, inline in the index so a host can load every library's LaTeX before it builds an engine (compute-engine's dictionary is fixed then, cortex-js/compute-engine#393):
    • TraditionalForm is box templates, by arity. A TemplateSlot per parameter is filled with that argument's boxes ({ Tight: true } fences it as a base), or a call gives the name of a call written name(args…): { "params": ["s", "z"], "call": ["SubscriptBox", "Li", ["TemplateSlot", "s"]] } is Li_s(z). Boxes have an Epsil spelling, so a template can be written in Epsil too.
    • LaTeX is a trigger, a kind and a precedence. compute-engine parses and serialises from those alone, with no handler.
    • It names no head: it is compiled (@enumeratio/boxes' compileNotation) for the head the definition is declared as, so the pinned name never has to be written. The resolver hands it to the host (notation option) as it declares the definition.
    • Not part of the pin: a change to it never changes a value. The version check diffs it.
    • Data covers our notation so far: templates reproduce our code rules box for box (the analytic family, in boxes' tests). A package-level JavaScript entry stays the fallback for what data can't say (a parse that rewrites, like residues' \pmod into Congruent), and names heads only as written.
  • Through a namespace record, compute-engine evaluates the arguments and sees no parameter names (the callee is a field value). So ensure returns calls by head where that matters: a definition with attributes, or a call by named argument. Upstream: a Field callee that names a declared operator gets its names and lazy (cortex-js/compute-engine#390).

Hosts read them through the kernel. createKernel(ce, catalogue, { libraries, notation }) resolves a call's qualified names after the catalogue's: each definition fetched, checked and declared the first time a call names it, its notation registered under its pinned head. The catalogue's own resolver declares whatever packages a definition's body names, in the install check's engine too, so a library can build on the system's heads. The site lists its libraries in theme/libraries.ts (#582), and the page's worker kernel and the build's prerender both read them; markup gets their parameter names when the config loads.

All of this lives in @enumeratio/manifest/libraries, so the resolver alone doesn't bring semver, and reads from a PackageHost: npm and GitHub (a repository's tags) over jsDelivr today, the namespace being whatever name the host reserves (npm's scope, GitHub's owner). GitLab, or a private registry, is another host.

4.3 What a version number promises (proposed)

Not settled. Pins make a dependency exact, so semver isn't what keeps a definition correct; it's what a range (^1.2.0) trusts when it pulls in a newer version. So the bump should be computed, not declared: pack-library --since <previous> diffs the package against its last published version and refuses a version number smaller than the change needs. The surface it diffs, per symbol:

surface minor (compatible) major (breaking)
names a symbol added a symbol removed or renamed
signature a parameter widened, a return narrowed, an optional parameter or overload added (by compute-engine's subtype check) a parameter narrowed, a return widened, arity reduced, an overload removed
named slots (options, named parameters) one added one removed, or its type narrowed
attributes (hold, listable, …) any change: it changes what evaluates
notation (LaTeX triggers, serialization) a trigger added a trigger removed or rebound (a document stops parsing, or means something else)
behaviour: the previous version's examples new examples one of them now fails
system range widened narrowed

Everything else, a body whose pin changed with all of the above intact, is a patch. Examples are the behavioural contract: the previous version's examples run against the new definitions, and one that fails is a break, unless it was aspirational (or disagreed with its known), in which case it's a fix and stays a patch.

For now (2026-09-30): a notation change that only changes printing is a patch, and narrowing the system range is major; both are open.

Built as pack-library <dir> --since <previous-dir> (check-version.ts, changesOf / versionSays in @enumeratio/manifest/libraries): names, signatures (compute-engine's function subtyping decides widened), pins, examples added and the system range, plus the previous version's examples run against the new definitions. A caret range reads the version, so in 0.x a minor bump carries a break. One consequence it surfaced: changing a symbol changes its pin, so every symbol of the same library that pins it breaks until its requires moves too, and pack-library refuses a library whose pins don't resolve (its own symbols, and those of dependencies installed beside it). Named slots, attributes and notation are diffed too: a parameter renamed, any change of attributes, and a trigger removed or reading as another head are breaks; a trigger added is minor; a TraditionalForm rule changed is a patch.

The same tool versions the system: diff the manifest (every head's signatures, attributes, notation) and the reference examples against the last release, and that's the bump for SYSTEM_VERSION.

Upstream (compute-engine), smallest first: print Apply(Field(N, "m"), args) as N.m(args) (it prints Apply(Stats.Mean, x) today), in Epsil and LaTeX; an engine-free epsil entry (§2); later, a lazy record, whose missing field asks a hook, for load-and-resume.

4.4 Mappings: what a library tracks beyond its symbols (agreed 2026-10-01)

Our records carry every system's forms and answers for every head (examples.values.<system>.tsv), the ids other sources give it (references), and how other systems write a call (bindings), most of them empty for any one package. A library says what it tracks instead, and publishes it in one shape. All of it is mappings: a symbol mapped to something outside it.

  • The library names its targets in its enumeratio field: "mappings": ["wolfram", "mpmath", "dlmf", "wikidata"]. That's what the scans and the forms collector run for it, so a package no one checks against Sage has no Sage column, and a catalog knows which oracles a library's claims rest on. Our own packages declare the same, which trims the columns we keep.
  • What a target can do is the target's, known once, and grows. Every target is in @enumeratio/entry's SOURCES (its label and link pattern); one that can check examples has a scan lane in @enumeratio/oracle (SYSTEMS: Wolfram, SymPy, mpmath, Sage, Oscar, Julia, mathlib4, Rust). It's progressive, not two fixed kinds:
    • a source that only documents a symbol today (an id in OEIS, FindStat, Wikidata) may later check specific values (a sequence's terms, a statistic's values, a property's value), once it has a lane, without any library changing;
    • a pointer to another library that implements the same function (a Sage or SymPy module, a Julia package) is a reference with a URL before anyone has written the binding and the scan that would make it an oracle.
  • The published contract is JSON under a JSON Schema, generated from @enumeratio/entry's types as the records' schema is: per symbol, mappings.json, holding the declared targets' references, bindings and per-example implementations. pack-library packs it from the record; the index lists which targets each symbol has data for.
  • How an author keeps the source is theirs, but we make our format the easy one. For now that's the record folder as ours are (index.md front matter, examples.values.<system>.tsv), and our tooling reads, writes and tidies it (@enumeratio/entry/node, format-records.ts). When the format changes, the tooling ships a codemod that reshapes a library's source, as it will reshape our packages when they move out of the monorepo.

The template library is enumeratio/library-template (public, a GitHub template, tagged v0.1.0): figurate numbers in Epsil, with an option and its default, a pin into itself, a system head, notation as data, and mappings to Wolfram, OEIS and Wikipedia. Read over the live jsDelivr by exact version, it resolves, passes its install check and evaluates (enumeratio.PyramidalNumber(4, sides: 4) is 30). What writing it turned up:

  • The resolver read only short-form MathJSON, so anything parsed from Epsil found no names. Fixed (#573).
  • Writing definition.json by hand was the worst part, so now it's generated. A definition is Epsil in its record's front matter (definition: { signature, body, defaults }); packing parses the body as a lambda over the signature's parameter names (so a parameter called factor isn't read as compute-engine's Factor) and pins every qualified name it uses: the library's own at their pins, an installed dependency's from its index, anything else from an explicit requires.
  • The tooling isn't published, so the system ships it as a GitHub Action: a library's CI runs uses: enumeratio/enumeratio/.github/actions/pack-library@<ref>, which packs it with the system at that ref and fails when the committed packed files are stale. The ref is the system version the library is written against, pinned by the library; a numbered system version (versioning the system) would make that ref a tag.
  • jsDelivr won't list a new repository's versions (502 from its data API). The GitHub host falls back to GitHub's own tags API (each version tag, a leading v dropped), so a range locks (^0.1.0 to 0.1.0).
  • Packed files are committed, since a tag serves the repository as it is.
  • Packed output is JSON for now, a file per symbol fetched as an expression needs it. It's a build artifact, so it can change to whatever reads best by machine: a columnar format (Parquet) pays off for bulk tables, a library's examples or the printed forms, before per-symbol files.

Scanning a library: oracle-scan.ts --root <library> reads one library's records and asks only the systems its mappings names, writing its own examples.values.<system>.tsv. The emitter maps the library's heads from its records' origin: mapped bindings, by the name an expression calls them (enumeratio.PolygonalNumber(4, 5) is PolygonalNumber[5, 4]); a head with no binding is unmapped, not sent as written. The template's PolygonalNumber agrees with Wolfram's on all its examples. That one command is a library's nightly lane.

Not committed to yet: where each kind of data lives (sidecars, columns in examples.tsv, front matter), claimed versus re-checked verdicts, and per-symbol opt-outs stay as they are. The next step is one toy library in its own repository, to see how a standalone library feels to write and how it behaves in the system, before reshaping ours with a codemod. Most of our packages should become libraries in their own repositories; the thinner the core, the better.

5. The notatio example column

Examples-as-Data §2 keys notatio as "the vdom as Vue/React markup". It becomes the canonical markup (§2), framework-free, from markupOf, pinned by the round-trip test. The key stays notatio. Component stories' vue and react snippets go, for this one column.

6. What gets deleted

  • packages/frontend/src/generate.ts, vue.ts, react.ts (and #436's notatio-vue.ts), the gitignored vue-generated.ts / react-generated.ts; the ./vue, ./react, ./generate exports; vue and react as peers and dev dependencies; reflect.ts's wrapperName.
  • web/.vitepress/config.mts's generate("vue"); the theme's registerNotatio.
  • <Notatio>: the pages using it, and those using <Plot>, <Cell>, <Row>, …, switch to the notatio-* elements until markup renders through §3.
  • collect-stories.ts' vue / react snippets and their test.
  • Docs: Vdom, Components-and-Symbols (the generated wrappers), Examples-as-Data §2, the playground pages describing the wrappers, AGENTS.md's Names line.

7. Order

  1. Delete (§6), the site still building, the theme chunk still free of the engine.
  2. Reader and printer in formats: htm, the token pass, readMarkup, parseMarkup, markupOf; the round-trip test; the notatio column.
  3. Registry: the interface in manifest, the manifest as the first registry, evaluateMarkup, namespaces as records, named slots placed at canonicalisation.

Open

  • A catalog viewer, in its own repo: browse a catalog's libraries, their docs and examples, run a library's examples in the reader's browser (the install check, on the page), and optionally contribute the results (browser, system, pass or fail) to shared statistics. Needs its own plan: where results go, what's collected, and consent.

  • @enumeratio/catalog predates this: the combinatorial catalogue's records plus a first resource registry and search path (ResourceRegistry, Namespaces §8), which @enumeratio/manifest's registry and search path now cover. Fold or retire it, and settle the name, since "catalog" now means a registry of libraries.

  • Our packages' notation as data. Libraries write it as data; our 14 packages still write JavaScript rules. Porting them would make all of it diffable and portable, leaving JavaScript for parses like \pmod. A head's data sits beside its record in the package that declares it (reference/<Head>/notation.json), as a library's sits beside its definition; the package's notation entry combines the compiled data with whatever code rules remain. analytic goes first: its rules all fit today's templates.

    Notation data: three extensions (approved and built, 2026-10-04). A survey of the other 13 packages (49 rules, 2 LaTeX entries) found 35 rules fit the first templates. Three extensions cover 10 more:

    • Explicit call arguments: args on a call template, slots and expressions written as they are: H_n(q), Δ_K(t), V_K(t).
    • A rest parameter: "...xs" last in params matches it and more; its slot joins the operands with Separator (and Tight per item), spliced into a row: G × H × K, A ⊗ B, and in args it spreads: P(a, b, …).
    • A list slot: { Items: true, Separator, Open, Close, Empty } spreads a literal List's items (with scalars: false): M_{(1,2)}, M_∅.

    Hecke, braid, hopf and groupalgebra are data now (hopf and groupalgebra entirely), every form their examples print unchanged. Still code: Braid (run-length σᵢⁿ), AdicValuation and AdicNorm (they read the prime out of a literal AdicNumeral), HeckeT (a word joins bare only while its letters are digits), and residues' LaTeX (\pmod rewrites Equal to Congruent). A package compiles its notation.json files with @enumeratio/manifest's scripts/collect-notation.ts.

  • Named slots vs options on one head: the signature decides, so a parameter and an option can't share a name. A lint over the records holds that.

  • {…} values (JSX expression containers): not needed with tokens and value; revisit if data props want them.

  • The search path (§4.1).

Home

Project

Design: every design page, settled, speculative and archived

Clone this wiki locally