Skip to content

Latest commit

 

History

History
1154 lines (1005 loc) · 66.9 KB

ARCHITECTURE.md

File metadata and controls

1154 lines (1005 loc) · 66.9 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 CLAUDE.md 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 CLAUDE.md.

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 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 to Indeterminate — 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 and 0 / 0 likewise canonicalize to Indeterminate. With a float operand (0.0 / 0.0) the canonical form is 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 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:

  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.

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.

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

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 via a selector on the scoped definition flag — 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 doc/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 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.

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.

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().
  • Reasoning: assume(), ask(), verify(), forget().
  • Compilation: compile() on boxed expressions and the standalone compile() export. The target registry is internal (_registerCompilationTarget(), _getCompilationTarget(), _listCompilationTargets(), _unregisterCompilationTarget()).
  • Replay: checkpoint(), restore(), and discard() provide linear, cell-boundary state rewind. See docs/CHECKPOINT-MODEL.md.
  • 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, 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.

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

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 CLAUDE.md.

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

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.

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.
  • 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 + 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.

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

  • Build: npm run build (dev → /build), npm run build production (minified → /dist). Driven by scripts/build.sh → scripts/build.mjs (esbuild) plus tsc --emitDeclarationOnly for types.
  • Type check: npm run typecheck (run when completing a task).
  • Test: npm run test compute-engine/<name> runs test/compute-engine/<name>.test.ts (Jest via config/jest.config.cjs). npm run test snapshot updates snapshots.
  • Lint: npm run lint runs every check of npm run ci except the test suite (Oxlint + Prettier, the type check, the corpus and artifact freshness checks). npm run lint:code runs only Oxlint + Prettier.
  • Dependency check: npm run check:deps (madge — 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 CLAUDE.md.
  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).

Where to go next

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