Skip to main content

Compiling Expressions

The Compute Engine can compile LaTeX expressions to JavaScript functions!

Introduction

Some expressions can take a long time to evaluate numerically, for example if they contain a large number of terms or involve a loop $(\sum$ or $\prod$).

In this case, it is useful to compile the expression into a JavaScript function that can be evaluated much faster.

For example this approximation: \pi \approx \textstyle\sqrt{6\sum^{10^6}_{n=1}\frac{1}{n^2}}

// import { parse, compile } from '@cortex-js/compute-engine'; const expr = parse("\\sqrt{6\\sum^{10^5}_{n=1}\\frac{1}{n^2}}"); // Numerical evaluation using the Compute Engine console.time('evaluate'); const evaluated = expr.evaluate(); console.timeEnd('evaluate'); console.log(evaluated.toString()); // Compilation to a JavaScript function and execution console.time('compile'); const result = compile(expr); console.timeEnd('compile'); console.log(result.run?.());

Compiling

To get a compiled version of an expression use the compile() function:

import { compile } from '@cortex-js/compute-engine';

const f = compile("2\\prod_{n=1}^{\\infty} \\frac{4n^2}{4n^2-1}");

To evaluate the compiled expression call the run method on the CompilationResult returned by compile():

console.log(f.run());
// ➔ 3.141592653589793

If the expression cannot be compiled, the result.success property will be false.

Validation and Fallback Behavior

Compilation APIs enforce runtime contracts to catch malformed extension payloads early:

  • ce.registerCompilationTarget(name, target) validates target names and required LanguageTarget methods (getOperators(), getFunctions(), createTarget(), compile()).
  • compile(expr, options) validates option payload shape for to, target, operators, functions, vars, imports, preamble, fallback, and constantFold.

By default, compile() falls back to interpretation (success: false with a run function). To disable fallback and fail fast, set fallback: false.

Constant Folding

A pure subexpression with no free variables is evaluated at compile time and emitted as a literal instead of being lowered structurally — on every target:

compile(ce.parse("\\mathrm{Sum}(\\mathrm{Take}(\\mathrm{Map}(\\_ \\mapsto \\_^2, 1..20), 10))")).code
// ➔ "385"

compile(ce.parse("x + \\mathrm{Sum}(\\mathrm{Map}(\\_ \\mapsto \\_^2, 1..5))")).code
// ➔ "_.x + 55"

Numbers, booleans, and constant collections fold. A constant collection becomes a literal list, which matters most when the collection is constant but its consumer is not:

compile(ce.box(['At', ['Map', ['Function', ['Square', 'y'], 'y'],
['Range', 1, 6]], 'k'])).code
// ➔ "_SYS.at([1, 4, 9, 16, 25, 36], _.k)"

The index k is a run-time input, so the expression as a whole cannot fold — but its base is baked once at compile time instead of being rebuilt and mapped over on every call. Collection folding is bounded: it applies to a finite, indexed collection (a Set has no defined element order, so it never folds) of at most 50 numeric elements. A longer collection, or one holding strings, tuples or nested collections, compiles structurally.

Folding declines — and the subtree compiles exactly as before — whenever any of the following holds:

  • the subtree is impure (Random(…) and friends keep drawing at run time);
  • it mentions an unknown, a vars-mapped input (even one with an engine value — vars pins it live), or a name bound by an enclosing lambda or loop;
  • it mentions an operator whose emission you overrode with the functions or operators options (folding would evaluate the engine's definition, not yours);
  • it contains a Sum or Product over a non-finite bound (Σ i, i=1..∞): for a divergent series the interpreter's numeric evaluation silently returns an iteration-limit-truncated partial sum, and folding would bake that wrong number as a constant — such expressions keep their fail-closed structural behavior (a bounded infinite pipeline like Sum(Take(Map(_ ↦ _^2, 1..∞), 10)) still folds);
  • it is estimated to cost too much to evaluate at compile time, or exceeds the engine's collection-size cap. The estimate is deterministic — it reads the expression and nothing else, so the same input always compiles to the same output, and folded code is safe to pin in a test. Constructs that multiply work are priced by their counts: a Sum or Product by its number of iterations, a Map or Filter by the size of its source. An expression whose count cannot be determined statically is not folded — though a bound supplied by a consumer still counts, which is why Sum(Take(Map(f, 1..∞), 10)) folds while Sum(Map(f, 1..∞)) does not;
  • the compilation records a capture set (the symbolDeps option, used by the engine's implicit-compilation cache): folding evaluates through engine state transitively and would under-report the dependencies the cache is keyed on, so such a compilation never folds.

The folded value is the interpreter's (.N()), so a folded constant can differ in the last ulp from what the structural code would have computed in a different operation order — compiled output tracks evaluate() by design (sin(π/6) compiles to 0.5, not Math.sin(Math.PI / 6), which is 0.49999999999999994).

To disable folding — for example to inspect the structural lowering of a constant expression — pass constantFold: false:

compile(expr, { constantFold: false });

The interval-arithmetic target (interval-js) never constant-folds: a folded point value would discard the outward-rounded enclosure that target exists to compute.

Why a compilation declined

When success is false, CompilationResult.error carries the reason (the same text the fallback: false path throws). The message identifies which of three things happened:

Message shapeMeaning
X: cannot compile — … naming an operand or a componentThe head lowers, but not for these operand shapes (e.g. a collection-valued point component)
X: cannot compile — the operator is known … no loweringA target gap: the engine knows X, this target has no codegen for it
Unknown operator `X` No operator definition for X at all — a typo, or a symbol never declared as a function

CompilationResult.unsupported lists the same heads declaratively, so a caller can branch on the condition without parsing the message.

Values That May Be Lists

An operand typed broadcastable<T> — a value that may be a scalar or a list, such as the result of a call whose return type is unknown — compiles differently per target:

  • JavaScript: arithmetic and element-wise math functions compile through a runtime broadcast helper. The same compiled artifact returns a scalar for a scalar binding and the element-wise list for a list binding, matching the interpreter.
  • Shapes that cannot be lowered soundly fail closed (a compilation error; with the default fallback, the interpreter is used): a product of two or more possibly-list operands (a run-time matrix would require the matrix product, not an element-wise one), Equal/NotEqual over a possibly-list operand, and complex-element broadcasts.
  • Python: arithmetic over a possibly-list operand always fails closed — Python's * and + repeat or concatenate a plain list rather than broadcasting, so scalar code would silently compute the wrong value. Function heads that lower to NumPy calls (np.sin, …) are unaffected — NumPy broadcasts natively.
  • GLSL/WGSL: such operands compile as scalar slots, unchanged — shader targets have no dynamic lists.

Out-of-Domain Operands to String Operators

Several string operators take an operand whose domain the interpreter enforces by returning an error value: RangeOf's from (an integer of 1 or more) and its needle (a non-empty sequence), StringReplace's target (a non-empty string) and count (a positive integer), StringRepeat's n (a non-negative integer), and PadStart/PadEnd's n (a non-negative integer) and pad (a non-empty string). Compiled code has no representation for an error value, so each of those operands takes one of three routes.

A valid literal compiles bare. The check happens once, at compile time, and nothing is emitted for it:

compile(ce.box(['PadStart', 's', 5, { str: '0' }])).code
// ➔ '_SYS.spad(_.s, 5, "0", true)'

An invalid literal declines to compile, and so does a computed operand the engine can already prove out of domain (Negate(k) for a k known positive, a symbol assigned ""). The result is success: false with a message naming the operator and the rule; with the default fallback the interpreter runs the expression instead, and returns its error value:

const r = compile(ce.box(['PadStart', 's', -1, { str: '0' }]));
r.success;
// ➔ false
r.error;
// ➔ "PadStart: cannot compile — `n` must be a non-negative integer of at most
// 1000000, and this operand is the literal `-1`, which the interpreter
// answers with an error value. Fail closed (D6)."

This is the first row of the decline table — the head lowers, just not for this operand — so it is reported in error only; CompilationResult.unsupported stays empty, because PadStart itself is not a target gap.

A computed operand compiles and is guarded at run time. The emitted code carries a domain check that throws a RangeError naming the operator and the rule when the value turns out to be out of domain:

ce.declare('s', 'string');
ce.declare('w', 'integer');

const p = compile(ce.box(['PadStart', 's', 'w', { str: '0' }]));
p.code;
// ➔ '_SYS.spad(_.s, _SYS.domi(_.w, 0, 1000000, "PadStart: `n` must be a
// non-negative integer of at most 1000000"), "0", true)'

p.run({ s: '7', w: 5 });
// ➔ "00007"

p.run({ s: '7', w: -1 });
// ➔ throws RangeError: PadStart: `n` must be a non-negative integer of at
// most 1000000

Throwing is the deliberate divergence. The interpreter answers an out-of-domain operand with an error value; a compiled artifact returns a plain JavaScript value and cannot carry one, so it fails loudly rather than returning a wrong answer. Slice in the same target already sets that precedent — a non-literal span argument compiles and the emitted code throws RangeError: Slice: the span argument is not an ascending index range at run time.

The upper bound in those messages is not arbitrary. The interpreter reads these counts with asSmallInteger, which answers null — hence an error value — above 1 000 000, so the compiled guard uses the same ceiling; without it StringRepeat(s, 2000001) would build a multi-megabyte string where the interpreter errors. RangeOf's from is the exception: it is read with toInteger, which has no ceiling, so a large from is not an error at all — just a search that starts past the end and answers Nothing.

Implicit Compilation and the jit Setting

Beyond explicit compile() calls, the engine compiles automatically in a number of implicit spots — for example when draining a large numeric Map, or when an exact integer computation over a large collection is provably safe to run in floating point. This is transparent: results are identical to the interpreter's, and any compilation failure silently falls back to interpretation.

The jit property of a ComputeEngine instance controls this behavior:

  • "auto" (the default): implicit compilation is attempted where beneficial. If the environment forbids code generation altogether (a strict Content-Security-Policy page without 'unsafe-eval', an MV3 browser extension), the engine detects this on the first attempt and latches to "off" engine-wide, so at most one CSP violation is reported.
  • "off": no implicit code generation is ever attempted; every implicit path uses the interpreter. Set it up front on hardened runtimes, or use it as a diagnostic kill switch to compare interpreter and compiled behavior.

Explicit compile() calls are not affected by this setting — a direct request keeps failing loudly with the environment's own error.

Toggling jit is an engine-configuration change: caches whose entries were produced on the other route are invalidated, so jit = "off" genuinely re-runs the interpreter rather than serving previously compiled results.

What Can Be Compiled

Three kinds of expressions can be compiled, and each produces a run function with a different calling convention.

Plain Expressions

A plain expression like x^2 + 1 has free variables (unknowns). The compiled run function takes a vars object mapping variable names to values:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("n^2"); for (let i = 1; i < 10; i++) console.log(f.run({ n: i }));

An expression with no unknowns can be called with no arguments:

// import { compile } from '@cortex-js/compute-engine'; console.log(compile("\\sqrt{6\\sum^{100}_{n=1}\\frac{1}{n^2}}").run()); // ➔ 3.1320...

Lambda Expressions

A lambda expression uses \mapsto (\mapsto) to explicitly declare parameters. The compiled run function takes positional arguments:

// import { compile } from '@cortex-js/compute-engine'; // Single parameter const f = compile("x \\mapsto x^2 + 1"); console.log(f.run(3)); // ➔ 10 // Multiple parameters const g = compile("(x, y) \\mapsto x^2 + y^2"); console.log(g.run(3, 4)); // ➔ 25

Lambdas are useful when the variable name is user-specified rather than assumed by convention. A lambda like \theta \mapsto 1 + \cos(\theta) makes the parameter explicit, avoiding ambiguity.

Tuple Expressions

A tuple expression like (\cos(t), \sin(t)) compiles to a function that returns a JavaScript array:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("(\\cos(t), \\sin(t))"); const [x, y] = f.run({ t: Math.PI / 5 }); console.log(x, y);

This is particularly useful for parametric curves. Both Tuple and List expressions compile to arrays.

Control Structures

Control structures such as conditionals, loops, and blocks can be compiled to JavaScript.

If and Which (Conditionals)

If expressions compile to ternary operators and Which expressions (used by \begin{cases}) compile to chained ternaries:

// import { compile } from '@cortex-js/compute-engine'; // If expression const f = compile("\\keyword{if} x > 0 \\keyword{then} x \\keyword{else} -x"); console.log(f.run({ x: -5 })); // ➔ 5 // Which / cases expression const g = compile("\\begin{cases} x^2 & x > 0 \\\\ -x & x < 0 \\\\ 0 \\end{cases}"); console.log(g.run({ x: 3 })); // ➔ 9

When no condition matches, Which returns NaN.

Sum and Product

Sum and Product with numeric bounds compile to for loops with accumulator variables:

// import { compile } from '@cortex-js/compute-engine'; // Fourier approximation const f = compile("\\sum_{k=0}^{5} \\frac{\\sin((2k+1)x)}{2k+1}"); console.log(f.run({ x: 1.0 }));

Loop, Break, Continue, Return

Loop is imperative control flow, compiled for effect: bare Loop(body) compiles to a JavaScript while (true) { ... } loop, and Loop(body, Element(...), ...) compiles to plain for/for-of loops with no result collection — the compiled expression's value is undefined. Break, Continue, and Return compile to their JavaScript equivalents:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("\\keyword{for} i \\keyword{from} 1 \\keyword{to} 10 \\keyword{do} i^2"); console.log(f.run()); // ➔ undefined (Loop is evaluated for effect)

Comprehension

Comprehension compiles to nested for (const x of ...) loops that push each computed value into a result array, which is returned:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("x^2 \\keyword{for} x = [1...5]"); console.log(f.run()); // ➔ [1, 4, 9, 16, 25]

Comprehension is not compilable to GLSL or WGSL (shaders have no dynamic arrays). Imperative Loop is still compilable to GLSL/WGSL.

Multi-statement constructs on the shader targets

A shader has no expression-level loop or IIFE, so on GLSL and WGSL a Sum or Product with a symbolic bound is emitted as statements (with constant bounds it unrolls into an ordinary expression instead). Such a loop is hoisted ahead of the value that consumes it, so it can appear anywhere in an expression — 0.03\sum_{k=0}^{n}kx compiles, and a nested sum is hoisted into its enclosing loop body rather than out of it.

A loop inside a conditionally-evaluated branch — an If, When, Which or Match arm — fails closed (success: false) instead. A shader conditional is an expression, not a statement, so there is no place to put the loop inside the branch; hoisting it out would run it whichever branch is selected, which changes the result whenever the branch draws from the random stream.

Loop and Block are not hoisted either — they remain valid only as a whole function body, and fail closed when used as a sub-expression.

Block and where

Block expressions create scoped variable bindings. The where syntax provides a convenient way to define local variables:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("r^2 \\text{ where } r \\coloneq x^2 + y^2"); console.log(f.run({ x: 3, y: 4 })); // ➔ 625

Variable Names

The compiled function expects variable names as they appear in the parsed MathJSON expression. Greek letters become their English names:

LaTeXVariable Name
x, y"x", "y"
t"t"
\theta"theta"
\alpha"alpha"
u, v"u", "v"

Use the wrong name and you'll get NaN silently:

const f = compile("1 + \\cos(\\theta)");

f.run({ theta: 0.5 }); // ✓ correct → 1.8776
f.run({ x: 0.5 }); // ✗ wrong key — returns NaN

To discover the variable names in an expression, use expr.unknowns (or its alias expr.freeVariables). These properties return only the free variables — symbols that are not constants, operators, or bound by scoping constructs like Sum or Product:

console.log(parse("n^2").unknowns); console.log(parse("a^2+b^3").unknowns); console.log(parse("\\sin(\\theta)").unknowns);

Complex Numbers

The JavaScript target has full complex arithmetic support. When the compiler detects that a subexpression involves complex values, it automatically emits complex-aware code for all operations touching that subexpression.

When Complex Arithmetic Is Used

The compiler decides at compile time whether each subexpression is complex-valued. This happens when:

  • The expression contains i (ImaginaryUnit)
  • A symbol has been declared with a complex type
  • An operation is applied to a complex-valued operand

The detection is static — based on the expression's structure, not on runtime values. So \sqrt{x} compiles to real Math.sqrt because x has no declared complex type, even though Math.sqrt(-1) returns NaN at runtime.

To get complex results from \sqrt{x}, you'd need to make the expression explicitly complex, e.g., \sqrt{x + 0i}.

Result Format

When an expression is complex-valued, the compiled run function returns a { re: number, im: number } object instead of a plain number:

// import { compile } from '@cortex-js/compute-engine'; // Explicit complex expression — compiler knows it's complex const f = compile("(1 + 2i)^2"); console.log(f.run()); // ➔ { re: -3, im: 4 }

Complex-aware operations include all the standard arithmetic (+, -, *, /), trigonometric functions (sin, cos, tan, and their inverses and hyperbolic variants), exp, ln, sqrt, pow, abs, arg, conjugate, Re, and Im.

Extracting Real and Imaginary Parts

Use Re and Im to extract components from a complex expression:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("\\Re((1 + 2i)^2)"); console.log(f.run()); // ➔ -3 (a plain number, not an object)

Mixing Real and Complex Operands

When a complex operand is combined with a real operand, the result is complex:

// import { compile } from '@cortex-js/compute-engine'; const f = compile("x + 2i"); console.log(f.run({ x: 3 })); // ➔ { re: 3, im: 2 }

GLSL Target

The GLSL target also supports complex arithmetic, but represents complex numbers as vec2 values — the .x component is the real part, .y is the imaginary part:

// Complex literal: 3 + 4i
vec2(3.0, 4.0)

// Imaginary unit
vec2(0.0, 1.0)

Simple operations like addition, subtraction, negation, and scalar multiplication use native vec2 operations. More involved operations — complex multiplication, division, exponentiation, and transcendentals — use helper functions emitted in a preamble block:

OperationGLSL Helper
z * w_gpu_cmul(z, w)
z / w_gpu_cdiv(z, w)
z ^ w_gpu_cpow(z, w)
sqrt(z)_gpu_csqrt(z)
exp(z)_gpu_cexp(z)
ln(z)_gpu_cln(z)

The preamble is dependency-aware: each helper declares its prerequisites (e.g., _gpu_cpow depends on _gpu_cexp, _gpu_cmul, and _gpu_cln), and only the needed functions are emitted in topological order. If the expression is purely real, no preamble is generated.

Component extraction maps to native swizzle operations:

Re(z) → (z).x
Im(z) → (z).y
Abs(z) → length(z) // built-in
Arg(z) → atan(z.y, z.x)
Conjugate(z) → vec2(z.x, -z.y)

The same complex value analysis used by the JavaScript target determines whether each subexpression needs complex or real code paths in GLSL.

Result Convention

A compiled JavaScript unit returns a real value as a plain number and a non-real value as { re, im } — and both directions are guaranteed at the run() boundary: a value whose imaginary part is exactly zero comes back as a number, and a returned { re, im } always has im !== 0. So a consumer's per-sample test is the single typeof v === 'number', and a { re, im } with a non-zero imaginary part tells "outside the real domain" from a genuine NaN. Booleans are never coerced.

// import { compile } from '@cortex-js/compute-engine'; console.log(compile("(1 + 0i) * 5").run()); // 5 (a plain number) console.log(compile("\\sqrt{x}").run({ x: 4 })); // 2 console.log(compile("\\sqrt{x}").run({ x: -1 })); // { re: 0, im: 1 }

The transcendental complex kernels chop their own roundoff dust at the machine scale (as the interpreter does), which is what lets the boundary test be exact: arcsin(0.5) compiled through the complex kernel is the number 0.5235…, while 1 + 10^{-12} i stays { re: 1, im: 1e-12 } — nothing is chopped in ring arithmetic.

Deprecated: realOnly: true (the old projection: { re, im }NaN unless the imaginary part is at roundoff scale, boolean → NaN) is kept for one release with a console warning. The convention above replaces it — the typeof v === 'number' ? v : NaN test on the consumer's side is the whole of what it did.

Modes: auto, strict, complex

JavaScript has no complex number: a compiled value is a number or a { re, im } object, and the compiler decides which statically, per node. What a compile mode fixes is what a numeric binding whose static type is wide (unknown, number, an unannotated parameter, a Block local not declared real) is shaped as — and what happens when a complex-shaped value reaches one:

  • strict — the shader targets' model, on every target: shape follows the static type. A complex-typed value, a Complex(…) literal, i, and a radical of a provably negative operand are complex-shaped; a wide binding is real; nothing promotes (√x at x = −1 is NaN, as Math.sqrt gives); and a complex-shaped value meeting a wide binding — b(z) for b(x) := 2x and z: complexfails closed with a LaneMismatch decline naming the binding to declare complex. This is today's real-kernel codegen byte for byte, plus that decline class.
  • complex — a wide binding is complex, lifted at its use; unknown-sign radicals promote; user functions are emitted once. Always sound, only slower (~2.3× on affected chains).
  • auto (the default on javascript and python) — strict shapes plus promotion: an unknown-sign Sqrt/Ln/Log, and x^{0.3}-style powers (a non-integer number exponent of an unknown-sign base), lower through the complex kernels so the compiled value matches evaluate(); and if — and only if — a promoted or typed complex value reaches a wide binding, the compilation is redone once in complex mode. Nothing else escalates. Radicals whose operand is non-negative under the compiler's own "wide is real" premise — √(x² + y²), √((x−a)² + (y−b)²), √|x|, ln(x²) — keep the real kernel and cost nothing.
// import { ComputeEngine, compile } from '@cortex-js/compute-engine'; const ce = new ComputeEngine(); ce.parse("z(t) \\coloneq \\sqrt{t-1}").evaluate(); // Interpreted — promotes to complex, then |·| brings it back to a real ce.assign("t", 0.3); console.log(ce.parse("|z(t)/2 - 1|").N().toString()); // 1.08397416943394 // Compiled, by default (`auto`) — the same value; the radical was PROMOTED const r = compile(ce.parse("|z(t)/2 - 1|")); console.log(r.run({ t: 0.3 }), r.mode, r.promoted); // 1.08397416943394 "strict" true // `strict` — today's real kernel, NaN console.log(compile(ce.parse("|z(t)/2 - 1|"), { mode: "strict" }).run({ t: 0.3 })); // NaN

Every result reports what was used, so a consumer can tell "this row would not be real on the shader lane" without guessing:

  • result.mode'strict' or 'complex', the discipline the code was compiled under (auto reports the attempt that produced the code);
  • result.promoted — whether a promotable head was lowered through a complex kernel; a compile-time fact decided from the source, so the same source always reports the same flag;
  • result.escalation — under auto, the LaneMismatch diagnostic of the strict attempt when the compilation was redone in complex mode (boundary, a user-legible binding such as "the parameter x of b", and the offending value);
  • result.diagnostic — on any decline, the structured form of error (code, kind: 'capability' | 'correctness', message, and the lane-mismatch payload above).

Ordering comparisons and the real-only heads (Floor, Mod, Max, Erf, …) over a value that may be complex — a complex-typed symbol, a promoted radical — compile under auto and complex with a runtime rule: the operand is bound once, and the comparison is false / the head NaN when the value's imaginary part is not exactly zero (z < 2 is true at z = 1, false at z = i; y > √x compiles and is false where x < 0). A statically non-real operand (i < 2, Floor(2i)) has no compiled value and is a compile-time decline in every mode; strict declines every complex operand of such a head, as before.

The single most effective lever is not a mode: declare the narrowest type you can. A symbol declared real is never a wide binding — 2a + 1, b(a), a < 3 stay on the real kernel in every setting (its unknown-sign radical still promotes under auto, because promotion is about sign, not wideness); a symbol declared complex is complex-shaped in every setting and never a mismatch. mode is per compilation, on CompileTarget (the target default) or on compile()'s options; a mode a target does not offer (complex on glsl) is a capability decline, never a silent coercion. The shader targets and interval-js offer strict only.

Deprecated: complexPromotion: true maps to mode: 'complex' (a console warning, once), and is ignored on a target without complex mode. Since auto already promotes, most callers can simply drop it.

Custom Operators

By default, operators like +, -, *, / compile to their JavaScript equivalents. However, you can override operators to use custom function calls instead. This is particularly useful for:

  • Vector and matrix operations - where [1,2,3] + [4,5,6] should call a custom add() function
  • Custom domain-specific languages - where operations have specialized semantics
  • Type-specific operations - where the same operator behaves differently for different types

Basic Usage

Override operators by passing an operators option to compile():

import { compile } from '@cortex-js/compute-engine';

const result = compile("v + w", {
operators: {
Add: ['add', 11], // Convert + to add() function
Multiply: ['mul', 12] // Convert * to mul() function
},
functions: {
add: (a, b) => a.map((v, i) => v + b[i]),
mul: (a, b) => a.map((v, i) => v * b[i])
}
});

const value = result.run({ v: [1, 2, 3], w: [4, 5, 6] });
console.log(value);
// ➔ [5, 7, 9]

The operator override format is [functionName, precedence]:

  • functionName: The name of the function to call (alphanumeric identifier)
  • precedence: Numeric precedence level (higher = tighter binding)

Function-Based Overrides

You can also use a function to conditionally override operators:

import { compile } from '@cortex-js/compute-engine';

const result = compile("v + w", {
operators: (op) => {
// Only override Add, let other operators use defaults
if (op === 'Add') return ['vectorAdd', 11];
return undefined;
},
functions: {
vectorAdd: (a, b) => a.map((v, i) => v + b[i])
}
});

Complex Expressions

Operator overrides work with complex nested expressions:

import { compile } from '@cortex-js/compute-engine';

const result = compile("(a + b) * c", {
operators: {
Add: ['add', 11],
Multiply: ['mul', 12]
},
functions: {
add: (a, b) => a.map((v, i) => v + b[i]),
mul: (a, b) => a.map((v, i) => v * b[i])
}
});

const value = result.run({
a: [1, 2, 3],
b: [4, 5, 6],
c: [2, 2, 2]
});
console.log(value);
// ➔ [10, 14, 18] // (a + b) * c = ([1,2,3] + [4,5,6]) * [2,2,2]

Important Notes

Canonical Form: Expressions are canonicalized before compilation, which may affect operator usage:

  • Subtraction a - b is canonicalized to Add(a, Negate(b))
  • To handle subtraction with custom operations, override both Add and Negate

Symbol vs Function Names:

  • Function names (alphanumeric like add, mul) compile to function calls: add(a, b)
  • Symbol operators (like +, -, ) compile to infix operators: a + b

Partial Overrides: You can override only some operators; others will use their default JavaScript implementations.

Example: Complete Vector Math

// import { compile } from '@cortex-js/compute-engine'; // Define vector operations function add(a, b) { return a.map((v, i) => v + b[i]); } function mul(a, b) { return a.map((v, i) => v * b[i]); } function neg(a) { return a.map(v => -v); } const f = compile("u * v + w - z", { operators: { Add: ['add', 11], Multiply: ['mul', 12], Negate: ['neg', 14] }, functions: { add, mul, neg } }); console.log(f.run({ u: [2, 3, 4], v: [1, 1, 1], w: [5, 6, 7], z: [1, 2, 3] })); // ➔ [6, 7, 8] // u*v + w - z = [2,3,4] + [5,6,7] - [1,2,3]

Advanced: Custom Compilation Targets

For advanced use cases, you can create completely custom compilation targets by using the exported CompileTarget interface and BaseCompiler class.

Exported Interfaces

import {
ComputeEngine,
BaseCompiler,
JavaScriptTarget,
type CompileTarget,
} from '@cortex-js/compute-engine';

Creating a Custom Target

Define a custom target object that implements the CompileTarget interface:

import { BaseCompiler } from '@cortex-js/compute-engine';

const myTarget = {
language: 'my-dsl',
operators: (op) => {
const ops = {
Add: ['ADD', 11],
Multiply: ['MUL', 12],
Divide: ['DIV', 13],
};
return ops[op];
},
functions: (id) => id.toUpperCase(),
var: (id) => `VAR("${id}")`,
string: (s) => `"${s}"`,
number: (n) => n.toString(),
ws: () => ' ',
preamble: '',
indent: 0,
};

const code = BaseCompiler.compile("x + y * 2", myTarget);
console.log(code);
// → ADD(VAR("x"), MUL(VAR("y"), 2))

Example: SQL-like Target

import { BaseCompiler } from '@cortex-js/compute-engine';

const sqlTarget = {
language: 'sql',
operators: (op) => {
return {
Equal: ['=', 8],
NotEqual: ['<>', 8],
And: ['AND', 4],
Or: ['OR', 3],
}[op];
},
functions: (id) => ({ Abs: 'ABS', Sqrt: 'SQRT' }[id]),
var: (id) => `"${id}"`, // Quote column names
string: (s) => `'${s.replace(/'/g, "''")}'`,
number: (n) => n.toString(),
ws: () => ' ',
preamble: '',
indent: 0,
};

const sql = BaseCompiler.compile("x > 10 \\land y \\leq 20", sqlTarget);
console.log(`SELECT * FROM table WHERE ${sql}`);
// → SELECT * FROM table WHERE AND("x" > 10, "y" <= 20)

For more examples of custom targets, see examples/compile-custom-target.js.

Plugin Architecture: Registering Custom Targets

The Compute Engine includes a plugin architecture that allows you to register custom compilation targets and switch between them easily.

Built-in Targets

The Compute Engine comes with these compilation targets:

  • javascript (default) - Compiles to executable JavaScript functions
  • glsl - Compiles to GLSL (OpenGL Shading Language) for WebGL shaders
  • python - Compiles to Python/NumPy code for scientific computing (requires registration)
  • interval-js - Compiles to JavaScript using interval arithmetic for reliable function plotting
  • interval-glsl - (Deprecated) Compiles to GLSL using interval arithmetic for GPU-based plotting

Compiling to Different Targets

Use the to option to specify the target language:

import { parse, compile } from '@cortex-js/compute-engine';

const expr = parse("x^2 + y^2");

// Compile to JavaScript (default)
const jsResult = compile(expr);
console.log(jsResult.run({ x: 3, y: 4 })); // → 25

// Compile to GLSL
const glslResult = compile(expr, { to: 'glsl' });
console.log(glslResult.code); // → pow(x, 2.0) + pow(y, 2.0)

Python/NumPy Target

The Compute Engine includes a complete Python/NumPy compilation target for scientific computing:

import { ComputeEngine, PythonTarget, compile } from '@cortex-js/compute-engine';

const ce = new ComputeEngine();

// Register the Python target with NumPy support
ce.registerCompilationTarget(
'python', new PythonTarget({ includeImports: true })
);

// Compile expressions to Python/NumPy code
const f = compile("\\sin(x) + \\cos(y)", { to: 'python' });
console.log(f.code);
// → import numpy as np
//
// np.sin(x) + np.cos(y)

Generating Python Functions

The PythonTarget can generate complete Python functions:

import { PythonTarget } from '@cortex-js/compute-engine';

const python = new PythonTarget({ includeImports: true });

const func = python.compileFunction(
"\\sqrt{x^2 + y^2}",
'euclidean_distance',
['x', 'y'],
'Calculate Euclidean distance between two points'
);

console.log(func);

Generates:

import numpy as np

def euclidean_distance(x, y):
"""Calculate Euclidean distance between two points"""
return np.sqrt(x ** 2 + y ** 2)

Use Cases

The Python target is ideal for:

  • Scientific Computing: Generate NumPy code for numerical analysis
  • Machine Learning: Create feature engineering functions
  • Data Analysis: Convert formulas to Pandas/NumPy operations
  • Education: Show Python equivalents of mathematical notation
  • Code Generation: Automated function creation from LaTeX

Lambda Functions

Generate Python lambda expressions:

const lambda = python.compileLambda("x^2 + 2x + 1", ['x']);
console.log(lambda);
// → lambda x: x ** 2 + 2 * x + 1

Supported Functions

The Python target maps to NumPy functions:

  • Trigonometric: sinnp.sin, cosnp.cos, etc.
  • Exponential: expnp.exp, lnnp.log
  • Power: x^nx ** n, sqrtnp.sqrt
  • Statistics: sumnp.sum, meannp.mean
  • Linear Algebra: dotnp.dot, crossnp.cross

For complete documentation, see the Python Target Guide.

Interval Arithmetic Targets

The Compute Engine includes interval arithmetic compilation targets designed for reliable function plotting. These targets operate on intervals [lo, hi] rather than point values, providing guaranteed enclosures of the true result and detecting singularities.

Why Interval Arithmetic?

Standard plotting approaches sample functions at regular intervals, which can:

  • Miss features (spikes between sample points)
  • Create aliasing (high-frequency oscillations appear as lower frequencies)
  • Produce wild line segments at singularities (like tan(π/2))
  • Render discontinuities as vertical lines

Interval arithmetic addresses these by:

  • Returning wide intervals when uncertainty is high (triggers refinement)
  • Explicitly detecting division by zero and other singularities
  • Indicating when function domains are restricted

JavaScript Interval Target (interval-js)

import { compile } from '@cortex-js/compute-engine';

const f = compile("\\sin(x) / x", { to: 'interval-js' });

// Call with interval inputs
const value = f.run({ x: { lo: -0.1, hi: 0.1 } });
console.log(value);
// → { kind: 'singular' } // Division by interval containing zero

The function accepts an object where keys are variable names and values are Interval objects with lo and hi properties.

Result Types

The compiled function returns an IntervalResult discriminated union:

KindMeaningExample
intervalNormal result with boundssin([0, π]){ kind: 'interval', value: { lo: 0, hi: 1 } }
emptyNo valid output valuessqrt([-2, -1]){ kind: 'empty' }
entireResult spans all realsDivision with mixed signs near zero
singularContains a pole/asymptote1 / [-1, 1]{ kind: 'singular' }
partialPartially valid domainsqrt([-1, 4]){ kind: 'partial', value: { lo: 0, hi: 2 }, domainClipped: 'lo' }

Examples

import { compile } from '@cortex-js/compute-engine';

// Simple function - normal result
const sinResult = compile('\\sin(x)', { to: 'interval-js' });
sinResult.run({ x: { lo: 0, hi: Math.PI } });
// → { kind: 'interval', value: { lo: 0, hi: 1 } }

// Singularity detection
const recipResult = compile('1/x', { to: 'interval-js' });
recipResult.run({ x: { lo: -1, hi: 1 } });
// → { kind: 'singular' }

// Partial domain
const sqrtResult = compile('\\sqrt{x}', { to: 'interval-js' });
sqrtResult.run({ x: { lo: -1, hi: 4 } });
// → { kind: 'partial', value: { lo: 0, hi: 2 }, domainClipped: 'lo' }

// Multi-variable expressions
const fnResult = compile('x^2 + y', { to: 'interval-js' });
fnResult.run({ x: { lo: 1, hi: 2 }, y: { lo: 0, hi: 0.5 } });
// → { kind: 'interval', value: { lo: 1, hi: 4.5 } }

GLSL Interval Target (interval-glsl)

Deprecated

The interval-glsl target (and the never-rebuilt interval-wgsl sibling) is deprecated and will be removed in a future release. GPU interval evaluation only pays off when the entire pipeline stays on the GPU; a compile → GPU framebuffer → readPixels → CPU round-trip is net-negative versus CPU interval-js, and the target cannot compile any relational operator (so it cannot host restriction/masking conditions). Use interval-js (CPU interval arithmetic) or the scalar glsl/wgsl targets instead.

For GPU-based plotting, compile to GLSL interval arithmetic:

import { IntervalGLSLTarget } from '@cortex-js/compute-engine';

const target = new IntervalGLSLTarget();

// Generate complete shader code
const shader = target.compileShaderFunction("\\sin(x) + y^2", {
functionName: 'evaluateInterval',
parameters: ['x', 'y'],
version: '300 es'
});

console.log(shader);
// Outputs complete GLSL shader with interval arithmetic library

In GLSL, intervals are represented as vec2 where .x is the lower bound and .y is the upper bound. The generated shader includes status flags for singularity detection.

Plotting Integration

The interval results enable adaptive plotting algorithms:

function shouldSubdivide(result, tolerance) {
switch (result.kind) {
case 'singular':
case 'entire':
return true; // Always refine near singularities
case 'interval':
case 'partial':
return (result.value.hi - result.value.lo) > tolerance;
case 'empty':
return false; // Nothing to plot
}
}

Registering Custom Targets

You can also create your own compilation targets using ce.registerCompilationTarget():

import { ComputeEngine, BaseCompiler, compile } from '@cortex-js/compute-engine';

const ce = new ComputeEngine();

// Define a custom target (e.g., for R, MATLAB, etc.)
class CustomTarget {
getOperators() {
return {
Add: ['+', 11],
Multiply: ['*', 12],
// ... other operators
};
}

getFunctions() {
return {
Sin: 'sin',
Cos: 'cos',
// ... other functions
};
}

createTarget() {
return {
language: 'custom',
operators: (op) => this.getOperators()[op],
functions: (id) => this.getFunctions()[id],
var: (id) => id,
string: (str) => JSON.stringify(str),
number: (n) => n.toString(),
indent: 0,
ws: () => ' ',
preamble: '',
};
}

compile(expr, options = {}) {
const target = this.createTarget();
const code = BaseCompiler.compile(expr, target);

return {
target: 'custom',
success: true,
code,
};
}
}

// Register and use
ce.registerCompilationTarget('custom', new CustomTarget());
const result = compile("x + y * 2", { to: 'custom' });
console.log(result.code);

Direct Target Override

For one-time use, you can provide a CompileTarget directly without registration:

import { compile } from '@cortex-js/compute-engine';

const customTarget = {
language: 'custom',
operators: () => ['+', 10],
functions: () => undefined,
var: (id) => id.toUpperCase(),
string: (str) => `"${str}"`,
number: (n) => n.toString(),
indent: 0,
ws: () => ' ',
preamble: '',
};

const result = compile("a + b", { target: customTarget });
console.log(result.code); // → A + B

The target option takes precedence over the to option if both are provided.

Creating Custom Language Targets

To create a custom language target, implement the LanguageTarget interface:

interface LanguageTarget {
/**
* Returns operator mappings for this language.
* Maps operator names to [operator_string, precedence] tuples.
*/
getOperators(): CompiledOperators;

/**
* Returns function mappings for this language.
* Maps function names to language-specific function names or implementations.
*/
getFunctions(): CompiledFunctions;

/**
* Creates a CompileTarget with the specified options.
*/
createTarget(options?: Partial<CompileTarget>): CompileTarget;

/**
* Compiles an expression to a CompilationResult.
* For non-JavaScript targets, this typically returns a result
* with the source code in the `code` property.
*/
compile(
expr: Expression,
options?: CompilationOptions
): CompilationResult;
}

For complete examples of custom targets including RPN (Reverse Polish Notation), Python, and more, see examples/compile-plugin-architecture.js.

Performance Benchmarks

JavaScript vs Python/NumPy

To compare performance across compilation targets, the project includes benchmark scripts:

JavaScript Benchmarks (Node.js):

npm run test compute-engine/compile-performance

Python Benchmarks (generated):

# Generate the Python benchmark script
npm run test compute-engine/compile-python-generate

# Run Python benchmarks (requires NumPy)
python benchmarks/python-performance.py

Expected Results

The benchmarks test the same mathematical expressions across different targets:

TargetPerformanceUse Case
JavaScript Compiled40-2900x faster than evalCPU computation, real-time calculations
Python/NumPyFast for arrays, some overhead for scalarsScientific computing, data analysis
GLSLMassively parallel on GPUGraphics, WebGL shaders

Key Insights:

  • JavaScript compilation is excellent for single-threaded CPU performance
  • Python/NumPy excels at vectorized array operations
  • GLSL enables GPU parallelism for millions of simultaneous operations

Limitations

Precision: Compiled functions use machine-precision (64-bit) floating-point arithmetic only. Arbitrary-precision and symbolic calculations are not available.

Complex numbers: The JavaScript target supports complex arithmetic when the expression involves complex-valued operands (e.g., i, complex-typed symbols). Complex results are returned as { re, im } objects. See Complex Numbers for details and Result Convention: a real value is a plain number, so typeof v === 'number' ? v : NaN projects to the reals.

Unsupported functions: Most standard mathematical functions are supported, but some cannot be compiled. When compilation fails, compile() returns a CompilationResult with success set to false.

By default, a fallback run function based on the CE's numerical evaluator is provided so the expression still produces a result (at lower performance). Set { fallback: false } to throw instead.

// import { compile, parse } from '@cortex-js/compute-engine'; function compileOrEvaluate(latex) { const result = compile(latex); if (result.success) { return result.run() + " (compiled)"; } else { const evaluated = parse(latex).N(); return evaluated.numericValue + " (evaluated)"; } } // `compile()` can handle this expression console.log(compileOrEvaluate("\\frac{\\sqrt{5}+1}{2}")); // `compile()` cannot handle `Expand`, so it falls back // and we use numerical evaluation with expr.N() console.log(compileOrEvaluate("\\operatorname{Expand}((x+1)^2)"));

Target coverage varies. Not every function that compiles to JavaScript also compiles to GLSL or interval-js. The JavaScript target has the broadest coverage (~120 functions including special functions, statistics, Bessel, and color operations). The GLSL target supports ~80 functions (basic math, trig, hyperbolic, gamma, erf, and color — but not statistics, Bessel, Zeta, LambertW, or iterative constructs like Sum/Product). The interval-js target covers ~60 functions (basic math, trig, hyperbolic, gamma).

When a target fails, callers should fall back to a more capable target (e.g., GLSL → JavaScript, interval-js → JavaScript).