Skip to content

Speculative Nucleus Facade

enumeratio edited this page Oct 4, 2026 · 3 revisions

The nucleus facade

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

Status: speculative (2026-10-04). Survey of what libraries take from compute-engine today, and what the nucleus would expose in its place. It backs the "only the nucleus knows compute-engine" direction in Speculative-Per-Package-Repos §4 and is meant to decide whether to adopt it, not to assume it. Counts are a snapshot of main and go stale; the shape of the table is the point.

1. What the survey says

  • The import surface is tiny. Libraries import two names almost everywhere: the ComputeEngine type and the BoxedExpression type. A handful of files import a dozen others (§2). A lint rule against direct imports is cheap, and most of the migration is one codemod.
  • The real coupling is members, not imports. Everything a library does with an engine is a method on a ComputeEngine or a BoxedExpression it already holds: ce.number, ce.function, ce.declare, .evaluate(), .operator, .re. Re-exporting the types keeps all of that reachable. What breaks on a compute-engine minor is a short list of patterns (§3), not the import lines.
  • Libraries already depend on the layer that would become the nucleus. Nearly every library imports @enumeratio/engine, and analytic imports ce-patches from most of its files. The facade is mostly those two packages plus a few helpers from boxes and structures, put behind one name.
  • "Almost only Epsil" is a different axis, and the facade doesn't deliver it. Libraries are mostly TypeScript kernels with a thin declare on top. Most kernels don't import compute-engine at all, so a CE-free library is soon; an Epsil-only library is not (§5).

My read: the import boundary, the pin, and the lint rule pay for themselves. Narrowing the members (§4.3) is a second, optional step, and it costs casts. The Epsil goal rides on kernels, per head, and is orthogonal.

2. Inventory: names imported from compute-engine

Libraries only (packages/symbols/{algebras,analysis,arithmetic,combinatorics,groups}), src/ and tests/ counted separately, files / packages. Class: a facade, b hook, c kernel code, d escape hatch.

Import Class Library src Library tests Becomes
ComputeEngine (type) a many files, most packages – Engine
ComputeEngine (new) a a few files (statistics probe, generators) most test files createEngine(), bareEngine()
BoxedExpression (type) a many files, most packages – Expr
MathJsonExpression (root, /epsil) a a few files, a few packages – Json
isNumber, isSymbol a analytic only – guards in the facade
BigDecimal c analytic, modular – numerics subpath
LatexDictionaryEntry, Parser, Serializer (/latex-syntax) b residues, combinatorics – LatexRule hook
JavaScriptTarget (/compile) b braid – compile hook
CollectionHandlers b/d combinatorics – extendHead option
LatexSyntax, LATEX_DICTIONARY, compile b – two packages createEngine() loads them

Nothing else. The nucleus side imports the rest (below), none of it from libraries:

Import Class Used by
parseEpsil, serializeEpsil, resolveLibraryNames, executeEpsil b formats, engine, components
WGSLTarget, GLSLTarget, PythonTarget, JavaScriptTarget, compile b formats, frontend, components, the site
CancellationError, isFunction, isString, isDictionary a evaluation, ce-patches
BoxedType, Type, version, dynamic import() a/d frontend, components, bench

2.1 Members used through the engine and expressions

Not imports, but this is where the surface is. Counts are files in library src/.

Member Class Reach
ce.number, ce.function, ce.declare a every library with kernels
ce.symbol, ce.box a box in a few libraries, symbol in most
ce.Zero, ce.One, ce.True, … a a few libraries
.evaluate(), .N(), .operator, .symbol, .re, .im, .json a most libraries
.isInteger, .isFinite, .isNaN, .isZero, .isSame, .subs, .ops, .canonical a several libraries
ce.precision, .bignumRe, BigDecimal c analytic, combinatorics, modular, numerals
ce.declareType a the seven algebra libraries
ce.lookupDefinition as a "is it native?" probe d analytic, combinatorics, statistics, and one file each in several others
mutating the looked-up definition (.operator.evaluate, .signature, .collection) d analytic, combinatorics, residues, hypercomplex, groupalgebra, number-theory
capturing a native handler (ce.box([…]).operatorDefinition?.evaluate) d analytic, numerals, hypercomplex
ce.pushScope/popScope/assume d analytic (Refine/Assuming), combinatorics
x as never, as unknown as d hundreds of occurrences, most in analytic and combinatorics
evaluateAsync, withTimeLimit a evaluation only

ce.box(json as never) is the most common cast: box's parameter type rejects arbitrary MathJSON. A facade box(json: Json) removes most of them at once.

3. What the nucleus already wraps

@enumeratio/engine has what libraries need most, as plain functions over a ComputeEngine:

  • Reading values out of expressions: operandsOf, collectionElements, symbolNameOf, integerAt, bigIntegerAt, bigRationalAt, stringAt. Written when compute-engine's isFunction() and friends didn't ship; they do now, so the two sets overlap.
  • Extending a native head in place: wrapOperator, widenSignature, threadOverLists, mayBeInteger, and overloads.
  • Options, messages, deadlines, randomness, InputForm, and the compiled entry.

It does not wrap: constructing an engine, typing box(json), the native-head probe, capturing a native handler, attaching a collection, scopes and assumptions, precision, LaTeX hooks, or compile targets.

ce-patches exposes three things at once:

  1. the patch registry (applyPatch, applyPatches, declareLibrary, patchSymbols), applied from a library's own declare;
  2. numeric kernels re-exported for libraries: complex arithmetic, BigCx, balls, logGamma, Hurwitz and Lerch, Barnes G, Stieltjes, Dirichlet L, Clausen, polylog;
  3. boxing helpers, EvalOptions, NativeEval, wantsNumber, isFiniteNum, isRealInt, numberResult, declined, BoxInput, precision helpers, and a ./wgsl-complex subpath.

The third group is the de-facto facade analytic already programs against. Folding ce-patches into the nucleus means one package owns the second group (pure numerics) and the third (helpers), and the registry stops being something libraries call.

Three ways to extend a native head exist now: wrapOperator/widenSignature in engine, extendBuiltin in structures, and compute-engine's own declare(name, def, { extend: true }). The CE API adoption session started moving engine onto the third: branch origin/backup/ce-api-adoption-wip replaces the hand-written signature join (joinWide and its union logic) with addSignature plus extend: true, and routes wrapOperator through the same call. It is unfinished. Two things to settle before building on it: widenSignature joined signatures into one union, while addSignature adds an overload, so result types now matter (the branch had to change Partition's result from any to list<any>); and later evaluate extensions replace earlier ones, so chaining still works by capture. The facade's extendHead should be that call, not a fourth mechanism.

4. The facade

One package, @enumeratio/nucleus (today's engine, with ce-patches inside). Subpaths keep the main entry light: libraries that only declare heads don't load numerics or compile targets.

4.1 Named exports

Main entry:

  • Types. Engine, Expr, Json, HeadDefinition, EvaluateHandler, EvalOptions, NativeEvaluate, Type.
  • Guards and accessors. isNumber, isSymbol, isFunction, isString, isDictionary; the existing readers. Pick one set (§7, question 5).
  • Building. box(ce, json), num, fn, sym, str: typed over Json, so as never goes away. ce.number and ce.function stay as methods while Engine is compute-engine's own type (§4.3).
  • Declaring. declareHead(ce, name, def), declareType, and extendHead(ce, name, patch) over declare(…, { extend: true }), with wrapOperator, widenSignature, threadOverLists built on it. extendHead takes evaluate (wrapped by capture), addSignature, collection, broadcastable, canonical.
  • Probing. isNativeHead(ce, name) for the "never redeclare" probes, and nativeEvaluate(ce, head) for capturing the handler.
  • Scopes. withAssumptions(ce, conds, fn) over pushScope/assume/popScope, restoring on throw.
  • Options, messages, deadlines, randomness, overloads. As they are in engine.
  • Definitions in data. What entry and manifest already own (EpsilDefinition, the notation data), re-exported where a library needs the type.

/numerics: pure kernels with no compute-engine types: complex arithmetic, BigCx, balls, the special-function kernels from ce-patches, and BigDecimal (re-exported, since compute-engine's own class is what its boxed numbers hold). The kernels stay importable by a library that wants only them.

/testing: createEngine(libraries?) and bareEngine(). Tests build engines in most test files today; this is the one place that does it, and it loads the notation and LaTeX dictionary before construction, which compute-engine fixes at that point.

/compile, /epsil: the compile targets and the Epsil reader and printer, for the toolchain and interface. Libraries have no use for them.

4.2 Hooks

Interfaces a library implements instead of reaching for compute-engine's.

Hook Replaces Shape
Notation registerNotation, raw LatexDictionaryEntry lists The existing PackageNotation (traditional rules, LaTeX, macros), compiled from notation.json per head. Already data for most libraries; the raw entries in residues and combinatorics become LatexRule.
LatexRule LatexDictionaryEntry, Parser, Serializer { name, kind, trigger, precedence, parse?(p, lhs), serialize?(s, expr) } with p and s narrowed to what those two files call (parseGroup, parseToken, wrap, wrapShort, serialize, serializeFunction).
Formatters box rules, makeBoxes As boxes has them; the notation hook feeds them.
Serialization and parsing parseEpsil, serializeEpsil, the InputForm pass Nucleus internal. Libraries give records; they never call these.
Compile JavaScriptTarget, compile, the compile field on a definition HeadDefinition.compile as a typed field, and one compileTarget(name, impl) registration. braid's direct JavaScriptTarget use becomes a call to the nucleus's own compile.
Collections CollectionHandlers, operator.collection = … extendHead(ce, name, { collection }), merged by capture like evaluate.
Carriers and algebras declareAlgebra, declareCarrier* As structures has them.
Components direct compute-engine use in components and frontend Out of scope here, and an open question (§7, question 3).

4.3 Import boundary, then members

Two steps, in this order.

  1. Boundary. Engine and Expr are aliases of compute-engine's own types. The lint rule (§4.5) and the pin do their work; nothing about what a library can do changes. This takes care of the pin (one exact compute-engine, in the nucleus only) and of upgrades being a nucleus matter, as long as the facade absorbs the §2.1 patterns.
  2. Narrowing. Engine and Expr become interfaces holding only the members libraries use (a couple of dozen across the two). That is the "controlled subset", and it is what makes a compute-engine rename invisible to libraries. It costs: a narrowed Expr is no longer assignable where compute-engine wants its own, so every path back in goes through a nucleus function that casts once. Do it per library, driven by the usage table, after step 1. Skip it if step 1 plus the §2.1 absorption turns out to keep upgrades quiet.

4.4 The unstable entry

@enumeratio/nucleus/unstable, with unstable/latex-syntax and unstable/compile beside it, mirroring compute-engine's own subpaths.

  • Shape. Re-exports compute-engine as the nucleus's pinned copy, so there is exactly one class identity in the process, and adds raw(x): given an Engine or Expr, the underlying compute-engine value (identity at step 1; the escape from the narrowed types at step 2). Nothing else.
  • Undocumented. No wiki page, no API listing, no d.ts comments beyond a one-line warning. The semver of the nucleus follows the facade and excludes this entry.
  • Accountable. A use carries a one-line comment naming why and a link to the issue or compute-engine bug it works around (// unstable: <reason> <issue>). The toolchain check prints every use across libraries, and the census counts them. The target is zero; the count is how we see what the facade is missing.

4.5 Lint

  • Imports. no-restricted-imports (oxlint, through vp check) for @cortex-js/* in every library, static and dynamic, with an override for the nucleus's own tree. Message points at the nucleus.
  • Manifest. The toolchain check/pack rejects a library whose package.json lists @cortex-js/compute-engine as a dependency or peer; the nucleus is the peer.
  • Unstable. Importing @enumeratio/nucleus/unstable is allowed only with the justification comment above (a small script, since oxlint has no rule for it).
  • Ratchet. A committed baseline lists the files that still violate. The count may only go down, so the rule lands in one PR before the migration and tightens as lanes clear files. Tests are exempt until createEngine has landed.
  • Candidates, later. Restrict lookupDefinition, operatorDefinition and as never the same way; each becomes a one-line facade call once §4.1 has them.

5. Which libraries, and how code-free

"CE-free" means the library imports nothing from compute-engine, only the nucleus. "Epsil-only" means its heads are records with Epsil definitions and no kernel. LOC are src/ TypeScript, rounded; glue is declare*, notation, index; the glue column includes handler code that lives in declare files, so it overstates how thin that layer is.

Library LOC CE-importing src files Beyond the facade Kernel CE-free Epsil-only
hecke, incidence, quiver 350–450 each one or two one native probe in hecke exact algebra over bigint/rational now no; kernel is the algebra
hopf, diagram ~800 one or two none algebra, bases, diagrams now no
geometric ~430 four none thin over hypercomplex now possibly mostly
polytope ~800 none none geometry already no
adeles ~1,000 one none exact arithmetic now no
groupalgebra ~1,100 one signature widening, one probe group algebra now no
braid ~1,600 two compile hook braid words, Jones, Laurent polynomials after the compile hook no
modular ~1,800 one BigDecimal PSL₂(ℤ), forms, continued fractions after /numerics no
hypercomplex ~1,200 three definition capture (InvisibleOperator) Clifford and hypercomplex algebra after nativeEvaluate/extendHead no
residues ~1,700 four LaTeX hook, collection, probe sieves, factoring, discrete logs after LatexRule + extendHead arithmetic identities, little
numerals ~2,100 three definition capture numeral systems, adic after nativeEvaluate systems are data already; adic kernels stay
number-theory ~2,900 nine probes, handler wrapping (~2k LOC of rule handlers) exact arithmetic, Gaussian integers after extendHead the widening rules, partly
statistics ~5,000 seven probes, in-place patching closed-form builders over expressions; samplers after the §2.1 patterns pdf/cdf/CF/MGF tables, yes; samplers no
combinatorics ~29,000 about fifty probes, in-place patching, scopes, LaTeX, collection, a bare engine ~17k LOC pure enumerators and ranking; ~10k LOC with CE glue yes, after the same list counts and ranks as formulas, yes; generators no
analytic ~19,000 all but a handful BigDecimal, guards, definition capture, scopes see below the largest migration by far the smallest share

Seven or eight libraries could be CE-free in one pass, as soon as the main-entry facade exists: they use the two types and a few helpers, and a probe or two. That's the cheap, early win, and where a codemod does the work.

5.1 analytic

analytic is the outlier: roughly 110 source files, nearly all importing compute-engine, and almost none of it for the kernel. Most of those files hold a pure numeric function and the declare wrapper in the same file, so the import is for the wrapper's types. Splitting each file into kernel and wrapper makes the kernel CE-free before anything else changes. The code falls into groups (rough share of LOC):

Group Share Examples Facade needs Epsil outlook
Numeric kernels (double, BigDecimal, balls) ~40% Hurwitz/Lerch/Barnes G (in ce-patches), Carlson, Jacobi, theta, Meijer G, hypergeometric, nsum, interval balls, correctly-rounded N, certified /numerics, BigDecimal Stay TypeScript. Arbitrary precision and error bounds are the point; a recurrence-defined few (Chebyshev, Legendre, harmonic, q-series, Nörlund, Keiper–Li) are Epsil-sized and could move.
Rule and special-value tables behind wrapOperator pre-checks ~20% elementary special values, trig normalisation, inverse compositions, infinity arguments, constant rounding, widened, closed-forms-113 extendHead, nativeEvaluate Closest to Epsil: rows of identities with a guard. Needs a rule format whose guard can say "applies when". Medium.
Symbolic algorithms ~30% dsolve, Laplace/Fourier/Mellin/Hankel tables, optimize-core, differential-root, difference-root, find-instance, function-properties, signals scopes, lookupDefinition on Integrate/Sum/Apply, derivative tables The transform pair tables and the waveform definitions (signals) are data. The solvers and reducers are real code. Hard.
Interval and Around ~7% interval arithmetic, tagged propagation, centered intervals structured values Medium.
Alias and delegating heads small XGCD → ExtendedGCD, RisingFactorial → Pochhammer none Trivially Epsil: XGCD(a, b) := ExtendedGCD(a, b). Quick win and a good first test of the definition path.
Platform small shader.ts (a WGSL port of the zeta kernel) compile hook, ./wgsl-complex A component/codegen concern, not a library one.

The same split holds in ce-patches, which is almost all numerics and patch wiring; its kernels become the /numerics subpath, and the patches become the nucleus's own business.

Epsil definitions and hand-written kernels

Every head's meaning is data and Epsil: its record, its examples and its Epsil definition. A hand-written kernel is allowed beside it as an accelerated implementation, never instead of it. The kernel is cross-verified against the Epsil definition (agreement tests over the examples, Plausible sampling toward the domain's edges), so a library can drop or replace it without changing what the head means. This keeps the facade small where it matters: kernels need numerics and the declare glue, not the full engine.

6. Migration order

Cheapest and most shared first. Each step is one PR (fewer, bigger), and the ratchet keeps the list honest.

  1. Prerequisites. Finish or abandon the extend-based branch (§3), so extendHead has one meaning. Settle the name (nucleus vs engine); package renames wait (Component-Naming), so the facade can ship as engine growing new subpaths and be renamed later.
  2. Types and the lint ratchet. Add Engine, Expr, Json, the guards and /testing's createEngine; land the lint rule with a baseline. A codemod rewrites the ComputeEngine/BoxedExpression imports (the majority of all lines), and the same codemod swaps test-file engine construction. The §5 "now" libraries go to zero direct imports here.
  3. Absorb the §2.1 patterns: box(json) (removes most casts), isNativeHead, nativeEvaluate, extendHead with collection, withAssumptions. Several dozen probe sites and a few dozen in-place mutations, mostly in analytic and combinatorics; the mechanical ones by codemod, the rest by hand. This is the step that makes a compute-engine minor invisible.
  4. Hooks: LatexRule (residues, combinatorics), the compile hook (braid), notation data for the rest. /numerics takes BigDecimal (modular, analytic).
  5. Fold ce-patches in, once its helpers are the facade's. analytic's ~75 files change an import path; the patch registry goes behind engine construction instead of applyPatch in each declare. This changes who applies a patch, which touches declare order; coordinate with Speculative-Overloads-as-Data.
  6. Kernel and wrapper split, library by library, smallest first: the algebras, adeles, braid, modular, hypercomplex, residues, numerals, number-theory, statistics, combinatorics, analytic last. Each library ends with zero direct imports and its kernels CE-free.
  7. Narrowing (§4.3 step 2), per library, if it still looks worth it.
  8. Epsil, per head, alongside: aliases first, then the recurrence-defined and table-defined heads, reading from the same records the library template uses (Speculative-Vdom-Markup §4.2). This runs on its own clock.

Steps 1–3 are the whole of "libraries never import compute-engine". Steps 4–7 are how little code is left.

7. Decisions so far (2026-10-04)

  • Types: start with aliases of compute-engine's own types (lint and pin only); narrow Engine/Expr per library later, where it pays.
  • The full compute-engine API stays reachable only through the non-public, opt-in unstable entry. Whether the interface layer (frontend, components) is held to the same rule is still open.
  • Tests use a common testing engine from the nucleus (/testing, createEngine).
  • Kernels: see "Epsil definitions and hand-written kernels" above.
  • The extend-based rework (wrapOperator/widenSignature on declare(…, { extend: true }) with addSignature) gets finished, not abandoned; extendHead builds on it.
  • Heads are declared once, contributed to by many. A package doesn't own a head: compute-engine or the lowest package that introduces it declares it, and any other package contributes its own signatures, overloads or overrides through extendHead. What's forbidden is redeclaring an already-declared head (a clobber decided by order) or two packages contributing the same signature.

7. Open questions

  1. Re-export or narrow? Aliases get the lint, the pin and an easier bump, and leave the full member surface reachable. Narrowing gets the controlled subset and costs a cast at each boundary. Start with aliases and decide per library?
  2. Numerics. Does /numerics live in the nucleus, or as its own package? Its kernels are pure and the heaviest code we have, and BigDecimal is compute-engine's class either way.
  3. The interface. frontend and components also import compute-engine, for compile targets, the Epsil printer, BoxedType, and engine construction. Does "only the nucleus" cover them (compile and Epsil subpaths), or is the interface allowed direct access?
  4. Tests. Is createEngine from /testing the rule for library tests too, or may a library's tests keep compute-engine as a dev dependency?
  5. Accessors. engine's operandsOf and symbolNameOf predate compute-engine's own guards. One set, which?
  6. Patches. Applied from a library's declare today. In the nucleus, at engine construction for everyone, or declared by the library's records (requires: [patch])? The first changes behavior for libraries that don't ask for it.
  7. Kernels. Decided (2026-10-04): the goal is for libraries, and the crux of a library is data and Epsil. Compiled Epsil won't match hand-written kernels soon, so a head carries both: its Epsil definition is the meaning and the source of truth, and an optional hand-written kernel is an accelerated implementation that must agree with it, checked by agreement tests and Plausible (the pattern BL-30's conversions already use, with the roles stated this way round). The host prefers the kernel when present; the record says which heads have one.
  8. Versioning. The nucleus's version follows its facade, and a compute-engine bump is patch-safe or library-visible. Who decides, the census run on the new nucleus?

Home

Project

Design: every design page, settled, speculative and archived

Clone this wiki locally