This document describes the overall architecture of the Epsil Compute
Engine — a TypeScript library for symbolic manipulation and numeric
evaluation of mathematical expressions, published as
@cortex-js/compute-engine.
It is the high-level map of the codebase, intended for contributors and
maintainers. For the public API and usage guides, see
README.md and
cortexjs.io/compute-engine. Internal
architecture history is summarized in
docs/STATUS_REPORT.md.
The Compute Engine parses, manipulates, evaluates, and serializes mathematical expressions. Its capabilities fall into a few categories:
- Parse LaTeX (and ASCIIMath-ish lenient syntax) into a structured representation.
- Canonicalize expressions into a normalized form for consistent comparison and efficient operations.
- Evaluate expressions symbolically or numerically (machine precision or arbitrary precision).
- Simplify, expand, factor, solve, differentiate, and integrate.
- Reason about expressions using assumptions and a type system.
- Compile expressions to executable JavaScript (and GLSL/WGSL/Python).
- Serialize back to LaTeX, MathASCII, or MathJSON.
Everything in the Compute Engine flows between three representations of an
expression, mediated by the ComputeEngine instance:
LaTeX string MathJSON BoxedExpression
"x + \frac{1}{2}" ◄──► ["Add","x",...] ◄──► (boxed, typed, canonical)
│ │ │
latex-syntax/ math-json/ boxed-expression/
(parse/serialize) (interchange format) (the runtime object model)
- LaTeX is the human-facing input/output notation.
- MathJSON is the JSON-based interchange format — plain data, no behavior. It is the serialization boundary and the format consumers store and exchange.
- BoxedExpression is the live, in-memory object model. Boxed expressions carry a type, a definition binding, cached properties, and methods to evaluate, simplify, compare, and serialize themselves. All computation happens on boxed expressions.
The typical lifecycle:
Parse LaTeX ──► MathJSON ──► BoxedExpression (ce.parse)
Canonicalize normalize structure & operands
Evaluate symbolic / numeric result
Simplify apply algebraic rewrite rules
Serialize BoxedExpression ──► MathJSON ──► LaTeX (.json / .latex)
The ComputeEngine (src/compute-engine/index.ts) is the composition root that
owns scopes, symbol/operator definitions, assumptions, numeric configuration,
the standard library, and the LaTeX syntax. It exposes the public API:
parse(), box()/expr(), function(), evaluate(), simplify(),
declare(), assign(), assume(), ask(), and more.
src/
├── compute-engine.ts Full package entry (engine + LaTeX + compilation)
├── core.ts Core engine entry (no LaTeX, no compilation)
├── math-json.ts MathJSON types + utilities entry
├── latex-syntax.ts Standalone LaTeX parser/serializer entry
├── interval.ts Interval-arithmetic entry
├── numerics.ts Numeric primitives entry
├── compile.ts Compilation-targets entry
├── identities.ts Fungrim identities plugin entry (loadIdentities)
├── integration-rules.ts Rubi integration-rules plugin entry
├── epsil.ts Experimental published Epsil language entry
│
├── math-json/ MathJSON format: types + accessors/guards
│ ├── types.ts MathJsonExpression and its object forms
│ └── utils.ts operator(), operands(), type guards, accessors
│
├── big-decimal/ Arbitrary-precision decimals backed by native bigint
│ ├── big-decimal.ts BigDecimal class: arithmetic, comparison, rounding
│ ├── transcendentals.ts sqrt/exp/ln/sin/cos/atan/… (prototype-merged)
│ └── utils.ts fixed-point bigint primitives, π constant, pow10 cache
│
├── common/ Cross-cutting utilities
│ └── type/ The type system (see "Type system" below)
│
└── compute-engine/ The engine itself
├── index.ts ComputeEngine class (composition root, ~1700 lines)
├── compute-engine.ts Full-package wiring (LatexSyntax factory injection)
├── engine-*.ts Runtime services (one bounded concern per file)
├── types-*.ts Type contracts (kernel generics + concrete wrappers)
├── free-functions.ts Top-level parse/simplify/evaluate/N/… via a global engine
│
├── boxed-expression/ The runtime object model (the heart of the engine)
├── latex-syntax/ LaTeX ↔ MathJSON tokenizer, parser, serializer, dictionary
├── library/ Standard library of operators & constants, by domain
├── symbolic/ Simplification rules, calculus, the Fu trig algorithm
├── rubi/ Rubi rule-based integrator (loader, matcher, driver)
├── numerics/ Numeric algorithms (primes, rationals, special functions)
├── numeric-value/ NumericValue abstraction (exact / machine / bignum)
├── compilation/ Expression → JS/GLSL/WGSL/Python code generation
├── interval/ Interval arithmetic
└── tensor/ Vectors, matrices, multi-dimensional arrays
Outside src/: data/ holds the Rubi and Fungrim rule corpora consumed
by the plugin entries (see "Integration rules" below); benchmarks/ is
the cross-library benchmark and audit harness (see
benchmarks/README.md). Completed internal review
campaigns are summarized in docs/STATUS_REPORT.md.
A task-oriented index — where to start for the most common kinds of work
(paths relative to src/compute-engine/ unless noted):
| Task | Start here |
|---|---|
| Parse bug (LaTeX → wrong MathJSON) | latex-syntax/dictionary/definitions-*.ts |
| Serialization bug (expression → wrong LaTeX) | latex-syntax/serializer.ts + the operator's dictionary entry |
| Wrong canonical form | boxed-expression/arithmetic-*.ts, boxed-expression/canonical.ts |
Wrong evaluate()/N() result |
the operator's evaluate handler in library/<domain>.ts, then numerics/ for the kernel |
| Simplification missing or wrong | symbolic/simplify-rules.ts + docs/SIMPLIFY.md |
| Trig simplification | symbolic/fu*.ts, boxed-expression/trigonometry.ts (constructibleValues) |
| Integration (symbolic) | symbolic/antiderivative.ts for built-ins; rubi/ for the rule-based integrator; latex-syntax/dictionary/README.md for the ∫ parse→eval→serialize pipeline |
| Solve | boxed-expression/solve.ts, boxed-expression/solve-linear-system.ts |
| Type-inference bug | library/type-handlers.ts, src/common/type/ |
| Compilation target bug | compilation/<target>.ts; the tree walk is in compilation/base-compiler.ts |
Assumptions / verify() / ask() |
engine-assumptions.ts, assume.ts |
| Step-by-step explanations | boxed-expression/explain.ts, explain-labels.ts |
Within src/compute-engine/, modules are organized into layers with a strict,
acyclic dependency direction (enforced by the per-layer no-restricted-imports
overrides in .oxlintrc.json and a zero-circular-dependency budget checked
with madge):
4. Composition root index.ts (ComputeEngine) — composes services, public API
3. Runtime services engine-*.ts — one bounded concern per file
2. Type wrappers types-*.ts, global-types.ts — bind generics to concrete types
1. Kernel type layer types-kernel-*.ts — generic contracts, no engine imports
The ComputeEngine class is deliberately an "API shell and integration point":
business logic lives in the engine-*.ts services (startup, scoping,
declarations, assumptions, numeric configuration, caching, extension contracts,
compilation-target registry, etc.). The dependency rules below are enforced by
the Oxlint layering overrides and the zero-cycle check.
src/math-json/ defines the format. A MathJsonExpression is plain JSON in one
of these forms:
- A number —
2.5, or{ num: "3.14159..." }for arbitrary precision / special values ("NaN","+Infinity"). - A symbol —
"x","Pi", or{ sym: "Pi" }. - A string —
{ str: "hello" }. - A function —
["Add", "x", 1], or{ fn: [...] }. The first element is the operator (head); the rest are operands. - A dictionary —
{ dict: { ... } }.
Any object form may carry optional metadata: comment, latex, wikidata,
sourceUrl, and similar. src/math-json/utils.ts provides safe accessors
(operator(), operands(), stringValue()) and type guards. MathJSON has no
behavior — it is the inert boundary between the LaTeX layer, the boxed object
model, and the outside world.
src/compute-engine/boxed-expression/ is the heart of the engine. A
"boxed" expression is a class instance that wraps a piece of math with a
consistent interface for evaluation, comparison, and serialization.
_BoxedExpression (abstract-boxed-expression.ts) is the abstract base; the
concrete subclasses are:
| Class | File | Represents |
|---|---|---|
BoxedNumber |
boxed-number.ts |
Numeric literals (int, rational, radical, float, bignum, complex) |
BoxedSymbol |
boxed-symbol.ts |
Symbols/identifiers, bound to a value definition when canonical |
BoxedFunction |
boxed-function.ts |
Function applications [operator, ...operands], bound to an operator definition |
BoxedString |
boxed-string.ts |
String literals (stored NFC-normalized) |
BoxedDictionary |
boxed-dictionary.ts |
Key/value maps |
There is no dedicated tensor class: a vector/matrix/n-dimensional array is a
canonical List BoxedFunction. Tensor-ness is a lazy view over a
List's ops (boxed-expression/tensor-view.ts) — isTensor, .shape,
.rank and the List type handler all agree via one shared shape-claim
predicate, and packed numeric kernels obtain an AbstractTensor per operation
through packTensor (never cached on the node).
The shared public surface (on _BoxedExpression) includes the getters/methods
most consumers use: .json, .latex/.toLatex(), .operator, .ops,
.type, .canonical, .structural, .evaluate(), .simplify(), .N(),
.subs(), .match(), the algebra helpers (.add(), .mul(), .pow(), …),
and the three comparison methods .isSame() / .is() / .isEqual() (see
CLAUDE.md for their distinct
semantics).
box.ts is the factory: it takes raw input (a number, string, MathJSON, or
existing boxed expression), chooses the concrete class, and — by default —
canonicalizes the result. Canonicalization normalizes structure so that
mathematically equal expressions share a representation: it flattens associative
operators, orders operands, and folds exact numeric operands. The arithmetic
modules implement the per-operator canonical logic:
arithmetic-add.ts— flatten, drop zeros, fold exact numbers, sort terms.arithmetic-mul-div.ts— group by exponent, combine coefficients (Product).arithmetic-power.ts— exponent rules, root/radical normalization.flatten.ts,negate.ts,order.ts— supporting transforms.
canonical.ts orchestrates partial/selective canonicalization by named form
(Number, Multiply, Add, Power, Divide, Flatten, Order).
Three creation modes exist — canonical (default), structural (bound but not canonicalized), and non-canonical (not bound, usable only for pattern matching/serialization). The distinctions and pitfalls are documented in detail in CLAUDE.md.
Canonicalization applies a small set of algebraic folds to generic (unconstrained) symbols — an operand that carries a free variable stands for a generic value of that variable. These are deliberate conventions, not bugs: they trade exactness at a measure-zero point for a simpler canonical form.
| Input | Canonical form |
|---|---|
x / x, (x + 1)/(x + 1) |
1 |
1 ^ x |
1 |
x / 0 |
~∞ (ComplexInfinity) |
0 / x |
0 |
x / ∞ |
0 |
The cancellation asserts genericity: x/x → 1 treats x as a generic
(hence non-zero) value. This is why the fold survives a later substitution —
ce.parse('\\frac{x}{x}').subs({ x: 0 }) is 1, not Indeterminate, because the .json
is already the literal 1 before the substitution reaches it. To recover the
true value at an excluded point, keep the expression uncanonicalized
({ canonical: false }) and evaluate after substituting.
Constant operands are protected. These folds fire only on operands that carry a free variable. A numerator or denominator that is a constant expression is never collapsed by the generic rules; it is evaluated instead, so a provably-zero constant denominator surfaces the true indeterminate:
0 / (1 − 1)canonicalizes toIndeterminate— the denominator evaluates to0, giving0/0— not to0: the0/x → 0fold does not reach a constant zero.(1 − 1) / 0and0 / 0likewise canonicalize toIndeterminate. With a float operand (0.0 / 0.0) the canonical form isNaN.(π + 1)/(π + 1)is left as a fraction at canonicalization (a constant, so the identical-operand fold is skipped); it reduces to1only under.simplify(), which first proves the denominator non-zero.
Larger cancellations (x²/(5x²) → 1/5) and the wider policy that governs which
.simplify() rewrites treat an unknown as a generic real (why
ln(x³) → 3·ln(x) fires for an unconstrained x but not for a
declared-complex one) are simplify-level, documented in
docs/SIMPLIFY.md.
Whoever constructs a function or a symbol is responsible for giving it an
accurate type. A host such as Tycho declares its symbols and the parameters
of its functions with narrow types (real | signed_infinity | nan, not
number), and a declared result type is a promise the host makes: the
interpreter does not check a function's value against it (decision
2026-09-24). CE constructs the return values of its operators and of the
bodies it types, so CE owes an accurate, narrow result type for them: an
operator's type handler claims the tightest type its operand types prove
(Clamp(x, -1, 1) is real<-1..1>, tanh(y) is real), and a function
literal assigned under a declared signature whose result is unknown
reports the result its body has with the DECLARED parameter types
(resultUnderDeclaredParameters() in engine-declarations.ts). Where no type
information exists, a reasonable assumption is acceptable: a compiler that
meets a number operand assumes a real value. A result type that is too wide
is a defect of the return type, not a reason to change that assumption.
The numeric lattice is finite-by-default (ratified 2026-08-27; the decision
record is docs/plans/2026-08-26-numeric-lattice-ratification-brief.md). The
top type partitions into three groups of values that share no member:
number holds exactly the finite complex numbers (complex and everything
below it — real, rational, integer, imaginary), the infinite values
(infinity: the signed +∞/-∞, the unsigned ~oo, and mixed values such
as ∞ + i), and the not-a-number marker (nan). Bare real, rational,
integer and complex contain only finite values. The signed pair
exactly {+∞, −∞} is spelled signed_infinity — the union of the two value
types; its former one-word name non_finite_number was retired 2026-08-31
(the name was misleading: ~∞ and ∞ + i are non-finite numbers, yet
neither was a member). The sign-aware gates
and the 1/±∞ = 0 folds consume its signedness, which infinity (which
admits the unsigned ~oo) does not guarantee. The five finite_* names it
used to sit beside are gone: finite_integer, finite_rational,
finite_real, finite_complex and finite_number are retired from the
type system, and the bare names carry those meanings. The old spellings
still parse for one release cycle as deprecated aliases — each normalizes to
its bare name, and finite_number to complex — and are never emitted.
Every operator type handler follows these rules:
- Claim the signed pair
signed_infinityonly when the value is provably±∞. Examples:Ln(0) = −∞;Round/Ceil/Floor/Truncateof a provably real ±∞;±∞ · (finite reals all provably non-zero);EllipticK(1) = +∞;(+∞)^pfor finite realp > 0;±∞ + (real terms). - A result that may blow up is claimed as a union with the non-finite
branch spelled out. A pole-capable operator whose argument may land on a
pole claims
complex | +oo | -oo(the unknown-signLn, the bounded inverse functions at their poles); a result that may be~ooor NaN —x · ∞with a possibly-zero complexx,∞/∞,k/0— claimsnumber, the only type that admits every numeric value. A claim of barecomplexis a finiteness promise and must not cover a path that reaches a pole. Exception for extended-real operands (user decision 2026-09-25): a product or sum whose operands are all on the extended real line (real | signed_infinity, with or withoutnan) cannot be complex, so it claimsreal | signed_infinity, with ananarm when one factor may be infinite and a different factor may be zero (0 · ∞), or when two terms may be infinite (∞ − ∞). The same holds for a power with a non-negative base or an integer exponent (extendedPowerType()), an elementary function whose values are real (extendedElementaryFunctionType()inlibrary/type-handlers.ts; a function with no value at±∞, such asSin, takes ananarm for it),Max/Min, the reductionsSum/Product/Mean(extendedReductionType()), and the element type of a literal list (widenElementTypes()).x · ∞withx: realisnan | signed_infinity, and2ywithy: real | signed_infinityisreal | signed_infinity. A host readsnumberandinfinityas possibly complex, so the wider claims cost it the real value layout. The rules live in theMultiplyandAddtype handlers (library/arithmetic.ts,signedInfinitySum()forAdd). - Unknown finiteness no longer exists for the bare tiers. An operand
declared
real(orinteger,rational,complex) is finite by its type;Sin(x)withx: realclaimsrealbecause the input provably is one. Only an operand whose type admits an infinity (number,infinity, the±oovalue types, an extended union) triggers the non-finite analysis — and once an operand is provably non-finite, a claim that depends on another operand being non-zero (e.g.x · ∞ = ±∞, wherex = 0gives NaN) must prove it (viasgn), never assume it. An undeclared symbol is such an operand: it has no finiteness evidence, so a generic numeric application over undeclared operands —x · y— claims the top typenumber. The finite tiers are claimed at the rungs where finiteness is provable, not by default.
The values type onto the new names: oo and -oo carry the singleton
value-literal types +oo/-oo (widening to infinity); the value ~oo
carries the ~oo singleton (also below infinity); NaN and Indeterminate
type nan; a value
with an infinite real or imaginary part and a finite other part (∞ + i)
types infinity. Every spelling of the same value — the ComplexInfinity
constant, 1/0, Divide(~oo, 5), Add(1, ~oo), (-1)!, Gamma(-2),
Zeta(1) — types identically. The extended real line is spelled
real | +oo | -oo (the EXTENDED_REAL_TYPE constant in
src/common/type/primitive.ts), and the value predicate for it is
isExtendedReal — the former isReal, renamed when bare real became a
finiteness promise; NaN answers false to it.
Three non-finite results are kept apart. A pole is ~oo (5/0, Gamma(0)).
An exact form with no value (0/0, 0·∞, ∞ − ∞, 0^0, Mod(5, 0)) is
Indeterminate on the exact route. A floating-point failure or an absent
numeric operand is NaN (0.0/0.0, Missing + 2), and .N() and every
compiled target answer NaN for Indeterminate too. Indeterminate and
NaN are both number literals with isNaN true and both type nan; only
Indeterminate has isIndeterminate true, and isSame does not match one
with the other (design: docs/plans/2026-09-28-indeterminate-value.md).
On a float-only compile target, a ~oo lowers to IEEE +Infinity (ruled
2026-08-28): the pole's magnitude survives, and the direction ~oo never had
is dropped. This holds on the constant-folded, structural, and shader paths,
and — since 2026-08-30 — for a ~oo handed ACROSS the boundary as an argument
to a compiled function, which is projected to +Infinity instead of being
refused as a complex value. One consequence is that no compiled guard over a
plain JS number can distinguish ~oo from a true +oo; another is that float
arithmetic can change a pole's non-finite class where the interpreter's
projective arithmetic does not (Infinity - Infinity is NaN, while the
interpreter's ~oo - ~oo stays ~oo). A multi-clause parameter guard for the
infinity or nan type, or for the signed pair signed_infinity, therefore
cannot be faithful,
and such a clause set declines to compile whole-function, running interpreted
instead; the same holds for non-finite VALUE-literal clauses (oo, -oo,
NaN). The measurements behind each decline are recorded in ROADMAP.md.
The shared handler implementations (and per-operator dispatch) live in
src/compute-engine/library/type-handlers.ts; the convention is pinned by
test/compute-engine/non-finite-typing.test.ts.
Behavior is attached not to the expression but to its definition, resolved during binding against the current scope:
boxed-value-definition.ts—_BoxedValueDefinition: a symbol's type, value, constancy, andholdUntilevaluation semantics.boxed-operator-definition.ts—_BoxedOperatorDefinition: a function's type signature, algebraic flags (associative,commutative,idempotent,pure,lazy,broadcastable, …), and its handlers (canonical,evaluate,evaluateAsync,compile, comparison, collection).
The standard library (below) is just a large table of such definitions.
BoxedFunction.evaluate() looks up the operator definition and dispatches to its
evaluate handler. simplify.ts drives rule-based simplification with guards
against infinite recursion (deduplication, a step limit, and loop detection —
see CLAUDE.md).
serialize.ts produces MathJSON from a boxed expression. The directory also
holds focused modules for expand.ts, factor.ts, solve.ts /
solve-linear-system.ts, polynomials.ts, trigonometry.ts, pattern matching
(match.ts, pattern-utils.ts), comparison (compare.ts), and the type guards
exported to consumers (type-guards.ts).
Step-by-step explanations. explain.ts / explain-labels.ts implement
expr.explain('simplify' | 'solve' | 'D'), which returns a structured trace
of the operation with human-readable labels (a ~250-entry registry in
explain-labels.ts; the registry lives in boxed-expression/ rather than
symbolic/ for layering reasons). The derivative driver
(symbolic/explain-derivative.ts) self-registers via _setExplainDDriver,
keeping boxed-expression/ free of an upward import into symbolic/.
(Convention ratified 2026-07-24. It governs every operator, including future ones — check new binder operators against it.)
One rule governs how an assigned symbol value interacts with every operation:
A bound variable is a pure symbol: its declared type and in-scope assumptions apply, its assigned value never does. A free symbol's value always applies under evaluation — in every operator uniformly.
There is no taxonomy of "symbolic" vs "value-considering" operators. What varies is whether an operator binds a variable:
- Binder operators —
D/Derivative,Integrate,Limit,Sum,Product,Solve,Function(lambda parameters), comprehension/table indices, quantifiers — bind their own variable. (Most declare their binding sites via a selector on thescopeddefinition flag — seedocs/SCOPING-MODEL.md.Minimize/Maximize, once listed here, have no library definition and would be a new feature.) That variable is shielded from any same-named global assignment for the whole operation, including in the result: withx := 5,D(x², x)is2xandIntegrate(x², x)isx³/3, both left in terms of the still-symbolicx. Any other symbol in the operand is free and resolves normally: witha := 3as well,D(a·x², x)is6x. - Non-binder operators —
Together,Expand,Factor,Simplify, arithmetic, everything else — bind nothing, so all their symbols are free and all values apply:Together(1/x + a/x²)with those assignments is8/25. HoldValuesis not an exception but the rule made available à la carte: it is itself a binder, binding the assigned free symbols of its body (all of them, or a listed subset via a second operand) — the analogue of Mathematica'sBlock[{x}, …].HoldValues(Together(1/x + a/x²))is(x + a)/x². The granular compositional form also works:Block(Declare(w, 'real'), …)shadow-declareswvalueless for the body.
Two footnotes:
- The
.simplify()method (engine API, not reachable from the operator surface) is value-blind wholesale — as if the expression were wrapped inHoldValues. That is its documented contract (seedocs/SIMPLIFY.md); theSimplifyoperator evaluates its argument like any operator. - Shielding is implemented by shadow-declaring the bound variable valueless
(keeping its type; assumptions survive) in a temporary scope — use the
shared
withValueShieldhelper (boxed-expression/utils.ts), do not invent per-operator renames or new shadow-declare loops.
Implementation status: the convention is enforced across the binder operators
(D, Integrate, Limit, Solve — positional and bundled — and
JacobianMatrix), and HoldValues is implemented. The complete binding and
scope contract is in docs/SCOPING-MODEL.md.
src/common/type/ is a self-contained type system used to describe and check
the types of mathematical expressions. It models:
- Numeric primitives in a subtype lattice:
integer ⊂ rational ⊂ real ⊂ complex ⊂ number, with finite/non-finite variants and bounded ranges (e.g.integer<5..10>). - Collections:
list,set,tuple,record,dictionary, with shapes (e.g.matrix<number^(2x3)>for a matrix). A list type with no dimensions,list<E>, is read by its elements: a matrix of integers is alist<vector<integer>>, not alist<integer>. Alist<U^n>, a vector, a matrix and a tensor (tensor<U^(2x3x4)>, ortensor<U>of any rank) are read by their cells. Every head accepts every cell type that is not a list (list<boolean^2>,matrix<boolean>andtensor<boolean>are valid,list<list<integer>^2>andmatrix<list<integer>>are not), and the bare namesvector,matrixandtensormean number cells. Alisthas one axis at most:list<boolean^(2x2)>is refused, and is spelledmatrix<boolean^(2x2)>. The rules are indocs/TYPE-SYSTEM.md, section "Lists, tensors andbroadcastable". - Broadcastable values:
broadcastable<T>— "aT, or an array of any rank whose cells areT, element-wise" (T | tensor<T>, for aTthat cannot hold a list). The static type of arithmetic whose operand may broadcast at runtime (an unknown-return call such as2h(x) - 1).T <: broadcastable<T>andlist<T> <: broadcastable<T>, butbroadcastable<T>is not a subtype ofT, oflist<T>or ofT | list<T>(a matrix is not alist<number>): a parameter that accepts a number or a numeric array of any rank is spellednumber | tensor. Produced by theAdd/Multiplytype handlers and the generic broadcast typing (broadcastLiftType()inboxed-expression/broadcast-lift-type.ts, called fromboxed-function.ts); the trigger is applications-only (a bare symbol'sunknownis inference-pending and stays scalar). See the Broadcastable section ofdoc/08-guide-types.md. - Function signatures:
(x: number, number?) -> number, including named, optional, and variadic arguments. - Algebraic types: union (
|), intersection (&), negation (!), plus the special typesany,unknown,nothing,never,error. unknownis an inference marker, not a content type: every type is a subtype of it (anyand the absence markers included), and it is a subtype only ofanyand itself, so it proves no contract. A site that needs evidence that a type admits a marker asksprovablyAdmits()(subtype.ts). As a declaration (list,list<unknown>, baretupleorrecord) it asks the engine to refine from each assignment. Rules:docs/TYPE-SYSTEM.md, section "The subtype hierarchy and the role ofunknown".- Declared types and absence: a declared type is a contract on every
value the symbol takes, so
s: stringrefusesMissing,realrefusesNaN, andstring | missingorreal | nanadmits them. A library parameter typed as data refuses an absent argument. The postfixx?!(AssertPresent) asserts presence at run time. Rules:docs/TYPE-SYSTEM.md, section "Declared types and absence".
Types are written as strings in a small grammar and parsed through
lexer.ts → parser.ts → type-builder.ts into a Type AST; subtype.ts and
reduce.ts implement compatibility checking and simplification; serialize.ts
renders types back to strings. boxed-type.ts provides BoxedType, the
immutable wrapper used throughout the engine and exposed on every
BoxedExpression via .type.
src/common/ also hosts other shared utilities (an interruptible/cancellation
helper, JSON/JSON5, Markdown rendering, grapheme handling, fuzzy string
matching).
The durable details for polymorphism, declarations, overloads, protocols,
objects, provenance, and first-class type values are in
docs/TYPE-SYSTEM.md. Open proposals remain in
docs/TYPE_SYSTEM_ROADMAP.md or an active plan.
src/compute-engine/latex-syntax/ converts between LaTeX and MathJSON,
independently of the engine. The pipeline:
LaTeX string ──tokenizer.ts──► tokens ──parse.ts──► MathJSON
MathJSON ──serializer.ts──► LaTeX string
LatexSyntax (latex-syntax.ts) is the public class, exposing parse() and
serialize(). Parsing is a recursive-descent / precedence-climbing parser
driven by a dictionary of entries (dictionary/) that map LaTeX notation to
MathJSON operators. The dictionary is assembled in default-dictionary.ts from
domain files (definitions-arithmetic.ts, definitions-algebra.ts,
definitions-trigonometry.ts, definitions-sets.ts, …), each contributing
symbol, function, infix, prefix, postfix, matchfix, or environment
entries with precedence. A lenient (non-strict) mode accepts ASCIIMath-style
input such as bare sin(x) and x^(2); see the public
LaTeX syntax guide.
LatexSyntax is an injectable dependency of ComputeEngine, not a static
import. The full package entry wires it in via a factory so ce.parse() works
out of the box; the core entry omits it (LaTeX methods then throw a clear
error). This keeps LaTeX out of bundles that don't need it — see
the package-entry table below for the corresponding wiring.
ComputeEngine (src/compute-engine/index.ts) ties everything together. Its
public API groups into:
- Construction:
parse(),box()/expr(),function(),hold(),tuple(), plus numeric helpersbignum(),complex(),chop(). - Operations:
evaluate(),N(),simplify(),expand()/expandAll(),factor(),solve(). - Declarations & assignment:
declare(),declareType(),declareSequence(),assign(). - Reasoning:
assume(),ask(),verify(),forget(). - Compilation:
compile()on boxed expressions and the standalonecompile()export. The target registry is internal (_registerCompilationTarget(),_getCompilationTarget(),_listCompilationTargets(),_unregisterCompilationTarget()). - Replay:
checkpoint(),restore(), anddiscard()provide linear, cell-boundary state rewind. Seedocs/CHECKPOINT-MODEL.md. - Configuration:
precision(default 21 significant digits;'machine'selects 64-bit float),tolerance(default1e-10),angularUnit(default'rad'),strict(validation depth),iterationLimit(default 1024),recursionLimit(default 256; a full JavaScript stack gives the samerecursion-depth-exceedederror). Time budgets are set withwithTimeLimitspans; the former globaltimeLimitproperty was removed (seedocs/TIMEOUT-MODEL.md§5) — an evaluation with no enclosing span has no time budget.
Internally these responsibilities are delegated to focused services
(engine-startup-coordinator.ts, engine-scope.ts, engine-declarations.ts,
engine-assumptions.ts, engine-numeric-configuration.ts,
engine-runtime-state.ts, engine-cache.ts,
engine-compilation-targets.ts, checkpoint.ts, checkpoint-journal.ts,
engine-extension-contracts.ts, …).
Scopes & assumptions. Symbol and operator definitions live in lexical scopes
with proper inheritance. Assumptions (e.g. "x > 0") are recorded per scope and
consulted during simplification and three-valued (true/false/undefined)
queries via ask() / verify(). The contract is detailed in the next section.
The assumptions subsystem (engine-assumptions.ts, assume.ts,
boxed-expression/constraint-subject.ts) lets callers state facts about
otherwise-free symbols and query them back. Its public surface is assume(),
ask(), verify(), and forget().
Predicate forms. assume() accepts a BoxedExpression, a MathJSON
expression, or a string (parsed as LaTeX — 'x > 0', '$x > 0$', or
'\pi > 0'; verify() accepts the same three forms). The supported predicates
are membership (Element / NotElement), (dis)equality (Equal / NotEqual),
the inequalities (Less / LessEqual / Greater / GreaterEqual), and their
conjunction (And). The predicate is canonicalized, then stored in a normalized
shape: inequalities as Less/LessEqual against 0 (e.g. x + y > 0 →
Less(Add(Negate(x), Negate(y)), 0)), equalities as Equal(lhs − rhs, 0). A
value assumption (assume(x = 5)) additionally installs a value binding for the
symbol. assume() returns 'ok', 'tautology' (already implied), or
'contradiction' (incompatible with existing facts).
Scoping. Assumptions are recorded in the current lexical scope
(ce.context.assumptions). A child scope sees the parent's facts (they are
copied on push) but discards its own additions on popScope(); a
subsequently-restored scope therefore recovers the parent's original facts. Any
mutation reports a state event (ce._noteStateEvent, the sole writer of the
invalidation axes since the 2026-08-11 state-event migration — see
docs/EFFECTS-MODEL.md), advancing
ce._anyVersion and the finer axes, which invalidates the cached rule sets
and the FactIndex (below) so stale sign/bound answers cannot survive a scope
change.
Three-valued discharge. verify(P) returns true when P is provable from
the current assumptions, false when its negation is provable, and undefined
when neither can be decided (Kleene semantics). It first evaluates the predicate
(so x < 0 reduces to False under assume(x > 0)), recurses through
And/Or/Not with Kleene combination, and finally consults the assumptions
DB directly (via ask) for opaque multi-symbol facts such as x·y > 0 that the
evaluator cannot reduce. ask(pattern) matches a pattern containing
wildcards (_k, _val, …) against the stored facts and returns the list of
binding substitutions; for a closed (wildcard-free) predicate it degrades to a
verify-style existence check. Recursion between the two (verify → ask → verify) is broken by an _isVerifying re-entrancy flag: while it is set,
ask skips its closed-predicate verify fallback.
Bounds & the FactIndex. Sign and bound queries are answered from a cached
FactIndex (getFactIndex, keyed on the any invalidation axis
(ce._anyVersion), the assumptions
map identity, and the fact count). It maps each subject — a bare symbol or a
part extractor of one (Re(z), Im(τ), Abs(q), Argument(z)) — to its
numeric lower/upper bounds with strictness flags. assume() uses it for the
tautology/contradiction checks; the sign getters (isPositive, isNonNegative,
…) and verify() both read the same bounds, so they converge: an assumption
that fixes a sign is reflected identically whether queried through expr.sgn /
isPositive or through verify(Greater(x, 0)). A legacy linear scan remains
only as a fallback for facts the index does not capture.
One binding nuance: on a held BoxedSymbol instance, type-backed predicates
(type, isInteger, …) read the definition captured when the symbol was
bound, while sign predicates resolve dynamically by symbol name against the
live assumptions (see the note on sgn in boxed-symbol.ts). Both agree in
the common path — assume() mutates the bound definition in place.
forget() and provenance. forget(symbol) removes every stored fact that
references the symbol and resets any value the symbol received from an
assumption — declare() / assign() values are left intact. To tell the two
apart, assume-installed value bindings are tracked in a per-scope provenance set
(context.assumptionBindings); forget() (no argument) clears all facts and
undoes exactly those bindings, so a symbol assigned only via assume(x = …)
evaluates back to itself, while a user assign() survives.
Free functions. free-functions.ts exports top-level parse, simplify,
evaluate, N, expand, factor, solve, assign, etc., backed by a
lazily-created shared engine (getDefaultEngine()), so simple use needs no
explicit ComputeEngine setup.
Random draws are block-scoped. There is no ambient seed: WithRandomSeed(seed, body) installs a frame — { seedLo, seedHi, next } in a per-engine slot on
engine-runtime-state.ts, swapped by withRandomSeedFrame with a finally
restore, exactly like the deadline frame — and every draw inside it (dynamically,
through user-function calls) is the counter-based hash(seed, n). Outside a
frame, draws are live. Because a draw is a pure function of the seed and the draw
index, the interpreter, the compiled JavaScript target and the GPU targets all
compute the same integer stream; compiled code reaches the same frame through its
engine reference at call time, which is what keeps dynamic scoping from silently
becoming lexical at the compile boundary.
The generator (PCG3D), the normative foldSeed, the tiered parity contract, the
draw-consumption table, and the GPU seed ABI are specified in
docs/RANDOMNESS-MODEL.md. The seed→stream mapping
is a cross-version contract: a change there is a breaking change, not a test
update.
src/compute-engine/library/ is the table of built-in operator and constant
definitions, organized by domain: core, arithmetic, trigonometry,
calculus, polynomials, logic, collections, sets, linear-algebra,
complex, combinatorics, number-theory, statistics, units, and more.
library.ts declares the domains, their requires dependencies, and a
topological sort so dependencies load first. Libraries can be loaded selectively,
and consumers can register their own (validated by the extension contracts).
src/compute-engine/symbolic/ implements rewriting:
simplify-rules.tsis the main rule set, supported by domain-specific simplifiers (simplify-sum.ts,simplify-product.ts,simplify-power.ts,simplify-trig.ts,simplify-log.ts,simplify-logic.ts, …).fu.ts/fu-transforms.ts/fu-cost.tsimplement the Fu et al. algorithm for automated trigonometric simplification, using a cost function to greedily reduce complexity.derivative.tsandantiderivative.tsprovide symbolic calculus;distribute.tshandles distribution.
Rules are pattern/predicate based and must avoid re-entrant .simplify() calls;
the recursion constraints are documented in
CLAUDE.md.
Symbolic capabilities beyond the built-in tables come from two optional,
separately-bundled plugins whose rule corpora live in data/:
- Rubi (
src/compute-engine/rubi/, corpus indata/rubi/, published as…/integration-rules) is a port of the Rubi rule-based integrator: a loader that compiles rule chapters into matchers, a normal-form module, and a driver that applies rules duringIntegrateevaluation. Rules ship in chapter bundles; chapter bundles assume the chapter-1 foundation rules are loaded first — loading, say, the trig chapters without ch1 silently degrades coverage rather than erroring. - Fungrim (
src/identities.ts, corpus indata/fungrim/, published as…/identities) provides curated identities and special values, loadable as simplify/solve rules vialoadIdentities(ce, { topics: […] }). The loader uses only the public engine API, so this bundle shares no engine code with the main entry.
Because these plugins re-bundle engine code, instanceof and
constructor.name checks fail across the host/plugin boundary — use string
checks like e.name === '…' (see
CLAUDE.md).
Numeric values are abstracted so the engine can stay exact when possible and fall back to floating or arbitrary precision when needed.
numeric-value/defines theNumericValueabstraction with three concrete variants:ExactNumericValue—(rational × √radical) + imaginary, keeping integers, rationals, and radicals exact.MachineNumericValue— IEEE-754 64-bit float (with Gaussian-integer imaginary part).BigNumericValue— arbitrary precision viaBigDecimal.
big-decimal/is a custom arbitrary-precision decimal type backed by nativebigint(significand × 10^exponent), with its own transcendental functions. It replaces the formerdecimal.jsdependency insrc/;decimal.jsnow appears only intest/big-decimal/cross-validation and benchmarks.numerics/holds the algorithms: rationals, primes/factorization, special functions (gamma, zeta, Bessel, …), statistics, interval helpers, and numeric integration/extrapolation. Complex numbers use thecomplex.jspackage.numerics/complex-scaling.tsreplaces the methods of itsComplexclass that square the parts of a value (abs,log,inverse, ...), so that they are correct for a very small or very large value.
evaluate() returns the most exact form. .N() (the same as
evaluate({ numericApproximation: true })) promises a result that is a
numeric value correct to the working precision. It does NOT promise that every
intermediate step is a float: a handler may compute exactly and approximate only
its result, and several already do (HurwitzZeta at a negative integer order
and DivisorSum return numericApproximation ? exact.N() : exact).
Canonicalization also folds exact arithmetic before .N() runs, so
((1/3)·3).N() is 1 and ((25! − 1) − 25!).N() is -1.
For most functions the order does not matter: a continuous function of an operand that is correct to the working precision is itself correct to about the working precision, so approximating the operands first is allowed, and it is usually faster.
A function whose result jumps at a point is different. A change of the
operand smaller than the working precision can move the result across the jump,
and the answer is then wrong, not only less precise. With b = 25! − 1, the
value b/24! is just below 25; at 21 digits it rounds to 25, so a Floor
of the approximation is 25, not 24. These functions therefore read an EXACT
operand (an integer, a rational, or a rational multiple of a square root)
exactly, also under .N(), and only then approximate the result:
- the rounding family
Floor,Ceil,Round,Truncate(withroundExactRealinlibrary/arithmetic.ts), the precision formRound(x, n), andFract; - the comparisons
Less,LessEqual,Greater,GreaterEqual,Equal,NotEqual; Sign,Heaviside(jumps at 0) andMod(jumps wherex/mis an integer).
To keep .N() fast, the operand is still approximated first. The handler
evaluates the original operand again, exactly, in two cases only: when it is
already an exact number (which costs nothing), or when the approximation is
within max(tolerance, 10⁻¹⁰)·max(1, |v|) of a point where the function jumps
and the operand is pure (no random draw, no assignment), so a second evaluation
has no side effect (exactRoundingOperand() in library/arithmetic.ts,
exactPairAtNearTie() in library/relational-operator.ts). Evaluating every
operand exactly first, as Mathematica does, was too slow (an exact
Sum(1/k², k, 1, 1000) takes 80 ms, against 5 ms approximated) and changed the
method of some operators (Integrate found the antiderivative instead of
integrating numerically). An approximation whose error is larger than that band
is not detected.
The same rule applies when one of these functions is applied element-wise to a
list or a tuple (Floor(Range(1, 3)/3).N() is [0, 0, 1], although at 21
digits (1/3)·3 is 0.999…): the element cells are built from the exact
elements when an approximated element is near a jump and the collection operand
is pure (JUMP_BROADCAST_OPERATORS and exactJumpBroadcastOperands() in
boxed-expression/boxed-function.ts). A lazy element-wise result (more than
100 elements) cannot check its elements without materializing them, so it
always maps over the exact source when that source is pure and has no float
literal, unless every operand is typed integer: then there is no fractional
part to push across a jump, and the approximated source is kept (rebuilding it
removed the marker that lets a chain of lazy maps fuse its levels,
lowerMapSpine()). The comparisons build their element cells before the approximation,
so they need no special case.
This is how Mathematica's N works (checked with Mathematica 15.0):
N[Floor[(25!-1)/24!]] is 24., while Floor[N[(25!-1)/24!]] is 25, and
N[1/2 - 10^-30 < 1/2] is True.
Two limits of the rule:
- A float operand keeps float precision. A float literal stands for a value
known only to its precision, so a comparison of an exact number with a float
is decided at the float's precision:
1/2 − 10⁻³⁰ < 0.5isFalse, and1/10 = 0.1isTrue. Mathematica gives the same answers. - An operand that is exact but not an exact number (an exact constant
expression:
π·10³⁰,√2 + √3,ln 2, with no free variable, no float and no side effect) is decided by raising the precision, as Mathematica does (isExactConstantExpression(),refineExactConstants()inboxed-expression/compare.ts). The operand is first evaluated exactly (exactFormOfConstant()): a fold such asπ − πmay make it an exact number, which is then decided exactly, so theevaluate()and.N()routes agree onπ + 10⁻¹⁵⁰ − π. Otherwise the engine computes an enclosure[v − E, v + E]with the error model ofapproximate()at the working precision plus 10 guard digits, then at double that, up to the working precision plus 100 digits plus the number of integer digits of the value (sosin(10²⁰⁰), whose argument reduction needs 200 more digits, is still decided), and decides when the enclosure does not contain the jump. A value with more than 500 integer digits (MAX_MAGNITUDE_DIGITS) is not refined: no jump can be separated at that size, and the float is used.Floor(π·10³⁰)is the exact3141592653589793238462643383279underevaluate(), andFract(π)isπ − 3. The enclosure is used at any distance from the jump, because cancellation can make a working-precision value wrong by far more than the tolerance: at 21 digits,√(10⁶⁰ + 10⁴⁰) − 10³⁰is10¹⁰, while its value is4.99999…·10⁹. When the enclosure still contains the jump at the limit and is NARROW (narrower than10^−(working + 50)of the distance between jumps, or of the magnitude of the compared values:NARROW_MARGIN_DIGITS), the value is probably AT the jump ((√2 + √3)² − 2√6is5), andevaluate()leaves a rounding function,SignandHeavisideunevaluated, a comparison answers equal, and.N()takes the value to be at the jump (Floor((√2 + √3)² − 2√6).N()is5), as Mathematica does after its$MaxExtraPrecisionwarning. A consequence: two constants that differ by less than about10^−(working + 50)of their size are taken as equal (Less(kπ, kπ + 10⁻¹⁵⁰)isFalse). Near 0 the test is absolute, and the width of an enclosure follows the size of the terms, not of the value, so a value that no fold simplifies and that is smaller than about10^−(working + 50)times its largest term is taken to be 0 ((√2 + √3)² − 5 − 2√6 + 10⁻¹⁵⁰hasSign0under.N()). An enclosure that is still WIDE at the limit decides nothing, and the caller keeps its previous answer (the tolerance comparison, or the float). An exact order (tolerance 0, used byMax, sorting andAbs) never accepts "equal at the limit". The bound is sound only for operators whose kernelsapproximate()covers; any other operator gives no bound, and the result stays undecided.
The form of the result does not depend on which of the two ways decided it:
under .N(), the rounding family returns a float (Floor(b/24!).N() is the
float 24, as Mathematica's 24.), and Floor(b).N() is b approximated to
the working precision. Under evaluate() the rounding family returns an exact
integer, also for a float operand (Floor(2.7) is the integer 2).
There are two distinct "is this zero?" questions in the engine, and they call for two different tolerances:
- Roundoff — is this tiny value floating-point noise? The rule (user
decision 2026-10-02) depends on where the value comes from:
- An EXACT input is reduced exactly before any float is formed, so its
roundoff never appears. An exact rational multiple of π in
sin,cos,tan,sec,csc,cot, ine^{iθ}and in a hyperbolic function ofiθis reduced in half-turns with bigints (exactHalfTurns,reduceHalfTurns), so a large multiple is exact too: in the interpreter bycircularOfExactAngle(machine precision),applyAngle/exactLargeAngle(above it, and for an angle with a rational term),exactEulerN(boxed-expression/arithmetic-power.ts) andhyperbolicOfImaginaryAngle; in compiled code bycosSinPi()(numerics/numeric-complex.ts) through_SYS.sinpi/cospi/tanpi/cexppi,_gpu_cossinpiand an inline NumPy form for Python, for an angleπ·uand an exponenta + iπuwritten with the symbolPi. Sosin(π).N()is0ande^{iπ}.N()is−1at every precision. Only these functions are covered: another kernel at an exact argument can still leave roundoff in a part whose value is0(erf(i).N()has a real part of2.2·10⁻¹⁶; ROADMAP). - The machine kernels give an exact zero part where the value has one:
the inverse trigonometric and hyperbolic functions use the formulas of
W. Kahan (
complexAsin()and the others),complexPow()uses repeated squaring for an integer exponent up to 64 and an exact angle for a base on an axis or a diagonal, andcomplexSqrt()scales its operand. No machine kernel, in the interpreter or in compiled code, removes a part of its result. - A small part of the value of a FLOAT input is the value at that float
and is kept, on every route:
e^{3.141592653589793i}is−1 + 1.22·10⁻¹⁶iat machine precision (sin(Math.PI)), and2^{10^{-100}i}is1 + 6.93·10^{-101}i. TheChopoperator removes such a part on request, withce.tolerance. - Two places still remove a part, because their kernel cannot avoid the
roundoff: the big-decimal kernels of
BigNumericValueremove a part at most10^(2−precision)times the modulus (isComplexDust,complexNoiseRatio,numeric-value/roundoff.ts): their polar form uses π at the working precision, soi^{3.0}at 21 digits would have a real part of about10^{-21}. And the big-decimalsin/cosremove real noise of the same size (chopBignumDust). An absolute cut erased small results:(10^{-10} i)^2gave 0 instead of-10^{-20}until 2026-09-26.
- An EXACT input is reduced exactly before any float is formed, so its
roundoff never appears. An exact rational multiple of π in
- Comparison tolerance — should two values be considered equal for the
user? That is
ce.tolerance(default1e-10, user-configurable), used byEqual, the relational operators,.is(), and theChopoperator. It applies only when a float is involved: two EXACT numbers (integers, rationals, rational multiples of a square root) are compared exactly, with no tolerance, by the operatorsEqual,NotEqual,Less,LessEqual,Greater,GreaterEqualand by the methods.isEqual(),.isLess(),.isLessEqual(),.isGreater(),.isGreaterEqual()(user decision 2026-09-30). Before,Equalused the tolerance whileLesssometimes did not, so1/2 − 10⁻³⁰and1/2were both "equal" and "less than". Exactly one of<,=,>now holds for two exact numbers, as in Mathematica (1/3 == 3333333333333/10^13isFalsethere). A float stands for a value known only to its precision, so1/10 = 0.1isTrue..is()keeps the tolerance for exact numbers too: it is the loose check for external callers.
The invariant for complex values:
- No part is removed after the fact, except by the big-decimal kernels.
The machine kernels (
boxed-expression/apply.ts, thepow/root/expcomplex branches ofMachineNumericValue, the compiled complex helpers) return their value as computed; an exact input is reduced exactly before it reaches them (see above). The complex branches ofBigNumericValueremove noise as described next. - In
BigNumericValue, a legitimately small component does not reach the chop, except in one case named below. Magnitude alone cannot tell rounding noise from a small correct component:e^{iπ}computes-1 + 1.2e-21iat 21 digits (noise), ande^{i·10^{-800}}is1 + 10^{-800}i(correct), whose imaginary part is smaller still. So the big-decimal kernels have two regimes. In the small-component regime, one part of the input is less than10^(2−precision)times the other (forexpand for the angle of a complex power, the angle is less than10^(2−precision)). There the kernel uses the first-order expansion, which is exact to the working precision and has no cancellation:exp(a + ib) = e^a·(1 + ib),ln(a + ib) = ln a + i·b/a,(a + ib)^n = a^n·(1 + i·n·b/a), and the Cartesiansqrt(which computes the smaller part fromb/(2·larger)). Nothing is chopped in this regime. An integer power in the normal regime uses repeated multiplication, which makes no polar noise, and is not chopped either. A power of a value with a negative real part, an imaginary part smaller than the real part and a real non-integer exponent splits the angle into±π·n, whose unite^{±iπn}alone is chopped, and the small anglen·atan(b/a), which is not. In the normal regime, the kernel uses the polar form at the working precision (with guard digits), rounds each part to the working precision, and chops a part that is at most10^(2−precision)times the modulus of the result. One case still loses a legitimate small part there: a COMPLEX exponent whose real part is a half-integer, at a base near the negative real axis — at 40 digits,(-4 + 10^{-800}i)^{1.5 + 10^{-50}i}gives-8i, where the real part is about1.1·10^{-49}. - Never chop in ring arithmetic or constructors. Results of
add/mul/divcan be legitimately tiny ((10⁻⁶ i)², user input1e-12i);chop()is an absolute-tolerance test and would destroy them. Only transcendental kernels produce dust at a known scale. (See the comment inapply2inboxed-expression/apply.tsfor the real-part version of this principle: a legitimately small real result is not chopped either.) - Read sites compare exactly. Because a kernel gives an exact zero part
where the value has one, and an exact input is reduced exactly, a stored
im ≠ 0is significant by construction. The many library dispatch checks (x.im === 0/x.im !== 0) are therefore correct as written — do not "fix" them toce.chop(x.im). A read-time chop would make a value dispatch as real while still typing, printing, and serializing as complex, and would tie expression identity to the mutablece.tolerance. (ce._numericValue's exactim === 0collapse to a real value is likewise correct by construction.)
The machine kernels remove no part (until 2026-10-02 they removed a part at
most 1e-14 times the modulus, ROUNDOFF_TOLERANCE in numerics/numeric.ts,
which also removed a correct small part: arcoth(10⁻¹⁰⁰) compiled was
−(π/2)i, and in degrees arcsin(10^{-200}i) was 0). The big-decimal
kernels of BigNumericValue use 10^(2−precision), as described above.
Using ce.tolerance for a kernel chop miscoupled the user knob
— tightening it below dust scale re-introduced the 2026-07-30 regression where
the compiled arcsin, projected to the real lane, returned NaN across its
whole domain, and loosening it silently projected genuinely complex results to
real (fixed 2026-07-31; the compile-time realOnly projection that first
surfaced this has since been removed).
Two related cases that are neither kernel dust nor comparison:
- Numericization dust —
.N()substitutes a precision-limited approximation for a symbolic constant, soSin(π)at bignum precision computes sin of π-to-precision-digits ≈ 10^−precision. The bignumSin/Coskernels chop this real dust at the bignum roundoff scale, 10^(2−precision) (chopBignumDustinboxed-expression/trigonometry.ts) — a chop atce.tolerancehere destroyed legitimately-computed small results (sin(3.141592653588793)≈ 1.0e−12 returned 0; the #231 failure class). The machine path does not chop a float argument (Sin(3.141592653589793).N()→1.22e-16, matching Mathematica'sSin[N[π]]); an EXACT rational multiple of π at machine precision is reduced with bigints and computed in doubles (circularOfExactAngle), soSin(π).N()is0at every precision. - Numeric-algorithm residual cleanup —
NullSpace/eigenvector results inlibrary/linear-algebra.tschop components atce.tolerance. Elimination residuals scale with the algorithm and conditioning, not machine eps, and the basis is a user-facing answer, so the user's tolerance is the right knob there.
src/compute-engine/compilation/ turns a boxed expression into source code in a
target language. base-compiler.ts walks the expression tree language-agnostically
and delegates formatting to a LanguageTarget / CompileTarget
(types.ts). Built-in targets:
javascript-target.ts— executable JavaScript (withconstant-folding.ts).glsl-target.ts,wgsl-target.ts,gpu-target.ts— GPU shaders.python-target.ts— Python 3.interval-javascript-target.ts— JavaScript with interval arithmetic.
Built-in targets are registered in the engine's internal compilation-target
registry and validated by the extension contracts. Consumers pass custom target
instances directly to the standalone compile() function.
The package ships several independently importable bundles (the exports map in
package.json). Each is built by scripts/build.mjs (esbuild) as ESM + UMD,
minified, with .d.ts types in dist/types/:
| Import path | Source | Contents |
|---|---|---|
@cortex-js/compute-engine |
src/compute-engine.ts |
Full: engine + LaTeX + compilation + free functions |
…/core |
src/core.ts |
Engine + free functions, no LaTeX/compilation |
…/math-json |
src/math-json.ts |
MathJSON types + utilities only |
…/latex-syntax |
src/latex-syntax.ts |
Standalone LaTeX parser/serializer |
…/numerics |
src/numerics.ts |
Numeric primitives (BigDecimal, rationals, special functions) |
…/interval |
src/interval.ts |
Interval arithmetic |
…/compile |
src/compile.ts |
Compilation targets |
…/identities |
src/identities.ts |
Fungrim identities loader (rules-as-data plugin) |
…/integration-rules |
src/integration-rules.ts |
Rubi rule-based integration plugin |
The full entry (compute-engine.ts) registers
ComputeEngine._latexSyntaxFactory and a default-engine factory that injects
LatexSyntax; the core entry registers a factory without it. This is the
mechanism that lets new ComputeEngine() support LaTeX when imported from the
full package while keeping LaTeX out of core bundles. src/epsil.ts is the
experimental @cortex-js/compute-engine/epsil published entry.
Some diagnostics are turned on by an environment variable that the source
reads from process.env when a module loads: CE_DEBUG_DEPS,
CE_MEMO_PARANOID, CE_TYPE_PURITY_GUARD, CE_CACHE_STATS and others. These
flags are for the debugging of the engine itself, in the repository's own Node
runs (tests, benchmarks, tsx probes). A host cannot use them:
- a browser has no
processobject, so the flag is always off there; - the functions that report what a flag records are module exports of files
such as
src/common/cache-stats.ts, which the package does not export and which minification renames.
A diagnostic that a host needs is an _-prefixed member of the engine,
declared on IComputeEngine in src/compute-engine/types-engine.ts (an
undeclared member is not visible to a host that uses the types). Such a member
survives bundling and minification, needs no subpath export, and works in a
browser. Examples: ce._mapAutoCompileStats (the counters of the
auto-compilation of Map callbacks) and ce._cacheStats (the hit and miss
counts of the memoization caches; set ce._cacheStats.enabled = true to start
recording, then read snapshot() or report()). CE_CACHE_STATS still turns
the cache statistics on at module load in a Node run. Add a new process.env
flag only for the engine's own debugging; when a host asks for a diagnostic,
convert it to an engine member.
- Build:
npm run build(dev →/build),npm run build production(minified →/dist). Driven byscripts/build.sh→scripts/build.mjs(esbuild) plustsc --emitDeclarationOnlyfor types. - Type check:
npm run typecheck(run when completing a task). - Test:
npm run test compute-engine/<name>runstest/compute-engine/<name>.test.ts(Jest viaconfig/jest.config.cjs).npm run test snapshotupdates snapshots. - Lint:
npm run lintruns every check ofnpm run ciexcept the test suite (Oxlint + Prettier, the type check, the corpus and artifact freshness checks).npm run lint:coderuns only Oxlint + Prettier. - Dependency check:
npm run check:deps(madge— zero circular dependencies; Oxlint also enforces the layering rules andimport/no-cycle). - Docs:
npm run doc(TypeDoc +concat-md) regeneratessrc/api.md. It is generated — do not edit it by hand.
These properties are intentional and enforced; preserve them when contributing:
- Zero circular dependencies in
src/compute-engine(runtime and type-only), checked withmadge. See CLAUDE.md. - Layered imports: kernel types → wrappers → services → composition root;
no upward imports. Enforced by the per-layer
no-restricted-importsoverrides in.oxlintrc.json. - LaTeX is optional: the engine depends on the structural
ILatexSyntaxinterface, never the concrete class. - Extension points are validated at runtime: custom libraries, compilation
targets, and
compile()options are shape-checked (extension contracts). - Public type surfaces avoid explicit
any. - Don't call
.simplify()from within simplification rules or functions they invoke (infinite-recursion risk).
README.md— installation, quick start, public API examples.CLAUDE.md— detailed conventions: comparison methods, creation modes, simplification/recursion rules, circular-dependency patterns.docs/STATUS_REPORT.md— internal architecture history and documentation lifecycle.docs/— focused internal specifications and active design work.BUILD.md— build instructions.