Skip to content

Latest commit

 

History

History
1521 lines (1338 loc) · 94.2 KB

ARCHITECTURE.md

File metadata and controls

1521 lines (1338 loc) · 94.2 KB

Architecture

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.

What the Compute Engine does

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.

The big picture: three representations and one engine

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)
  1. LaTeX is the human-facing input/output notation.
  2. MathJSON is the JSON-based interchange format — plain data, no behavior. It is the serialization boundary and the format consumers store and exchange.
  3. 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.

Repository layout

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.

Where does X live?

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

Layered module architecture

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.

MathJSON: the interchange format

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.

Boxed expressions: the runtime object model

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.

Class hierarchy

_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 implementation conventions for their distinct semantics).

Boxing and canonical forms

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 implementation conventions.

Default values of parameters at canonicalization

When a call leaves out a parameter that has a default value ((x: number, factor: number = 2) -> number), canonicalization inserts the default operand, so the canonical form carries it: Scaled(3) is ["Scaled", 3, 2], the same expression as Scaled(3, 2), and the handlers, the type handlers and the compiler see two operands. The operand is the default boxed once when the definition was declared (parameterDefaultOperands()). The fill is fillParameterDefaults() (parameter-defaults.ts): when every required parameter has an operand, each defaulted parameter that has no operand receives its default; when a required parameter is empty (Scaled()), nothing is inserted and the call reports missing. A present operand is never changed. makeCanonicalFunction (box.ts) runs it where the positional operand list is final, on each route:

  • an operator without a canonical handler: after named-argument normalization and flatten, before the signature check;
  • an operator with a canonical handler, and a lazy operator: the operands are not flattened, so a top-level Sequence operand counts as each of its elements, and the defaults are appended after the written operands. A written Nothing operand is a value: it fills its parameter, and the default does not replace it;
  • a named call: an optional parameter that the names skip and that has a default receives it (f(3, factor: 5) with (x, density: number = 1, factor: number = 2) is f(3, 1, 5)). A skipped optional parameter without a default is still argument-optional-skipped;
  • a call with a Spread operand whose positions are unknown (f(...p)) is not filled; the call boxed again at evaluation, after the splice, is;
  • a call through a variable declared with a signature that has a default (g: (number, number = 2) -> number) is filled from the declared type. A variable declared with the bare function type is not.

A non-canonical boxing does not fill. A call of a user function (a definition that holds a function literal) or of a variable whose type is inferred keeps its operands as written: when the name holds another function at evaluation, the call is boxed again from them (BoxedFunction._reboxedForCallee()), and the defaults of that function are inserted. After v(3) was filled to v(3, 2), assigning (x) => x to v makes the call evaluate to 3. An operator declared without a function literal and a variable with a declared type are not matched again.

Limit: the fill happens only where a call names its callee. A function value passed to a parameter is not filled when it is called through that parameter: with g declared as above and holding (x, k) => x * k, Map(g, [1]) is [2] (the call goes through the name g), but ((f) => f(3))(g) is a partial application of the literal, and ((f) => Map(f, [1]))(g) is a callback-arity error.

Generic-symbol conventions at canonicalization

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 NaN, 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 form with no value:

  • 0 / (1 − 1) canonicalizes to NaN — the denominator evaluates to 0, giving 0/0 — not to 0: the 0/x → 0 fold does not reach a constant zero.
  • (1 − 1) / 0, 0 / 0 and 0.0 / 0.0 likewise canonicalize to NaN.
  • (π + 1)/(π + 1) is left as a fraction at canonicalization (a constant, so the identical-operand fold is skipped); it reduces to 1 only 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.

Who provides a type (user decision 2026-09-26)

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.

Non-finite typing convention for type handlers

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 numeric type number partitions into three groups of values that share no member: 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:

  1. Claim the signed pair signed_infinity only when the value is provably ±∞. Examples: Ln(0) = −∞; Round/Ceil/Floor/Truncate of a provably real ±∞; ±∞ · (finite reals all provably non-zero); EllipticK(1) = +∞; (+∞)^p for finite real p > 0; ±∞ + (real terms).
  2. 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-sign Ln, the bounded inverse functions at their poles); a result that may be ~oo or NaN — x · ∞ with a possibly-zero complex x, ∞/∞, k/0 — claims number, the only type that admits every numeric value. A claim of bare complex is 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 without nan) cannot be complex, so it claims real | signed_infinity, with a nan arm 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() in library/type-handlers.ts; a function with no value at ±∞, such as Sin, takes a nan arm for it), Max/Min, the reductions Sum/Product/Mean (extendedReductionType()), and the element type of a literal list (widenElementTypes()). x · ∞ with x: real is nan | signed_infinity, and 2y with y: real | signed_infinity is real | signed_infinity. A host reads number and infinity as possibly complex, so the wider claims cost it the real value layout. The rules live in the Multiply and Add type handlers (library/arithmetic.ts, signedInfinitySum() for Add).
  3. Unknown finiteness no longer exists for the bare tiers. An operand declared real (or integer, rational, complex) is finite by its type; Sin(x) with x: real claims real because the input provably is one. Only an operand whose type admits an infinity (number, infinity, the ±oo value 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 · ∞ = ±∞, where x = 0 gives NaN) must prove it (via sgn), 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 type number. The finite tiers are claimed at the rungs where finiteness is provable, not by default.

A type handler receives OperandDescriptors (types-definitions.ts), not expressions. d.type is a plain Type (a string or an AST object), not a BoxedType: d.type.isUnknown is always undefined, so test d.type === 'unknown'. Read the operand's structure with d.structureOf?.() and test its kind before reading head or name. The general join widen() drops unknown and nothing arms, so a handler that wants an unknown operand to widen its result must test for it itself. A predicate's handler may claim the value type true or false when the operand types prove the answer: Greater(r, 0) with r: real<1..> is typed true.

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 types 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.

Two non-finite results are kept apart. A pole is ~oo (5/0, Gamma(0)). A form with no value (0/0, 0·∞, ∞ − ∞, 0^0, Mod(5, 0)) and a floating-point failure (0.0/0.0) are NaN, on the exact route, under .N() and in every compiled target. Indeterminate is an input spelling of NaN only (user decision of 2026-11-02, docs/ERROR-MODEL.md, "One static error, one dynamic non-value per domain").

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.

Definitions: the semantics of symbols and operators

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, and holdUntil evaluation 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.

Evaluation, simplification, and other operations

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 implementation conventions). 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/.

Bound variables, free symbols, and assigned values

(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 with the bound and clause roles of the operands handler of their definition — see docs/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: with x := 5, D(x², x) is 2x and Integrate(x², x) is x³/3, both left in terms of the still-symbolic x. Any other symbol in the operand is free and resolves normally: with a := 3 as well, D(a·x², x) is 6x.
  • 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 is 8/25.
  • HoldValues is 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's Block[{x}, …]. HoldValues(Together(1/x + a/x²)) is (x + a)/x². The granular compositional form also works: Block(Declare(w, 'real'), …) shadow-declares w valueless 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 in HoldValues. That is its documented contract (see docs/SIMPLIFY.md); the Simplify operator 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 withValueShield helper (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.

Type system

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 a list<vector<integer>>, not a list<integer>. A list<U^n>, a vector, a matrix and a tensor (tensor<U^(2x3x4)>, or tensor<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> and tensor<boolean> are valid, list<list<integer>^2> and matrix<list<integer>> are not), and the bare names vector, matrix and tensor mean number cells. A list has one axis at most: list<boolean^(2x2)> is refused, and is spelled matrix<boolean^(2x2)>. The rules are in docs/TYPE-SYSTEM.md, section "Lists, tensors and broadcastable".
  • Broadcastable values: broadcastable<T> — "a T, or an array of any rank whose cells are T, element-wise" (T | tensor<T>, for a T that cannot hold a list). The static type of arithmetic whose operand may broadcast at runtime (an unknown-return call such as 2h(x) - 1). T <: broadcastable<T> and list<T> <: broadcastable<T>, but broadcastable<T> is not a subtype of T, of list<T> or of T | list<T> (a matrix is not a list<number>): a parameter that accepts a number or a numeric array of any rank is spelled number | tensor. Produced by the Add/Multiply type handlers and the generic broadcast typing (broadcastLiftType() in boxed-expression/broadcast-lift-type.ts, called from boxed-function.ts); the trigger is applications-only (a bare symbol's unknown is inference-pending and stays scalar). See the Broadcastable section of cortexjs.io/docs/compute-engine/08-guide-types.md.
  • Function signatures: (x: number, number?) -> number, including named, optional, and variadic arguments.
  • Algebraic types: union (|), intersection (&), negation (!), plus the special types any, unknown, nothing, never, error.
  • unknown is an inference marker, not a content type: every type is a subtype of it (any and the absence markers included), and it is a subtype only of any and itself, so it proves no contract. A site that needs evidence that a type admits a marker asks provablyAdmits() (subtype.ts). As a declaration (list, list<unknown>, bare tuple or record) it asks the engine to refine from each assignment. Rules: docs/TYPE-SYSTEM.md, section "The subtype hierarchy and the role of unknown".
  • Declared types and absence: a declared type is a contract on every value the symbol takes, so s: string refuses Missing, real refuses NaN, and string | missing or real | nan admits them. A library parameter typed as data refuses an absent argument. The postfix x?! (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 Expression 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.

LaTeX syntax

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.

A symbol before a parenthesized group: call or product (user decision 2026-09-18)

A known function — an operator or a value with a function type — is applied as a call. For a head not already recognized as a function, f(x) parses to InvisibleOperator(f, Delimiter(x)), and canonicalization decides whether it is a call or a product. It is a call when the head has no type information: the head has no definition (a free name, docs/SCOPING-MODEL.md), its definition is inferred and of type unknown, or it is a parameter of the function literal being canonicalized that has no type annotation and no type from the caller (user decision 2026-11-05). The decision reads the definition of the head and the uses of the head in the same expression, not a type that another expression requires: after ce.parse('k + 1'), ce.parse('k(2)') is the call ["k", 2]; after ce.infer() declared k a number, it is the product 2k. So s(x+1), f(3)^2 and 2f(x) all contain the call of an undeclared head, and (g \mapsto \int_0^1 g(x)\,dx)(t \mapsto t^2) is 1/3. In this fallback, a value name stays a product factor when:

  • it has a declared non-function type (including value and any) or holds a non-function value;
  • the host declared it with the type unknown (a host can declare a value name before it assigns it: after ce.declare('k', 'unknown'), k(1-w) is a product);
  • the argument refers to the head (x(x+1), q(2q));
  • it is a parameter of the function literal being canonicalized with a type that is not a function type: an annotation ((x\colon \mathrm{real}) \mapsto x(2) is 2x), a type from the caller, or the declared signature of the function that the literal defines (b(x, t) \coloneq t(x+1) after ce.declare('b', '(real, real) -> real')). The declared signature is read by the Assign operator (\coloneq), not by ce.assign(), which receives a literal that is already canonical. An untyped parameter is applied: (x, N) \mapsto x(N+1) calls x;
  • it is the variable of an integral or a limit, which the engine makes the parameter of a function literal built from the integrand (\int_0^1 x(2)\,dx is 1), as the index of a Sum is a bound variable;
  • it is a name that a Block declares as a local;
  • the resolveSymbol handler of the parse answers a non-function type for it.

The serializer writes an explicit multiplication sign between a symbol and a parenthesized group (s\times(x+1)), so that a product parses back as a product in any engine. A library constant such as \pi(x+1) keeps the juxtaposition. The mechanism is in headHasNoTypeInformation() (boxed-expression/invisible-operator.ts) and in serializeMultiply (latex-syntax/dictionary/definitions-arithmetic.ts).

The ComputeEngine and its services

ComputeEngine (src/compute-engine/index.ts) ties everything together. Its public API groups into:

  • Construction: parse(), box()/expr(), function(), hold(), tuple(), plus numeric helpers bignum(), complex(), chop().
  • Operations: evaluate(), N(), simplify(), expand()/expandAll(), factor(), solve().
  • Declarations & assignment: declare(), declareType(), declareSequence(), assign(), infer() (declares the symbols of expressions with the types that their uses require; boxing declares no name).
  • Reasoning: assume(), ask(), verify(), forget().
  • Compilation: compile() on boxed expressions and the standalone compile() export. The target registry is internal (_registerCompilationTarget(), _getCompilationTarget(), _listCompilationTargets(), _unregisterCompilationTarget()).
  • Temporary state: withValues() gives names values for one computation; declarations made in a scope pushed with pushScope() are removed by popScope(). There is no API that rewinds the engine to an earlier state.
  • Configuration: precision (default 21 significant digits; 'machine' selects 64-bit float), tolerance (default 1e-10), angularUnit (default 'rad'), strict (validation depth), iterationLimit (default 1024), recursionLimit (default 256; a full JavaScript stack gives the same recursion-depth-exceeded error). Time budgets are set with withTimeLimit spans; the former global timeLimit property was removed (see docs/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, 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.

Assumptions & reasoning

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 an Expression, 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.

Assumptions and types. An assumption never writes a symbol's type. The facts in force are combined with the symbol's declared or inferred type when the type is read: with q: real and assume(q > 0), q reads real<0<..>, and after forget(q) it reads real again. A write that installs a lasting contract (a value, and through it a definition's type or signature) runs inside ce._withoutFacts(), so it stays true after the next forget(). Design: docs/plans/2026-08-29-assumptions-as-facts-type.md.

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.

Randomness & seeding

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.

Standard library

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).

Simplification and symbolic algebra

src/compute-engine/symbolic/ implements rewriting:

  • simplify-rules.ts is 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.ts implement the Fu et al. algorithm for automated trigonometric simplification, using a cost function to greedily reduce complexity.
  • derivative.ts and antiderivative.ts provide symbolic calculus; distribute.ts handles distribution.

Rules are pattern/predicate based and must avoid re-entrant .simplify() calls; the recursion constraints are documented in implementation conventions.

Integration rules: Rubi and Fungrim

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 in data/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 during Integrate evaluation. 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 in data/fungrim/, published as …/identities) provides curated identities and special values, loadable as simplify/solve rules via loadIdentities(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 implementation conventions).

Numerics

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 the NumericValue abstraction 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 via BigDecimal.
  • big-decimal/ is a custom arbitrary-precision decimal type backed by native bigint (significand × 10^exponent), with its own transcendental functions. It replaces the former decimal.js dependency in src/; decimal.js now appears only in test/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 the complex.js package. numerics/complex-scaling.ts replaces the methods of its Complex class that square the parts of a value (abs, log, inverse, ...), so that they are correct for a very small or very large value.

What .N() promises (user decision 2026-09-30)

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 (with roundExactReal in library/arithmetic.ts), the precision form Round(x, n), and Fract;
  • the comparisons Less, LessEqual, Greater, GreaterEqual, Equal, NotEqual;
  • Sign, Heaviside (jumps at 0) and Mod (jumps where x/m is 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.

An operand at a parameter whose declared type is integer (in every arm of the signature) is evaluated exactly under .N(), once, before the handler reads it (integerParameterPositions() and exactIntegerOperand() in boxed-expression/boxed-function.ts). Without this, the float of a large integer is rounded to the working precision first: DigitSum(2^1000).N() gave 84, where the digit sum is 1366. An operator with a wider parameter type (GCD, LCM, IsPrime, IsEven) is not covered by this rule; its handler reads the written operand itself (exactIntegerOriginals() in library/arithmetic.ts).

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.5 is False, and 1/10 = 0.1 is True. 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() in boxed-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 the evaluate() and .N() routes agree on π + 10⁻¹⁵⁰ − π. Otherwise the engine computes an enclosure [v − E, v + E] with the error model of approximate() 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 (so sin(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 exact 3141592653589793238462643383279 under evaluate(), and Fract(π) 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³⁰ is 10¹⁰, while its value is 4.99999…·10⁹. When the enclosure still contains the jump at the limit and is NARROW (narrower than 10^−(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√6 is 5), and evaluate() leaves a rounding function, Sign and Heaviside unevaluated, a comparison answers equal, and .N() takes the value to be at the jump (Floor((√2 + √3)² − 2√6).N() is 5), as Mathematica does after its $MaxExtraPrecision warning. A consequence: two constants that differ by less than about 10^−(working + 50) of their size are taken as equal (Less(kπ, kπ + 10⁻¹⁵⁰) is False). 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 about 10^−(working + 50) times its largest term is taken to be 0 ((√2 + √3)² − 5 − 2√6 + 10⁻¹⁵⁰ has Sign 0 under .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 by Max, sorting and Abs) never accepts "equal at the limit". The bound is sound only for operators whose kernels approximate() 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).

Chopping and the im === 0 convention

There are two distinct "is this zero?" questions in the engine, and they call for two different tolerances:

  1. 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, in e^{iθ} and in a hyperbolic function of iθ is reduced in half-turns with bigints (exactHalfTurns, reduceHalfTurns), so a large multiple is exact too: in the interpreter by circularOfExactAngle (machine precision), applyAngle/exactLargeAngle (above it, and for an angle with a rational term), exactEulerN (boxed-expression/arithmetic-power.ts) and hyperbolicOfImaginaryAngle; in compiled code by cosSinPi() (numerics/numeric-complex.ts) through _SYS.sinpi/cospi/tanpi/ cexppi, _gpu_cossinpi and an inline NumPy form for Python, for an angle π·u and an exponent a + iπu written with the symbol Pi. So sin(π).N() is 0 and e^{iπ}.N() is −1 at every precision. Only these functions are covered: another kernel at an exact argument can still leave roundoff in a part whose value is 0 (erf(i).N() has a real part of 2.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, and complexSqrt() 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⁻¹⁶i at machine precision (sin(Math.PI)), and 2^{10^{-100}i} is 1 + 6.93·10^{-101}i. The Chop operator removes such a part on request, with ce.tolerance.
    • Two places still remove a part, because their kernel cannot avoid the roundoff: the big-decimal kernels of BigNumericValue remove a part at most 10^(2−precision) times the modulus (isComplexDust, complexNoiseRatio, numeric-value/roundoff.ts): their polar form uses π at the working precision, so i^{3.0} at 21 digits would have a real part of about 10^{-21}. And the big-decimal sin/cos remove real noise of the same size (chopBignumDust). An absolute cut erased small results: (10^{-10} i)^2 gave 0 instead of -10^{-20} until 2026-09-26.
  2. Comparison tolerance — should two values be considered equal for the user? That is ce.tolerance (default 1e-10, user-configurable), used by Equal, the relational operators, .is(), and the Chop operator. 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 operators Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual and by the methods .isEqual(), .isLess(), .isLessEqual(), .isGreater(), .isGreaterEqual() (user decision 2026-09-30). Before, Equal used the tolerance while Less sometimes did not, so 1/2 − 10⁻³⁰ and 1/2 were both "equal" and "less than". Exactly one of <, =, > now holds for two exact numbers, as in Mathematica (1/3 == 3333333333333/10^13 is False there). A float stands for a value known only to its precision, so 1/10 = 0.1 is True. .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, the pow/root/exp complex branches of MachineNumericValue, the compiled complex helpers) return their value as computed; an exact input is reduced exactly before it reaches them (see above). The complex branches of BigNumericValue remove 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-21i at 21 digits (noise), and e^{i·10^{-800}} is 1 + 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 than 10^(2−precision) times the other (for exp and for the angle of a complex power, the angle is less than 10^(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 Cartesian sqrt (which computes the smaller part from b/(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 unit e^{±iπn} alone is chopped, and the small angle n·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 most 10^(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 about 1.1·10^{-49}.
  • Never chop in ring arithmetic or constructors. Results of add/mul/div can be legitimately tiny ((10⁻⁶ i)², user input 1e-12i); chop() is an absolute-tolerance test and would destroy them. Only transcendental kernels produce dust at a known scale. (See the comment in apply2 in boxed-expression/apply.ts for 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 ≠ 0 is significant by construction. The many library dispatch checks (x.im === 0 / x.im !== 0) are therefore correct as written — do not "fix" them to ce.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 mutable ce.tolerance. (ce._numericValue's exact im === 0 collapse 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, so Sin(π) at bignum precision computes sin of π-to-precision-digits ≈ 10^−precision. The bignum Sin/Cos kernels chop this real dust at the bignum roundoff scale, 10^(2−precision) (chopBignumDust in boxed-expression/trigonometry.ts) — a chop at ce.tolerance here 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's Sin[N[π]]); an EXACT rational multiple of π at machine precision is reduced with bigints and computed in doubles (circularOfExactAngle), so Sin(π).N() is 0 at every precision.
  • Numeric-algorithm residual cleanup — NullSpace/eigenvector results in library/linear-algebra.ts chop components at ce.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.

Compilation

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 (with constant-folding.ts).
  • glsl-target.ts, wgsl-target.ts, gpu-target.ts — GPU shaders. gpu-target.ts is the re-export facade of the gpu-*.ts modules: gpu-shader-target.ts (shared target class), gpu-functions.ts (operator handlers), gpu-at.ts, gpu-points.ts, gpu-reduce.ts, gpu-random.ts, gpu-preambles.ts, gpu-shapes.ts, gpu-finite-proof.ts and others.
  • 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.

Packaging and entry points

The package ships several independently importable bundles (the exports map in package.json). Each is built by scripts/build.mjs (esbuild) as ESM only, with normal and minified variants and .d.ts types in dist/types/. There is no UMD or CommonJS build (since 2026-10-26): Node supports loading an ESM entry through require() without a flag since Node 22.12, and exports has no require condition. The project's minimum supported Node version is 26.13.0, as specified in engines.node and BUILD.md. The entries that contain the engine (the full entry, core, compile, epsil, integration-rules, identities) are built in one code-splitting invocation, so the engine is emitted once into a shared chunk and a host that imports two of them loads one engine (duplicate class definitions would break instanceof across the two copies). runtime and kernel-worker are single self-contained files on purpose: compiled code runs on the runtime without the engine, and a host copies the worker script.

Import path Source Contents
@cortex-js/compute-engine src/compute-engine.ts Full: engine + LaTeX + all five compilation targets + free functions
…/core src/core.ts Engine + free functions, no LaTeX; only the javascript and interval-js targets (ce.registerCompilationTarget() adds one from …/compile)
…/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 The compile() function and every target class (GLSLTarget, WGSLTarget, PythonTarget, …)
…/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.

Diagnostic flags and _-prefixed engine members (user decision 2026-10-17)

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 process object, 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, test, and docs tooling

See BUILD.md for setup, commands, build modes and validation requirements. The architectural pieces are:

  • Build: scripts/build.sh orchestrates declaration generation and the esbuild bundler in scripts/build.mjs, writing bundles under dist/.
  • Tests: Jest discovers the root jest.config.cjs, which delegates to config/jest.config.cjs while preserving that configuration's relative paths.
  • Static checks: Oxlint checks code, Prettier checks formatting, TypeScript checks types, and Madge enforces zero circular dependencies. Oxlint also enforces the layering rules and import/no-cycle.
  • Docs: npm run doc (TypeDoc + concat-md) regenerates src/api.md. It is generated — do not edit it by hand.

Architectural invariants

These properties are intentional and enforced; preserve them when contributing:

  1. Zero circular dependencies in src/compute-engine (runtime and type-only), checked with madge. See implementation conventions.
  2. Layered imports: kernel types → wrappers → services → composition root; no upward imports. Enforced by the per-layer no-restricted-imports overrides in .oxlintrc.json.
  3. LaTeX is optional: the engine depends on the structural ILatexSyntax interface, never the concrete class.
  4. Extension points are validated at runtime: custom libraries, compilation targets, and compile() options are shape-checked (extension contracts).
  5. Public type surfaces avoid explicit any.
  6. Don't call .simplify() from within simplification rules or functions they invoke (infinite-recursion risk).
  7. Serialization is a read. toMathJson(), .json, .latex and toLatex() must not declare a symbol, infer a type, or change any scope. The serializer can re-canonicalize a structural copy (the Multiply → Divide prettification does), and canonicalizing a call of an undeclared head writes the function reading of that head: a requirement in the environment of the expression, or a declaration in the scope of the body of a function literal. So serializeJson() (boxed-expression/serialize.ts) runs inside ce._resolveOnly(), and these writes (box.ts, BoxedSymbol._infer()) are skipped while ce._resolveOnlyDepth > 0. Without this, an expression parsed against one scope could declare its heads in the scope that is current when it is serialized.

Implementation conventions

Expression creation modes

The public option is form:

Form Binding and normalization Use
canonical (default) Bound and canonicalized Normal expression construction
structural Bound, preserving structure Arithmetic without canonical rewriting
raw Unbound Patterns, serialization, or intermediate input

A raw expression cannot safely reach arithmetic methods. Use ce.function('Power', [base, exponent], { form: 'structural' }) when binding without canonical rewriting is needed. ce._fn() does not accept form. Selective normalization such as { form: ['Flatten', 'Order'] } is a separate choice. Canonical numeric folding reads literals, never a symbol's assigned value.

Expression comparison methods

There are four ways to check if expressions are equal, each with different semantics:

  • .isSame(v) — Fast exact structural/symbolic check. No evaluation, no tolerance. Accepts primitives (number, bigint, boolean, string) and Expression. Strictly syntactic, everywhere: a symbol's assigned value is never dereferenced, at any depth or in either direction (with x := 5, ce.symbol('x').isSame(5) and ce.box(5).isSame(ce.symbol('x')) are both false, just as a.isSame(b) is false for a := 5, b := 5). An unconditional equivalence relation, so it is safe as a dedup/matching key. Use this in internal engine code where you need a quick check against a known literal. Two deliberate cross-kind cases. The first (user-ruled 2026-08-16): a character and the one-cluster string with identical NFC content are the SAME value — ce.string('a').isSame(ce.character('a')) is true in both directions and their hashes agree — even though the TYPES character and string are disjoint. This is what lets "a" in "abc", Contains, Tally, Unique, Set membership, IndexOf and c == "a" work without per-operator narrowing hooks (design decision D5 in docs/plans/2026-08-16-string-phase1-character-type.md). A test that wants to witness literal NARROWING must therefore assert on the operand's kind (isCharacter(op)) or type, never on isSame. A second cross-kind case (user decision 2026-10-25): a finite Range whose bounds and step are integer literals, with at most 10,000 elements (integerRangeElements(), boxed-expression/type-guards.ts), is the SAME value as the List of its integers: Range(1, 3), Range(1, 3, 1) and [1, 2, 3] are isSame in both directions with equal hashes, so Set(Range(1, 3), [1, 2, 3]) has one element. A range that is an element of a collection stays a range (it is not listed), and this rule keeps set merging and membership as they were when it was listed.

  • .is(v, tolerance?) — Smart check for external API use. Symmetric: a.is(b) always equals b.is(a). Tries .isSame() first; if that fails and the expression has no free variables (including variables with assigned values), evaluates numerically and compares within the given tolerance (defaults to engine.tolerance). For a machine-number literal compared to a primitive, delegates to .isSame() when no explicit tolerance is provided; an exact literal (a rational, a radical, an exact complex) compared to a number/bigint compares within engine.tolerance, as .is(ce.number(v)) does. When an explicit tolerance is given, always compares numerically.

  • .isEqual(v) — Arithmetic equality (the Equal / = tier). Cheap: evaluates the operands, compares structurally, then compares the difference against zero within the engine tolerance when neither side has unknowns. No expand, no simplify, no sampling. Returns undefined when undecided — in particular for an identity in free variables (sin²x + cos²x vs 1), which is .isIdenticallyEqual()'s job, not this one's. Two EXACT numbers (integers, rationals, rational·√r) are compared exactly, with no tolerance — by .isEqual()/.isLess()/… and by the Equal/Less/… operators alike (user-decided 2026-09-30): 1/3 vs 3333333333333/10^13 is not equal. The tolerance applies only when a float is involved (1/10 = 0.1 is True). .is() keeps the tolerance.

  • .isIdenticallyEqual(v) — Full identity in all free variables (the IdenticallyEqual / \equiv tier). Everything .isEqual() does, plus the prover: stochastic sampling at random points, then a symbolic expand+simplify proof. Three-valued — a stochastic disagreement degrades to undefined, and a True from sampling is a probabilistic verdict, confined to this tier by design.

Internal code convention: All internal engine code uses .isSame() for numeric comparisons (e.g., .isSame(0), .isSame(1)). Those checks are on number literals, so the syntactic semantics is what they want — but a fold that could see a SYMBOL must guard with isNumber(op) first (isLiteral() in arithmetic-mul-div.ts), since .isSame(0) on a symbol is now always false. The .is() method is reserved for the external API where the numeric evaluation fallback is desired.

Type and collection implementation

TYPE-SYSTEM.md owns subtype, inference, presence, and collection contracts. In particular:

  • Bare collection names test the collection kind, not its element types. A bare tuple accepts any arity; tuple<any> describes one slot.
  • unknown is an inference marker. Use provablyAdmits() when evidence of missing, nothing, or error is needed; subtyping against unknown does not establish that evidence. joinUnresolvedArm() preserves unresolved arms in joins derived from values; the general join treats unknown as neutral.
  • list<T> describes elements; tensors describe cells. broadcastable<T> includes tensors, not arbitrary nested lists. Use leafwardElementType() for recursive descent toward cells: collectionElementType() can return a tensor that contains the same tensor type again.
  • ListType.dimensions can be absent, an array, or null for unknown rank. Use listTensorRank() rather than assuming every present value is an array. Use cellTypeMayCarryDimensions() before attaching dimensions; cellTypeCanHoldANumber() controls printing, not validity.
  • A use of a symbol with no explicit type (no definition, an inferred one, or the type unknown) requires a type of it. The requirement is kept with the expression (its unit), not written on a definition: boxing declares no name and narrows no definition. ce.infer(exprs) commits the requirements as inferred declarations. Assignment evidence is checked rather than rewritten by use. Bare-constructor declarations refine element types while keeping the declared constructor contract.
  • Declared types enforce presence. Declare string | missing or real | nan when those values are permitted. AssertPresent is spelled x?! in Epsil; separate it from a following operator (x?! + 1).
  • A declared function result is a host promise; the interpreter does not validate the returned value against it. Compilers reject contradictory representations.

Evaluation and rules

  • A cache entry on a function expression keys on the engine generation also when every operand is constant and the node is pure: a redefinition of the operator in place changes the answer (f(2) after f(x) = x + 1 is redefined) while the operands stay constant. A memo with no version key is invalidated by no change (test/compute-engine/constant-node-cache-key.test.ts).
  • A lazy handler must canonicalize the held operands it consumes. Binding with op.canonical does not substitute assigned symbol values. Box and parse input can contain unresolved syntax that direct function construction has resolved.
  • A lazy handler that has evaluated its operands and cannot reduce further must return a new expression built from the evaluated operands. When an evaluate handler returns undefined, the engine rebuilds the expression from the operands it passed to the handler, which for a lazy operator are the held, unevaluated ones, so the handler's work is lost: 0 < D(x^2, x) once stayed unevaluated instead of becoming 0 < 2x. A non-lazy operator does not have this problem, because its operands are evaluated before the handler runs. An operator that is not lazy can keep a statement operand (a body that its handler runs) unevaluated with the operands handler (OperatorDefinitionFlags): only a value operand is evaluated before the handler. To hold one value operand (Fibonacci(10^6) in Mod(Fibonacci(10^6), p)), give it the held role in an operator that is not lazy, rather than making the operator lazy: a held operand is canonicalized, checked against the signature, typed and broadcast as a value is, and a lazy operator skips these checks. If such a handler returns undefined, the engine rebuilds the expression with the held operand unevaluated.
  • isFinite === false means "not a finite number". It is also the answer for a value that is not a number at all (a tuple, a list, a string), so it does not prove that a value is infinite. Check isNumber before reading isFinite === false as "infinite": 1/(3·|(1, 2)|) once folded to 0 because of this.
  • A recursive user function that is called again, inside its own evaluation, with an argument that contains a free symbol is not unrolled: R(3, y) stays R(3, y). The evaluated arguments are kept (R(3, Sin(0), y) becomes R(3, 0, y)). Numeric arguments are not affected and use the application memo. (guardSymbolicRecursion in function-utils.ts.)
  • A host evaluate or evaluateAsync handler must not evaluate an expression that contains the call it evaluates. The engine does not detect this, and the result is not specified. Under evaluateAsync() the outer promise never settles. With node = Reverse(List(1, g())) and a handler of g that awaits node.evaluateAsync(): when g is pure, the nested call joins the walk of the lazy node that is in progress (_lazyValuePending in boxed-function.ts), and that walk waits for the handler; when g is not pure, each nested call starts a new walk, which calls the handler again, without end. The synchronous route gives [5, 1] for this example. This is a documented rule, not a check: a check needs to know that a nested call belongs to the walk that it would wait on, and an asynchronous evaluation does not carry that information across an await.
  • Do not advance an invalidation counter (ce._anyVersion or another version) to make a temporary cache epoch around a computation. A scope pop compares the counters with their values at the push, so any advance inside the scope makes the pop dirty, and a dirty pop advances the counter again; read-only probes then recompute types without end. For the same reason, an event that fires during a cached computation (type inference while canonicalizing) must not advance a counter that the caches of that computation read, or the caches filled during the walk are outdated at once and mutually typed nodes recompute each other.
  • evaluate() preserves exact values and leaves exact transcendentals symbolic when no exact reduction exists. Numeric handlers honor numericApproximation and use isExact to distinguish exact from floating operands. See the numerical evaluation contract.
  • Both evaluate() and .N() keep products of sums factored. The lower-level mul() and .mul() methods distribute; preserve mulFactored() for callers that need factored results. .add() and .mul() on exact literals can produce floats; use ce.function('Add' | 'Multiply', ...) for symbolic construction.
  • Forms without a value produce NaN (ce.NaN) on every route; test it with isNaN. Poles such as 5/0 are infinity, not forms without a value.
  • Rule strings are LaTeX. Patterns are boxed in a clean scope without operator definitions. replace() catches rule exceptions, and simplification can reject a correct rewrite that does not lower the cost.
  • Never recursively call .simplify() from a simplification rule or its helpers. Return canonical results and leave simplification to the caller.

Binding and runtime boundaries

  • Binding operators declare their bound variables with the bound and clause operand roles (the operands handler). Obtain binding symbols through ce._bindingSymbol, so library constants such as Pi, e, and i do not replace lexical symbols. Lift integrands with liftIntegrand() before passing them to integration providers.
  • canonicalFunctionLiteral() (function-utils.ts) turns any expression that is not a string or a symbol into a function: a number becomes a constant function (5 gives () => 5) and an expression with unknowns becomes a shorthand function (x + 1 gives (x) => x + 1). A result that is not undefined therefore does not show that the input was a function literal; test the operator (Function) or the type of the input for that.
  • Small integer literals and common constants are interned: ce.box(7) === ce.box(7). A mechanism or a test that needs two distinct instances of equal values must use function expressions, which are always new instances.
  • BigDecimal.precision is module-global. The construction of an engine and each assignment to ce.precision write it, so every engine in the program computes at the precision of the last write, not at its own: after a = new ComputeEngine(); a.precision = 60; b = new ComputeEngine(); b.precision = 21, a.parse('\\sqrt{2}').N() gives 20 digits. A program uses one engine; the user guide (numerical evaluation, "Numeric Precision") says so. A slow case: a 3000-digit engine created after a 21-digit one makes LogBarnesG(0.7).N() on the 21-digit engine take seconds instead of milliseconds. Save and restore BigDecimal.precision when later tests in the same file reuse an earlier engine. Boolean use also infers a symbol's type for the engine's lifetime.
  • Shared ESM chunks preserve class identity among package entries. Custom or separately bundled plugins may still contain duplicate classes; test package boundaries against built artifacts and avoid assuming constructor names or instanceof work across independent bundles.

Circular dependencies

Runtime and type-only cycles are prohibited. Respect the import layers and extract shared types/utilities to a lower layer instead of introducing a cycle. Inline type imports or lazy registration can break type-only dependencies where appropriate. Dynamic imports hidden behind a variable are not proven acyclic by Madge; do not use the check's limitations as permission to bypass the layering.

Where to go next

  • README.md — installation, quick start, public API examples.
  • CODING-STYLEGUIDE.md — code, comment, and testing conventions.
  • AGENTS.md — agent workflow and reference routing.
  • docs/STATUS_REPORT.md — internal architecture history and documentation lifecycle.
  • docs/ — focused internal specifications and active design work.
  • BUILD.md — build instructions.