This document describes the overall architecture of the Epsil Compute Engine
— a TypeScript library for symbolic manipulation and numeric evaluation of
mathematical expressions, published as
@cortex-js/compute-engine.
It is the high-level map of the codebase, intended for contributors and
maintainers. For the public API and usage guides, see README.md
and cortexjs.io/compute-engine. Internal
architecture history is summarized in
docs/STATUS_REPORT.md.
The Compute Engine parses, manipulates, evaluates, and serializes mathematical expressions. Its capabilities fall into a few categories:
- Parse LaTeX (and ASCIIMath-ish lenient syntax) into a structured representation.
- Canonicalize expressions into a normalized form for consistent comparison and efficient operations.
- Evaluate expressions symbolically or numerically (machine precision or arbitrary precision).
- Simplify, expand, factor, solve, differentiate, and integrate.
- Reason about expressions using assumptions and a type system.
- Compile expressions to executable JavaScript (and GLSL/WGSL/Python).
- Serialize back to LaTeX, MathASCII, or MathJSON.
Everything in the Compute Engine flows between three representations of an
expression, mediated by the ComputeEngine instance:
LaTeX string MathJSON BoxedExpression
"x + \frac{1}{2}" ◄──► ["Add","x",...] ◄──► (boxed, typed, canonical)
│ │ │
latex-syntax/ math-json/ boxed-expression/
(parse/serialize) (interchange format) (the runtime object model)
- LaTeX is the human-facing input/output notation.
- MathJSON is the JSON-based interchange format — plain data, no behavior. It is the serialization boundary and the format consumers store and exchange.
- BoxedExpression is the live, in-memory object model. Boxed expressions carry a type, a definition binding, cached properties, and methods to evaluate, simplify, compare, and serialize themselves. All computation happens on boxed expressions.
The typical lifecycle:
Parse LaTeX ──► MathJSON ──► BoxedExpression (ce.parse)
Canonicalize normalize structure & operands
Evaluate symbolic / numeric result
Simplify apply algebraic rewrite rules
Serialize BoxedExpression ──► MathJSON ──► LaTeX (.json / .latex)
The ComputeEngine (src/compute-engine/index.ts) is the composition root that
owns scopes, symbol/operator definitions, assumptions, numeric configuration,
the standard library, and the LaTeX syntax. It exposes the public API:
parse(), box()/expr(), function(), evaluate(), simplify(),
declare(), assign(), assume(), ask(), and more.
src/
├── compute-engine.ts Full package entry (engine + LaTeX + compilation)
├── core.ts Core engine entry (no LaTeX, no compilation)
├── math-json.ts MathJSON types + utilities entry
├── latex-syntax.ts Standalone LaTeX parser/serializer entry
├── interval.ts Interval-arithmetic entry
├── numerics.ts Numeric primitives entry
├── compile.ts Compilation-targets entry
├── identities.ts Fungrim identities plugin entry (loadIdentities)
├── integration-rules.ts Rubi integration-rules plugin entry
├── epsil.ts Experimental published Epsil language entry
│
├── math-json/ MathJSON format: types + accessors/guards
│ ├── types.ts MathJsonExpression and its object forms
│ └── utils.ts operator(), operands(), type guards, accessors
│
├── big-decimal/ Arbitrary-precision decimals backed by native bigint
│ ├── big-decimal.ts BigDecimal class: arithmetic, comparison, rounding
│ ├── transcendentals.ts sqrt/exp/ln/sin/cos/atan/… (prototype-merged)
│ └── utils.ts fixed-point bigint primitives, π constant, pow10 cache
│
├── common/ Cross-cutting utilities
│ └── type/ The type system (see "Type system" below)
│
└── compute-engine/ The engine itself
├── index.ts ComputeEngine class (composition root, ~1700 lines)
├── compute-engine.ts Full-package wiring (LatexSyntax factory injection)
├── engine-*.ts Runtime services (one bounded concern per file)
├── types-*.ts Type contracts (kernel generics + concrete wrappers)
├── free-functions.ts Top-level parse/simplify/evaluate/N/… via a global engine
│
├── boxed-expression/ The runtime object model (the heart of the engine)
├── latex-syntax/ LaTeX ↔ MathJSON tokenizer, parser, serializer, dictionary
├── library/ Standard library of operators & constants, by domain
├── symbolic/ Simplification rules, calculus, the Fu trig algorithm
├── rubi/ Rubi rule-based integrator (loader, matcher, driver)
├── numerics/ Numeric algorithms (primes, rationals, special functions)
├── numeric-value/ NumericValue abstraction (exact / machine / bignum)
├── compilation/ Expression → JS/GLSL/WGSL/Python code generation
├── interval/ Interval arithmetic
└── tensor/ Vectors, matrices, multi-dimensional arrays
Outside src/: data/ holds the Rubi and Fungrim rule corpora consumed by
the plugin entries (see "Integration rules" below); benchmarks/ is the
cross-library benchmark and audit harness (see
benchmarks/README.md). Completed internal review
campaigns are summarized in docs/STATUS_REPORT.md.
A task-oriented index — where to start for the most common kinds of work (paths
relative to src/compute-engine/ unless noted):
| Task | Start here |
|---|---|
| Parse bug (LaTeX → wrong MathJSON) | latex-syntax/dictionary/definitions-*.ts |
| Serialization bug (expression → wrong LaTeX) | latex-syntax/serializer.ts + the operator's dictionary entry |
| Wrong canonical form | boxed-expression/arithmetic-*.ts, boxed-expression/canonical.ts |
Wrong evaluate()/N() result |
the operator's evaluate handler in library/<domain>.ts, then numerics/ for the kernel |
| Simplification missing or wrong | symbolic/simplify-rules.ts + docs/SIMPLIFY.md |
| Trig simplification | symbolic/fu*.ts, boxed-expression/trigonometry.ts (constructibleValues) |
| Integration (symbolic) | symbolic/antiderivative.ts for built-ins; rubi/ for the rule-based integrator; latex-syntax/dictionary/README.md for the ∫ parse→eval→serialize pipeline |
| Solve | boxed-expression/solve.ts, boxed-expression/solve-linear-system.ts |
| Type-inference bug | library/type-handlers.ts, src/common/type/ |
| Compilation target bug | compilation/<target>.ts; the tree walk is in compilation/base-compiler.ts |
Assumptions / verify() / ask() |
engine-assumptions.ts, assume.ts |
| Step-by-step explanations | boxed-expression/explain.ts, explain-labels.ts |
Within src/compute-engine/, modules are organized into layers with a strict,
acyclic dependency direction (enforced by the per-layer no-restricted-imports
overrides in .oxlintrc.json and a zero-circular-dependency budget checked with
madge):
4. Composition root index.ts (ComputeEngine) — composes services, public API
3. Runtime services engine-*.ts — one bounded concern per file
2. Type wrappers types-*.ts, global-types.ts — bind generics to concrete types
1. Kernel type layer types-kernel-*.ts — generic contracts, no engine imports
The ComputeEngine class is deliberately an "API shell and integration point":
business logic lives in the engine-*.ts services (startup, scoping,
declarations, assumptions, numeric configuration, caching, extension contracts,
compilation-target registry, etc.). The dependency rules below are enforced by
the Oxlint layering overrides and the zero-cycle check.
src/math-json/ defines the format. A MathJsonExpression is plain JSON in one
of these forms:
- A number —
2.5, or{ num: "3.14159..." }for arbitrary precision / special values ("NaN","+Infinity"). - A symbol —
"x","Pi", or{ sym: "Pi" }. - A string —
{ str: "hello" }. - A function —
["Add", "x", 1], or{ fn: [...] }. The first element is the operator (head); the rest are operands. - A dictionary —
{ dict: { ... } }.
Any object form may carry optional metadata: comment, latex, wikidata,
sourceUrl, and similar. src/math-json/utils.ts provides safe accessors
(operator(), operands(), stringValue()) and type guards. MathJSON has no
behavior — it is the inert boundary between the LaTeX layer, the boxed object
model, and the outside world.
src/compute-engine/boxed-expression/ is the heart of the engine. A "boxed"
expression is a class instance that wraps a piece of math with a consistent
interface for evaluation, comparison, and serialization.
_BoxedExpression (abstract-boxed-expression.ts) is the abstract base; the
concrete subclasses are:
| Class | File | Represents |
|---|---|---|
BoxedNumber |
boxed-number.ts |
Numeric literals (int, rational, radical, float, bignum, complex) |
BoxedSymbol |
boxed-symbol.ts |
Symbols/identifiers, bound to a value definition when canonical |
BoxedFunction |
boxed-function.ts |
Function applications [operator, ...operands], bound to an operator definition |
BoxedString |
boxed-string.ts |
String literals (stored NFC-normalized) |
BoxedDictionary |
boxed-dictionary.ts |
Key/value maps |
There is no dedicated tensor class: a vector/matrix/n-dimensional array is a
canonical List BoxedFunction. Tensor-ness is a lazy view over a List's
ops (boxed-expression/tensor-view.ts) — isTensor, .shape, .rank and the
List type handler all agree via one shared shape-claim predicate, and packed
numeric kernels obtain an AbstractTensor per operation through packTensor
(never cached on the node).
The shared public surface (on _BoxedExpression) includes the getters/methods
most consumers use: .json, .latex/.toLatex(), .operator, .ops,
.type, .canonical, .structural, .evaluate(), .simplify(), .N(),
.subs(), .match(), the algebra helpers (.add(), .mul(), .pow(), …),
and the three comparison methods .isSame() / .is() / .isEqual() (see
implementation conventions for their distinct
semantics).
box.ts is the factory: it takes raw input (a number, string, MathJSON, or
existing boxed expression), chooses the concrete class, and — by default —
canonicalizes the result. Canonicalization normalizes structure so that
mathematically equal expressions share a representation: it flattens associative
operators, orders operands, and folds exact numeric operands. The arithmetic
modules implement the per-operator canonical logic:
arithmetic-add.ts— flatten, drop zeros, fold exact numbers, sort terms.arithmetic-mul-div.ts— group by exponent, combine coefficients (Product).arithmetic-power.ts— exponent rules, root/radical normalization.flatten.ts,negate.ts,order.ts— supporting transforms.
canonical.ts orchestrates partial/selective canonicalization by named form
(Number, Multiply, Add, Power, Divide, Flatten, Order).
Three creation modes exist — canonical (default), structural (bound but not canonicalized), and non-canonical (not bound, usable only for pattern matching/serialization). The distinctions and pitfalls are documented in detail in implementation conventions.
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
canonicalhandler: after named-argument normalization andflatten, before the signature check; - an operator with a
canonicalhandler, and a lazy operator: the operands are not flattened, so a top-levelSequenceoperand counts as each of its elements, and the defaults are appended after the written operands. A writtenNothingoperand 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)isf(3, 1, 5)). A skipped optional parameter without a default is stillargument-optional-skipped; - a call with a
Spreadoperand 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 barefunctiontype 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.
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 toNaN— the denominator evaluates to0, giving0/0— not to0: the0/x → 0fold does not reach a constant zero.(1 − 1) / 0,0 / 0and0.0 / 0.0likewise canonicalize toNaN.(π + 1)/(π + 1)is left as a fraction at canonicalization (a constant, so the identical-operand fold is skipped); it reduces to1only under.simplify(), which first proves the denominator non-zero.
Larger cancellations (x²/(5x²) → 1/5) and the wider policy that governs which
.simplify() rewrites treat an unknown as a generic real (why
ln(x³) → 3·ln(x) fires for an unconstrained x but not for a
declared-complex one) are simplify-level, documented in
docs/SIMPLIFY.md.
Whoever constructs a function or a symbol is responsible for giving it an
accurate type. A host such as Tycho declares its symbols and the parameters of
its functions with narrow types (real | signed_infinity | nan, not number),
and a declared result type is a promise the host makes: the interpreter does not
check a function's value against it (decision 2026-09-24). CE constructs the
return values of its operators and of the bodies it types, so CE owes an
accurate, narrow result type for them: an operator's type handler claims the
tightest type its operand types prove (Clamp(x, -1, 1) is real<-1..1>,
tanh(y) is real), and a function literal assigned under a declared signature
whose result is unknown reports the result its body has with the DECLARED
parameter types (resultUnderDeclaredParameters() in engine-declarations.ts).
Where no type information exists, a reasonable assumption is acceptable: a
compiler that meets a number operand assumes a real value. A result type that
is too wide is a defect of the return type, not a reason to change that
assumption.
The numeric lattice is finite-by-default (ratified 2026-08-27; the decision
record is docs/plans/2026-08-26-numeric-lattice-ratification-brief.md). The
top 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:
- Claim the signed pair
signed_infinityonly when the value is provably±∞. Examples:Ln(0) = −∞;Round/Ceil/Floor/Truncateof a provably real ±∞;±∞ · (finite reals all provably non-zero);EllipticK(1) = +∞;(+∞)^pfor finite realp > 0;±∞ + (real terms). - A result that may blow up is claimed as a union with the non-finite branch
spelled out. A pole-capable operator whose argument may land on a pole
claims
complex | +oo | -oo(the unknown-signLn, the bounded inverse functions at their poles); a result that may be~ooor NaN —x · ∞with a possibly-zero complexx,∞/∞,k/0— claimsnumber, the only type that admits every numeric value. A claim of barecomplexis a finiteness promise and must not cover a path that reaches a pole. Exception for extended-real operands (user decision 2026-09-25): a product or sum whose operands are all on the extended real line (real | signed_infinity, with or withoutnan) cannot be complex, so it claimsreal | signed_infinity, with ananarm when one factor may be infinite and a different factor may be zero (0 · ∞), or when two terms may be infinite (∞ − ∞). The same holds for a power with a non-negative base or an integer exponent (extendedPowerType()), an elementary function whose values are real (extendedElementaryFunctionType()inlibrary/type-handlers.ts; a function with no value at±∞, such asSin, takes ananarm for it),Max/Min, the reductionsSum/Product/Mean(extendedReductionType()), and the element type of a literal list (widenElementTypes()).x · ∞withx: realisnan | signed_infinity, and2ywithy: real | signed_infinityisreal | signed_infinity. A host readsnumberandinfinityas possibly complex, so the wider claims cost it the real value layout. The rules live in theMultiplyandAddtype handlers (library/arithmetic.ts,signedInfinitySum()forAdd). - Unknown finiteness no longer exists for the bare tiers. An operand
declared
real(orinteger,rational,complex) is finite by its type;Sin(x)withx: realclaimsrealbecause the input provably is one. Only an operand whose type admits an infinity (number,infinity, the±oovalue types, an extended union) triggers the non-finite analysis — and once an operand is provably non-finite, a claim that depends on another operand being non-zero (e.g.x · ∞ = ±∞, wherex = 0gives NaN) must prove it (viasgn), never assume it. An undeclared symbol is such an operand: it has no finiteness evidence, so a generic numeric application over undeclared operands —x · y— claims the top typenumber. The finite tiers are claimed at the rungs where finiteness is provable, not by default.
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.
Behavior is attached not to the expression but to its definition, resolved during binding against the current scope:
boxed-value-definition.ts—_BoxedValueDefinition: a symbol's type, value, constancy, andholdUntilevaluation semantics.boxed-operator-definition.ts—_BoxedOperatorDefinition: a function's type signature, algebraic flags (associative,commutative,idempotent,pure,lazy,broadcastable, …), and its handlers (canonical,evaluate,evaluateAsync,compile, comparison, collection).
The standard library (below) is just a large table of such definitions.
BoxedFunction.evaluate() looks up the operator definition and dispatches to
its evaluate handler. simplify.ts drives rule-based simplification with
guards against infinite recursion (deduplication, a step limit, and loop
detection — see
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/.
(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 theboundandclauseroles of theoperandshandler of their definition — seedocs/SCOPING-MODEL.md.Minimize/Maximize, once listed here, have no library definition and would be a new feature.) That variable is shielded from any same-named global assignment for the whole operation, including in the result: withx := 5,D(x², x)is2xandIntegrate(x², x)isx³/3, both left in terms of the still-symbolicx. Any other symbol in the operand is free and resolves normally: witha := 3as well,D(a·x², x)is6x. - Non-binder operators —
Together,Expand,Factor,Simplify, arithmetic, everything else — bind nothing, so all their symbols are free and all values apply:Together(1/x + a/x²)with those assignments is8/25. HoldValuesis not an exception but the rule made available à la carte: it is itself a binder, binding the assigned free symbols of its body (all of them, or a listed subset via a second operand) — the analogue of Mathematica'sBlock[{x}, …].HoldValues(Together(1/x + a/x²))is(x + a)/x². The granular compositional form also works:Block(Declare(w, 'real'), …)shadow-declareswvalueless for the body.
Two footnotes:
- The
.simplify()method (engine API, not reachable from the operator surface) is value-blind wholesale — as if the expression were wrapped inHoldValues. That is its documented contract (seedocs/SIMPLIFY.md); theSimplifyoperator evaluates its argument like any operator. - Shielding is implemented by shadow-declaring the bound variable valueless
(keeping its type; assumptions survive) in a temporary scope — use the shared
withValueShieldhelper (boxed-expression/utils.ts), do not invent per-operator renames or new shadow-declare loops.
Implementation status: the convention is enforced across the binder operators
(D, Integrate, Limit, Solve — positional and bundled — and
JacobianMatrix), and HoldValues is implemented. The complete binding and
scope contract is in docs/SCOPING-MODEL.md.
src/common/type/ is a self-contained type system used to describe and check
the types of mathematical expressions. It models:
- Numeric primitives in a subtype lattice:
integer ⊂ rational ⊂ real ⊂ complex ⊂ number, with finite/non-finite variants and bounded ranges (e.g.integer<5..10>). - Collections:
list,set,tuple,record,dictionary, with shapes (e.g.matrix<number^(2x3)>for a matrix). A list type with no dimensions,list<E>, is read by its elements: a matrix of integers is alist<vector<integer>>, not alist<integer>. Alist<U^n>, a vector, a matrix and a tensor (tensor<U^(2x3x4)>, ortensor<U>of any rank) are read by their cells. Every head accepts every cell type that is not a list (list<boolean^2>,matrix<boolean>andtensor<boolean>are valid,list<list<integer>^2>andmatrix<list<integer>>are not), and the bare namesvector,matrixandtensormean number cells. Alisthas one axis at most:list<boolean^(2x2)>is refused, and is spelledmatrix<boolean^(2x2)>. The rules are indocs/TYPE-SYSTEM.md, section "Lists, tensors andbroadcastable". - Broadcastable values:
broadcastable<T>— "aT, or an array of any rank whose cells areT, element-wise" (T | tensor<T>, for aTthat cannot hold a list). The static type of arithmetic whose operand may broadcast at runtime (an unknown-return call such as2h(x) - 1).T <: broadcastable<T>andlist<T> <: broadcastable<T>, butbroadcastable<T>is not a subtype ofT, oflist<T>or ofT | list<T>(a matrix is not alist<number>): a parameter that accepts a number or a numeric array of any rank is spellednumber | tensor. Produced by theAdd/Multiplytype handlers and the generic broadcast typing (broadcastLiftType()inboxed-expression/broadcast-lift-type.ts, called fromboxed-function.ts); the trigger is applications-only (a bare symbol'sunknownis inference-pending and stays scalar). See the Broadcastable section ofcortexjs.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 typesany,unknown,nothing,never,error. unknownis an inference marker, not a content type: every type is a subtype of it (anyand the absence markers included), and it is a subtype only ofanyand itself, so it proves no contract. A site that needs evidence that a type admits a marker asksprovablyAdmits()(subtype.ts). As a declaration (list,list<unknown>, baretupleorrecord) it asks the engine to refine from each assignment. Rules:docs/TYPE-SYSTEM.md, section "The subtype hierarchy and the role ofunknown".- Declared types and absence: a declared type is a contract on every value
the symbol takes, so
s: stringrefusesMissing,realrefusesNaN, andstring | missingorreal | nanadmits them. A library parameter typed as data refuses an absent argument. The postfixx?!(AssertPresent) asserts presence at run time. Rules:docs/TYPE-SYSTEM.md, section "Declared types and absence".
Types are written as strings in a small grammar and parsed through
lexer.ts → parser.ts → type-builder.ts into a Type AST; subtype.ts and
reduce.ts implement compatibility checking and simplification; serialize.ts
renders types back to strings. boxed-type.ts provides BoxedType, the
immutable wrapper used throughout the engine and exposed on every 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.
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 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
valueandany) 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: afterce.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)is2x), a type from the caller, or the declared signature of the function that the literal defines (b(x, t) \coloneq t(x+1)afterce.declare('b', '(real, real) -> real')). The declared signature is read by theAssignoperator (\coloneq), not byce.assign(), which receives a literal that is already canonical. An untyped parameter is applied:(x, N) \mapsto x(N+1)callsx; - 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)\,dxis1), as the index of aSumis a bound variable; - it is a name that a
Blockdeclares as a local; - the
resolveSymbolhandler 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).
ComputeEngine (src/compute-engine/index.ts) ties everything together. Its
public API groups into:
- Construction:
parse(),box()/expr(),function(),hold(),tuple(), plus numeric helpersbignum(),complex(),chop(). - Operations:
evaluate(),N(),simplify(),expand()/expandAll(),factor(),solve(). - Declarations & assignment:
declare(),declareType(),declareSequence(),assign(),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 standalonecompile()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 withpushScope()are removed bypopScope(). There is no API that rewinds the engine to an earlier state. - Configuration:
precision(default 21 significant digits;'machine'selects 64-bit float),tolerance(default1e-10),angularUnit(default'rad'),strict(validation depth),iterationLimit(default 1024),recursionLimit(default 256; a full JavaScript stack gives the samerecursion-depth-exceedederror). Time budgets are set withwithTimeLimitspans; the former globaltimeLimitproperty was removed (seedocs/TIMEOUT-MODEL.md§5) — an evaluation with no enclosing span has no time budget.
Internally these responsibilities are delegated to focused services
(engine-startup-coordinator.ts, engine-scope.ts, engine-declarations.ts,
engine-assumptions.ts, engine-numeric-configuration.ts,
engine-runtime-state.ts, engine-cache.ts, engine-compilation-targets.ts,
engine-extension-contracts.ts, …).
Scopes & assumptions. Symbol and operator definitions live in lexical scopes
with proper inheritance. Assumptions (e.g. "x > 0") are recorded per scope and
consulted during simplification and three-valued (true/false/undefined)
queries via ask() / verify(). The contract is detailed in the next section.
The assumptions subsystem (engine-assumptions.ts, assume.ts,
boxed-expression/constraint-subject.ts) lets callers state facts about
otherwise-free symbols and query them back. Its public surface is assume(),
ask(), verify(), and forget().
Predicate forms. assume() accepts 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.
Random draws are block-scoped. There is no ambient seed:
WithRandomSeed(seed, body) installs a frame — { seedLo, seedHi, next } in a
per-engine slot on engine-runtime-state.ts, swapped by withRandomSeedFrame
with a finally restore, exactly like the deadline frame — and every draw
inside it (dynamically, through user-function calls) is the counter-based
hash(seed, n). Outside a frame, draws are live. Because a draw is a pure
function of the seed and the draw index, the interpreter, the compiled
JavaScript target and the GPU targets all compute the same integer stream;
compiled code reaches the same frame through its engine reference at call time,
which is what keeps dynamic scoping from silently becoming lexical at the
compile boundary.
The generator (PCG3D), the normative foldSeed, the tiered parity contract, the
draw-consumption table, and the GPU seed ABI are specified in
docs/RANDOMNESS-MODEL.md. The seed→stream
mapping is a cross-version contract: a change there is a breaking change, not a
test update.
src/compute-engine/library/ is the table of built-in operator and constant
definitions, organized by domain: core, arithmetic, trigonometry,
calculus, polynomials, logic, collections, sets, linear-algebra,
complex, combinatorics, number-theory, statistics, units, and more.
library.ts declares the domains, their requires dependencies, and a
topological sort so dependencies load first. Libraries can be loaded
selectively, and consumers can register their own (validated by the extension
contracts).
src/compute-engine/symbolic/ implements rewriting:
simplify-rules.tsis the main rule set, supported by domain-specific simplifiers (simplify-sum.ts,simplify-product.ts,simplify-power.ts,simplify-trig.ts,simplify-log.ts,simplify-logic.ts, …).fu.ts/fu-transforms.ts/fu-cost.tsimplement the Fu et al. algorithm for automated trigonometric simplification, using a cost function to greedily reduce complexity.derivative.tsandantiderivative.tsprovide symbolic calculus;distribute.tshandles distribution.
Rules are pattern/predicate based and must avoid re-entrant .simplify() calls;
the recursion constraints are documented in
implementation conventions.
Symbolic capabilities beyond the built-in tables come from two optional,
separately-bundled plugins whose rule corpora live in data/:
- Rubi (
src/compute-engine/rubi/, corpus indata/rubi/, published as…/integration-rules) is a port of the Rubi rule-based integrator: a loader that compiles rule chapters into matchers, a normal-form module, and a driver that applies rules duringIntegrateevaluation. Rules ship in chapter bundles; chapter bundles assume the chapter-1 foundation rules are loaded first — loading, say, the trig chapters without ch1 silently degrades coverage rather than erroring. - Fungrim (
src/identities.ts, corpus indata/fungrim/, published as…/identities) provides curated identities and special values, loadable as simplify/solve rules vialoadIdentities(ce, { topics: […] }). The loader uses only the public engine API, so this bundle shares no engine code with the main entry.
Because these plugins re-bundle engine code, instanceof and constructor.name
checks fail across the host/plugin boundary — use string checks like
e.name === '…' (see
implementation conventions).
Numeric values are abstracted so the engine can stay exact when possible and fall back to floating or arbitrary precision when needed.
numeric-value/defines theNumericValueabstraction with three concrete variants:ExactNumericValue—(rational × √radical) + imaginary, keeping integers, rationals, and radicals exact.MachineNumericValue— IEEE-754 64-bit float (with Gaussian-integer imaginary part).BigNumericValue— arbitrary precision viaBigDecimal.
big-decimal/is a custom arbitrary-precision decimal type backed by nativebigint(significand × 10^exponent), with its own transcendental functions. It replaces the formerdecimal.jsdependency insrc/;decimal.jsnow appears only intest/big-decimal/cross-validation and benchmarks.numerics/holds the algorithms: rationals, primes/factorization, special functions (gamma, zeta, Bessel, …), statistics, interval helpers, and numeric integration/extrapolation. Complex numbers use thecomplex.jspackage.numerics/complex-scaling.tsreplaces the methods of itsComplexclass that square the parts of a value (abs,log,inverse, ...), so that they are correct for a very small or very large value.
evaluate() returns the most exact form. .N() (the same as
evaluate({ numericApproximation: true })) promises a result that is a
numeric value correct to the working precision. It does NOT promise that every
intermediate step is a float: a handler may compute exactly and approximate only
its result, and several already do (HurwitzZeta at a negative integer order
and DivisorSum return numericApproximation ? exact.N() : exact).
Canonicalization also folds exact arithmetic before .N() runs, so
((1/3)·3).N() is 1 and ((25! − 1) − 25!).N() is -1.
For most functions the order does not matter: a continuous function of an operand that is correct to the working precision is itself correct to about the working precision, so approximating the operands first is allowed, and it is usually faster.
A function whose result jumps at a point is different. A change of the
operand smaller than the working precision can move the result across the jump,
and the answer is then wrong, not only less precise. With b = 25! − 1, the
value b/24! is just below 25; at 21 digits it rounds to 25, so a Floor
of the approximation is 25, not 24. These functions therefore read an EXACT
operand (an integer, a rational, or a rational multiple of a square root)
exactly, also under .N(), and only then approximate the result:
- the rounding family
Floor,Ceil,Round,Truncate(withroundExactRealinlibrary/arithmetic.ts), the precision formRound(x, n), andFract; - the comparisons
Less,LessEqual,Greater,GreaterEqual,Equal,NotEqual; Sign,Heaviside(jumps at 0) andMod(jumps wherex/mis an integer).
To keep .N() fast, the operand is still approximated first. The handler
evaluates the original operand again, exactly, in two cases only: when it is
already an exact number (which costs nothing), or when the approximation is
within max(tolerance, 10⁻¹⁰)·max(1, |v|) of a point where the function jumps
and the operand is pure (no random draw, no assignment), so a second evaluation
has no side effect (exactRoundingOperand() in library/arithmetic.ts,
exactPairAtNearTie() in library/relational-operator.ts). Evaluating every
operand exactly first, as Mathematica does, was too slow (an exact
Sum(1/k², k, 1, 1000) takes 80 ms, against 5 ms approximated) and changed the
method of some operators (Integrate found the antiderivative instead of
integrating numerically). An approximation whose error is larger than that band
is not detected.
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.5isFalse, and1/10 = 0.1isTrue. Mathematica gives the same answers. - An operand that is exact but not an exact number (an exact constant
expression:
π·10³⁰,√2 + √3,ln 2, with no free variable, no float and no side effect) is decided by raising the precision, as Mathematica does (isExactConstantExpression(),refineExactConstants()inboxed-expression/compare.ts). The operand is first evaluated exactly (exactFormOfConstant()): a fold such asπ − πmay make it an exact number, which is then decided exactly, so theevaluate()and.N()routes agree onπ + 10⁻¹⁵⁰ − π. Otherwise the engine computes an enclosure[v − E, v + E]with the error model ofapproximate()at the working precision plus 10 guard digits, then at double that, up to the working precision plus 100 digits plus the number of integer digits of the value (sosin(10²⁰⁰), whose argument reduction needs 200 more digits, is still decided), and decides when the enclosure does not contain the jump. A value with more than 500 integer digits (MAX_MAGNITUDE_DIGITS) is not refined: no jump can be separated at that size, and the float is used.Floor(π·10³⁰)is the exact3141592653589793238462643383279underevaluate(), andFract(π)isπ − 3. The enclosure is used at any distance from the jump, because cancellation can make a working-precision value wrong by far more than the tolerance: at 21 digits,√(10⁶⁰ + 10⁴⁰) − 10³⁰is10¹⁰, while its value is4.99999…·10⁹. When the enclosure still contains the jump at the limit and is NARROW (narrower than10^−(working + 50)of the distance between jumps, or of the magnitude of the compared values:NARROW_MARGIN_DIGITS), the value is probably AT the jump ((√2 + √3)² − 2√6is5), andevaluate()leaves a rounding function,SignandHeavisideunevaluated, a comparison answers equal, and.N()takes the value to be at the jump (Floor((√2 + √3)² − 2√6).N()is5), as Mathematica does after its$MaxExtraPrecisionwarning. A consequence: two constants that differ by less than about10^−(working + 50)of their size are taken as equal (Less(kπ, kπ + 10⁻¹⁵⁰)isFalse). Near 0 the test is absolute, and the width of an enclosure follows the size of the terms, not of the value, so a value that no fold simplifies and that is smaller than about10^−(working + 50)times its largest term is taken to be 0 ((√2 + √3)² − 5 − 2√6 + 10⁻¹⁵⁰hasSign0under.N()). An enclosure that is still WIDE at the limit decides nothing, and the caller keeps its previous answer (the tolerance comparison, or the float). An exact order (tolerance 0, used byMax, sorting andAbs) never accepts "equal at the limit". The bound is sound only for operators whose kernelsapproximate()covers; any other operator gives no bound, and the result stays undecided.
The form of the result does not depend on which of the two ways decided it:
under .N(), the rounding family returns a float (Floor(b/24!).N() is the
float 24, as Mathematica's 24.), and Floor(b).N() is b approximated to
the working precision. Under evaluate() the rounding family returns an exact
integer, also for a float operand (Floor(2.7) is the integer 2).
There are two distinct "is this zero?" questions in the engine, and they call for two different tolerances:
- Roundoff — is this tiny value floating-point noise? The rule (user
decision 2026-10-02) depends on where the value comes from:
- An EXACT input is reduced exactly before any float is formed, so its
roundoff never appears. An exact rational multiple of π in
sin,cos,tan,sec,csc,cot, ine^{iθ}and in a hyperbolic function ofiθis reduced in half-turns with bigints (exactHalfTurns,reduceHalfTurns), so a large multiple is exact too: in the interpreter bycircularOfExactAngle(machine precision),applyAngle/exactLargeAngle(above it, and for an angle with a rational term),exactEulerN(boxed-expression/arithmetic-power.ts) andhyperbolicOfImaginaryAngle; in compiled code bycosSinPi()(numerics/numeric-complex.ts) through_SYS.sinpi/cospi/tanpi/cexppi,_gpu_cossinpiand an inline NumPy form for Python, for an angleπ·uand an exponenta + iπuwritten with the symbolPi. Sosin(π).N()is0ande^{iπ}.N()is−1at every precision. Only these functions are covered: another kernel at an exact argument can still leave roundoff in a part whose value is0(erf(i).N()has a real part of2.2·10⁻¹⁶; ROADMAP). - The machine kernels give an exact zero part where the value has one: the
inverse trigonometric and hyperbolic functions use the formulas of W. Kahan
(
complexAsin()and the others),complexPow()uses repeated squaring for an integer exponent up to 64 and an exact angle for a base on an axis or a diagonal, andcomplexSqrt()scales its operand. No machine kernel, in the interpreter or in compiled code, removes a part of its result. - A small part of the value of a FLOAT input is the value at that float and
is kept, on every route:
e^{3.141592653589793i}is−1 + 1.22·10⁻¹⁶iat machine precision (sin(Math.PI)), and2^{10^{-100}i}is1 + 6.93·10^{-101}i. TheChopoperator removes such a part on request, withce.tolerance. - Two places still remove a part, because their kernel cannot avoid the
roundoff: the big-decimal kernels of
BigNumericValueremove a part at most10^(2−precision)times the modulus (isComplexDust,complexNoiseRatio,numeric-value/roundoff.ts): their polar form uses π at the working precision, soi^{3.0}at 21 digits would have a real part of about10^{-21}. And the big-decimalsin/cosremove real noise of the same size (chopBignumDust). An absolute cut erased small results:(10^{-10} i)^2gave 0 instead of-10^{-20}until 2026-09-26.
- An EXACT input is reduced exactly before any float is formed, so its
roundoff never appears. An exact rational multiple of π in
- Comparison tolerance — should two values be considered equal for the
user? That is
ce.tolerance(default1e-10, user-configurable), used byEqual, the relational operators,.is(), and theChopoperator. It applies only when a float is involved: two EXACT numbers (integers, rationals, rational multiples of a square root) are compared exactly, with no tolerance, by the operatorsEqual,NotEqual,Less,LessEqual,Greater,GreaterEqualand by the methods.isEqual(),.isLess(),.isLessEqual(),.isGreater(),.isGreaterEqual()(user decision 2026-09-30). Before,Equalused the tolerance whileLesssometimes did not, so1/2 − 10⁻³⁰and1/2were both "equal" and "less than". Exactly one of<,=,>now holds for two exact numbers, as in Mathematica (1/3 == 3333333333333/10^13isFalsethere). A float stands for a value known only to its precision, so1/10 = 0.1isTrue..is()keeps the tolerance for exact numbers too: it is the loose check for external callers.
The invariant for complex values:
- No part is removed after the fact, except by the big-decimal kernels. The
machine kernels (
boxed-expression/apply.ts, thepow/root/expcomplex branches ofMachineNumericValue, the compiled complex helpers) return their value as computed; an exact input is reduced exactly before it reaches them (see above). The complex branches ofBigNumericValueremove noise as described next. - In
BigNumericValue, a legitimately small component does not reach the chop, except in one case named below. Magnitude alone cannot tell rounding noise from a small correct component:e^{iπ}computes-1 + 1.2e-21iat 21 digits (noise), ande^{i·10^{-800}}is1 + 10^{-800}i(correct), whose imaginary part is smaller still. So the big-decimal kernels have two regimes. In the small-component regime, one part of the input is less than10^(2−precision)times the other (forexpand for the angle of a complex power, the angle is less than10^(2−precision)). There the kernel uses the first-order expansion, which is exact to the working precision and has no cancellation:exp(a + ib) = e^a·(1 + ib),ln(a + ib) = ln a + i·b/a,(a + ib)^n = a^n·(1 + i·n·b/a), and the Cartesiansqrt(which computes the smaller part fromb/(2·larger)). Nothing is chopped in this regime. An integer power in the normal regime uses repeated multiplication, which makes no polar noise, and is not chopped either. A power of a value with a negative real part, an imaginary part smaller than the real part and a real non-integer exponent splits the angle into±π·n, whose unite^{±iπn}alone is chopped, and the small anglen·atan(b/a), which is not. In the normal regime, the kernel uses the polar form at the working precision (with guard digits), rounds each part to the working precision, and chops a part that is at most10^(2−precision)times the modulus of the result. One case still loses a legitimate small part there: a COMPLEX exponent whose real part is a half-integer, at a base near the negative real axis — at 40 digits,(-4 + 10^{-800}i)^{1.5 + 10^{-50}i}gives-8i, where the real part is about1.1·10^{-49}. - Never chop in ring arithmetic or constructors. Results of
add/mul/divcan be legitimately tiny ((10⁻⁶ i)², user input1e-12i);chop()is an absolute-tolerance test and would destroy them. Only transcendental kernels produce dust at a known scale. (See the comment inapply2inboxed-expression/apply.tsfor the real-part version of this principle: a legitimately small real result is not chopped either.) - Read sites compare exactly. Because a kernel gives an exact zero part
where the value has one, and an exact input is reduced exactly, a stored
im ≠ 0is significant by construction. The many library dispatch checks (x.im === 0/x.im !== 0) are therefore correct as written — do not "fix" them toce.chop(x.im). A read-time chop would make a value dispatch as real while still typing, printing, and serializing as complex, and would tie expression identity to the mutablece.tolerance. (ce._numericValue's exactim === 0collapse to a real value is likewise correct by construction.)
The machine kernels remove no part (until 2026-10-02 they removed a part at most
1e-14 times the modulus, ROUNDOFF_TOLERANCE in numerics/numeric.ts, which
also removed a correct small part: arcoth(10⁻¹⁰⁰) compiled was −(π/2)i, and
in degrees arcsin(10^{-200}i) was 0). The big-decimal kernels of
BigNumericValue use 10^(2−precision), as described above. Using
ce.tolerance for a kernel chop miscoupled the user knob — tightening it below
dust scale re-introduced the 2026-07-30 regression where the compiled arcsin,
projected to the real lane, returned NaN across its whole domain, and
loosening it silently projected genuinely complex results to real (fixed
2026-07-31; the compile-time realOnly projection that first surfaced this has
since been removed).
Two related cases that are neither kernel dust nor comparison:
- Numericization dust —
.N()substitutes a precision-limited approximation for a symbolic constant, soSin(π)at bignum precision computes sin of π-to-precision-digits ≈ 10^−precision. The bignumSin/Coskernels chop this real dust at the bignum roundoff scale, 10^(2−precision) (chopBignumDustinboxed-expression/trigonometry.ts) — a chop atce.tolerancehere destroyed legitimately-computed small results (sin(3.141592653588793)≈ 1.0e−12 returned 0; the #231 failure class). The machine path does not chop a float argument (Sin(3.141592653589793).N()→1.22e-16, matching Mathematica'sSin[N[π]]); an EXACT rational multiple of π at machine precision is reduced with bigints and computed in doubles (circularOfExactAngle), soSin(π).N()is0at every precision. - Numeric-algorithm residual cleanup —
NullSpace/eigenvector results inlibrary/linear-algebra.tschop components atce.tolerance. Elimination residuals scale with the algorithm and conditioning, not machine eps, and the basis is a user-facing answer, so the user's tolerance is the right knob there.
src/compute-engine/compilation/ turns a boxed expression into source code in a
target language. base-compiler.ts walks the expression tree
language-agnostically and delegates formatting to a LanguageTarget /
CompileTarget (types.ts). Built-in targets:
javascript-target.ts— executable JavaScript (withconstant-folding.ts).glsl-target.ts,wgsl-target.ts,gpu-target.ts— GPU shaders.gpu-target.tsis the re-export facade of thegpu-*.tsmodules: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.tsand 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.
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.
Some diagnostics are turned on by an environment variable that the source reads
from process.env when a module loads: CE_DEBUG_DEPS, CE_MEMO_PARANOID,
CE_TYPE_PURITY_GUARD, CE_CACHE_STATS and others. These flags are for the
debugging of the engine itself, in the repository's own Node runs (tests,
benchmarks, tsx probes). A host cannot use them:
- a browser has no
processobject, so the flag is always off there; - the functions that report what a flag records are module exports of files such
as
src/common/cache-stats.ts, which the package does not export and which minification renames.
A diagnostic that a host needs is an _-prefixed member of the engine, declared
on IComputeEngine in src/compute-engine/types-engine.ts (an undeclared
member is not visible to a host that uses the types). Such a member survives
bundling and minification, needs no subpath export, and works in a browser.
Examples: ce._mapAutoCompileStats (the counters of the auto-compilation of
Map callbacks) and ce._cacheStats (the hit and miss counts of the
memoization caches; set ce._cacheStats.enabled = true to start recording, then
read snapshot() or report()). CE_CACHE_STATS still turns the cache
statistics on at module load in a Node run. Add a new process.env flag only
for the engine's own debugging; when a host asks for a diagnostic, convert it to
an engine member.
See BUILD.md for setup, commands, build modes and validation requirements. The architectural pieces are:
- Build:
scripts/build.shorchestrates declaration generation and the esbuild bundler inscripts/build.mjs, writing bundles underdist/. - Tests: Jest discovers the root
jest.config.cjs, which delegates toconfig/jest.config.cjswhile 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) regeneratessrc/api.md. It is generated — do not edit it by hand.
These properties are intentional and enforced; preserve them when contributing:
- Zero circular dependencies in
src/compute-engine(runtime and type-only), checked withmadge. See implementation conventions. - Layered imports: kernel types → wrappers → services → composition root;
no upward imports. Enforced by the per-layer
no-restricted-importsoverrides in.oxlintrc.json. - LaTeX is optional: the engine depends on the structural
ILatexSyntaxinterface, never the concrete class. - Extension points are validated at runtime: custom libraries, compilation
targets, and
compile()options are shape-checked (extension contracts). - Public type surfaces avoid explicit
any. - Don't call
.simplify()from within simplification rules or functions they invoke (infinite-recursion risk). - Serialization is a read.
toMathJson(),.json,.latexandtoLatex()must not declare a symbol, infer a type, or change any scope. The serializer can re-canonicalize a structural copy (theMultiply→Divideprettification does), and canonicalizing a call of an undeclared head writes thefunctionreading of that head: a requirement in the environment of the expression, or a declaration in the scope of the body of a function literal. SoserializeJson()(boxed-expression/serialize.ts) runs insidece._resolveOnly(), and these writes (box.ts,BoxedSymbol._infer()) are skipped whilece._resolveOnlyDepth > 0. Without this, an expression parsed against one scope could declare its heads in the scope that is current when it is serialized.
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.
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) andExpression. Strictly syntactic, everywhere: a symbol's assigned value is never dereferenced, at any depth or in either direction (withx := 5,ce.symbol('x').isSame(5)andce.box(5).isSame(ce.symbol('x'))are bothfalse, just asa.isSame(b)isfalsefora := 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): acharacterand the one-clusterstringwith identical NFC content are the SAME value —ce.string('a').isSame(ce.character('a'))istruein both directions and their hashes agree — even though the TYPEScharacterandstringare disjoint. This is what lets"a" in "abc",Contains,Tally,Unique,Setmembership,IndexOfandc == "a"work without per-operator narrowing hooks (design decision D5 indocs/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 onisSame. A second cross-kind case (user decision 2026-10-25): a finiteRangewhose bounds and step are integer literals, with at most 10,000 elements (integerRangeElements(),boxed-expression/type-guards.ts), is the SAME value as theListof its integers:Range(1, 3),Range(1, 3, 1)and[1, 2, 3]areisSamein both directions with equal hashes, soSet(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 equalsb.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 giventolerance(defaults toengine.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 anumber/bigintcompares withinengine.tolerance, as.is(ce.number(v))does. When an explicittoleranceis given, always compares numerically. -
.isEqual(v)— Arithmetic equality (theEqual/=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. Returnsundefinedwhen undecided — in particular for an identity in free variables (sin²x + cos²xvs1), 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 theEqual/Less/… operators alike (user-decided 2026-09-30):1/3vs3333333333333/10^13is not equal. The tolerance applies only when a float is involved (1/10 = 0.1isTrue)..is()keeps the tolerance. -
.isIdenticallyEqual(v)— Full identity in all free variables (theIdenticallyEqual/\equivtier). Everything.isEqual()does, plus the prover: stochastic sampling at random points, then a symbolic expand+simplify proof. Three-valued — a stochastic disagreement degrades toundefined, and aTruefrom 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-SYSTEM.md owns subtype, inference, presence, and collection contracts. In particular:
- Bare collection names test the collection kind, not its element types. A bare
tupleaccepts any arity;tuple<any>describes one slot. unknownis an inference marker. UseprovablyAdmits()when evidence ofmissing,nothing, orerroris needed; subtyping againstunknowndoes not establish that evidence.joinUnresolvedArm()preserves unresolved arms in joins derived from values; the general join treatsunknownas neutral.list<T>describes elements; tensors describe cells.broadcastable<T>includes tensors, not arbitrary nested lists. UseleafwardElementType()for recursive descent toward cells:collectionElementType()can return a tensor that contains the same tensor type again.ListType.dimensionscan be absent, an array, ornullfor unknown rank. UselistTensorRank()rather than assuming every present value is an array. UsecellTypeMayCarryDimensions()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 | missingorreal | nanwhen those values are permitted.AssertPresentis spelledx?!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.
- 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)afterf(x) = x + 1is 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.canonicaldoes 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
evaluatehandler returnsundefined, 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 becoming0 < 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 theoperandshandler (OperatorDefinitionFlags): only avalueoperand is evaluated before the handler. To hold one value operand (Fibonacci(10^6)inMod(Fibonacci(10^6), p)), give it theheldrole in an operator that is not lazy, rather than making the operator lazy: aheldoperand is canonicalized, checked against the signature, typed and broadcast as avalueis, and a lazy operator skips these checks. If such a handler returnsundefined, the engine rebuilds the expression with the held operand unevaluated. isFinite === falsemeans "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. CheckisNumberbefore readingisFinite === falseas "infinite":1/(3·|(1, 2)|)once folded to0because 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)staysR(3, y). The evaluated arguments are kept (R(3, Sin(0), y)becomesR(3, 0, y)). Numeric arguments are not affected and use the application memo. (guardSymbolicRecursioninfunction-utils.ts.) - A host
evaluateorevaluateAsynchandler must not evaluate an expression that contains the call it evaluates. The engine does not detect this, and the result is not specified. UnderevaluateAsync()the outer promise never settles. Withnode = Reverse(List(1, g()))and a handler ofgthat awaitsnode.evaluateAsync(): whengis pure, the nested call joins the walk of the lazy node that is in progress (_lazyValuePendinginboxed-function.ts), and that walk waits for the handler; whengis 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 anawait. - Do not advance an invalidation counter (
ce._anyVersionor 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 honornumericApproximationand useisExactto distinguish exact from floating operands. See the numerical evaluation contract.- Both
evaluate()and.N()keep products of sums factored. The lower-levelmul()and.mul()methods distribute; preservemulFactored()for callers that need factored results..add()and.mul()on exact literals can produce floats; usece.function('Add' | 'Multiply', ...)for symbolic construction. - Forms without a value produce
NaN(ce.NaN) on every route; test it withisNaN. Poles such as5/0are 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 operators declare their bound variables with the
boundandclauseoperand roles (theoperandshandler). Obtain binding symbols throughce._bindingSymbol, so library constants such asPi,e, andido not replace lexical symbols. Lift integrands withliftIntegrand()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 (5gives() => 5) and an expression with unknowns becomes a shorthand function (x + 1gives(x) => x + 1). A result that is notundefinedtherefore 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.precisionis module-global. The construction of an engine and each assignment toce.precisionwrite it, so every engine in the program computes at the precision of the last write, not at its own: aftera = 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 makesLogBarnesG(0.7).N()on the 21-digit engine take seconds instead of milliseconds. Save and restoreBigDecimal.precisionwhen 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
instanceofwork across independent bundles.
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.
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.