Repository navigation
Speculative Vdom Markup
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:
- one markup syntax that is FullForm written as JSX: any expression, readable into MathJSON without knowing anything about the symbols it names;
- a small reader, engine-free, over a tiny JSX-like parser;
- evaluation that declares only what the expression names, resolved by name through a registry, so no host installs, or even knows, the whole catalogue;
-
.as the namespace separator, which Epsil already parses as field access.
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).
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.
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>isPolyLog(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'sH), 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.
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)" />isSin(k * x), the head gone once read). NotInputForm: 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.
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 thish. 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 bygrep -c ComputeEngineon the theme chunk being 0. compute-engine'sepsilsubpath isn't engine-free (on 0.139,epsil.jsimports the 2.5 MB chunk holding the engine), soparseTextis injected and supplied from the lazy side. Upstream ask: anepsilentry 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.
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).
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, andensurereports 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.
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(); // 42So 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, neverMean(Stats, x).
This settles Namespaces §3.3's separator: ., no new syntax, fine in Markdown code spans,
and what JSX already uses.
-
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.
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/primesservesada.*. 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": "*" }
}-
namespacemust be the scope's name (an unscoped package has to state one, and a registry may refuse it);systemis the range of the system it was written against. -
reference/<Name>/is a reference folder as ours are (index.md,examples.tsv), plusdefinition.json: signature, body, requires. -
reference/index.jsonis 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
expectedfails 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.jsonmay carryattributes(HoldAllfor now) anddefaults. 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, sodefaults: { "factor": 2 }fills what a call leaves out. -
HoldAllputs a call's arguments into the body as written. compute-engine evaluates aFunction's arguments when it applies one, even for alazyhead. - The index lists each symbol's
params(itsFunction's parameter names, which markup places by),defaultsandattributes. -
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
TemplateSlotper parameter is filled with that argument's boxes ({ Tight: true }fences it as a base), or acallgives the name of a call writtenname(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 (notationoption) 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, likeresidues'\pmodintoCongruent), and names heads only as written.
- TraditionalForm is box templates, by arity. A
- Through a namespace record, compute-engine evaluates the arguments and sees no parameter
names (the callee is a field value). So
ensurereturns calls by head where that matters: a definition with attributes, or a call by named argument. Upstream: aFieldcallee that names a declared operator gets its names andlazy(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.
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.
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
enumeratiofield:"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'sSOURCES(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
referencewith 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-librarypacks 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.mdfront 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.jsonby 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 calledfactorisn't read as compute-engine'sFactor) 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 explicitrequires. -
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
vdropped), so a range locks (^0.1.0to 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.
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.
-
packages/frontend/src/generate.ts,vue.ts,react.ts(and #436'snotatio-vue.ts), the gitignoredvue-generated.ts/react-generated.ts; the./vue,./react,./generateexports;vueandreactas peers and dev dependencies;reflect.ts'swrapperName. -
web/.vitepress/config.mts'sgenerate("vue"); the theme'sregisterNotatio. -
<Notatio>: the pages using it, and those using<Plot>,<Cell>,<Row>, …, switch to thenotatio-*elements until markup renders through §3. -
collect-stories.ts'vue/reactsnippets 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.
- Delete (§6), the site still building, the theme chunk still free of the engine.
-
Reader and printer in
formats: htm, the token pass,readMarkup,parseMarkup,markupOf; the round-trip test; thenotatiocolumn. -
Registry: the interface in
manifest, the manifest as the first registry,evaluateMarkup, namespaces as records, named slots placed at canonicalisation.
-
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/catalogpredates 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:
argson acalltemplate, slots and expressions written as they are:H_n(q),Δ_K(t),V_K(t). -
A rest parameter:
"...xs"last inparamsmatches it and more; its slot joins the operands withSeparator(andTightper item), spliced into a row:G × H × K,A ⊗ B, and inargsit spreads:P(a, b, …). -
A list slot:
{ Items: true, Separator, Open, Close, Empty }spreads a literalList's items (withscalars: 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 literalAdicNumeral), HeckeT (a word joins bare only while its letters are digits), and residues' LaTeX (\pmodrewritesEqualtoCongruent). A package compiles itsnotation.jsonfiles with@enumeratio/manifest'sscripts/collect-notation.ts. -
Explicit call arguments:
-
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 andvalue; revisit if data props want them. -
The search path (§4.1).